diff --git a/Duplicati/UnitTest/DiskImage/DiskImageTests.cs b/Duplicati/UnitTest/DiskImage/DiskImageTests.cs
deleted file mode 100644
index 77447db70..000000000
--- a/Duplicati/UnitTest/DiskImage/DiskImageTests.cs
+++ /dev/null
@@ -1,1623 +0,0 @@
-// Copyright (C) 2026, The Duplicati Team
-// https://duplicati.com, hello@duplicati.com
-//
-// Permission is hereby granted, free of charge, to any person obtaining a
-// copy of this software and associated documentation files (the "Software"),
-// to deal in the Software without restriction, including without limitation
-// the rights to use, copy, modify, merge, publish, distribute, sublicense,
-// and/or sell copies of the Software, and to permit persons to whom the
-// Software is furnished to do so, subject to the following conditions:
-//
-// The above copyright notice and this permission notice shall be included in
-// all copies or substantial portions of the Software.
-//
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-// DEALINGS IN THE SOFTWARE.
-
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using Duplicati.Library.Interface;
-using Duplicati.Library.Main;
-using Duplicati.Proprietary.DiskImage;
-using Duplicati.Proprietary.DiskImage.General;
-using Duplicati.UnitTest.DiskImage.Helpers;
-using NUnit.Framework;
-using Assert = NUnit.Framework.Legacy.ClassicAssert;
-
-#nullable enable
-
-namespace Duplicati.UnitTest.DiskImage;
-
-///
-/// Unit tests for the DiskImage backup and restore functionality.
-/// These tests use disk image files managed to test the full backup and restore flow.
-///
-[TestFixture]
-[Category("DiskImage")]
-[Platform("Win,MacOsX,Linux")]
-public class DiskImageTests : BasicSetupHelper
-{
- private string _sourceImagePath = null!;
- private string _restoreImagePath = null!;
- private string _sourceMountPath = null!;
- private string _restoreMountPath = null!;
- private IDiskImageHelper _diskHelper = null!;
-
- private const long MiB = 1024 * 1024;
-
- ///
- /// Sets up the test environment before each test.
- /// Creates the disk helper and temporary disk image paths.
- ///
- [SetUp]
- public void DiskImageSetUp()
- {
- // Create the appropriate disk image helper for the current platform
- _diskHelper = DiskImageHelperFactory.Create();
-
- // Check for admin privileges
- if (!_diskHelper.HasRequiredPrivileges())
- {
- Assert.Ignore("DiskImage tests require administrator privileges");
- }
-
- // Create temp disk image paths
- var extension = DiskImageTestHelpers.GetPlatformDiskImageExtension();
- _sourceImagePath = Path.Combine(DATAFOLDER, $"duplicati_test_source_{Guid.NewGuid()}.{extension}");
- _restoreImagePath = Path.Combine(DATAFOLDER, $"duplicati_test_restore_{Guid.NewGuid()}.{extension}");
- _sourceMountPath = Path.Combine(DATAFOLDER, $"mnt_source_{Guid.NewGuid()}");
- _restoreMountPath = Path.Combine(DATAFOLDER, $"mnt_restore_{Guid.NewGuid()}");
- Directory.CreateDirectory(_sourceMountPath);
- Directory.CreateDirectory(_restoreMountPath);
- }
-
- ///
- /// Cleans up the test environment after each test.
- /// Detaches and deletes disk image files.
- ///
- [TearDown]
- public void DiskImageTearDown()
- {
- // Cleanup disk images
- try
- {
- _diskHelper.CleanupDisk(_sourceImagePath);
- }
- catch (Exception ex)
- {
- TestContext.Progress.WriteLine($"Warning: Failed to cleanup source disk image: {ex.Message}");
- }
-
- try
- {
- _diskHelper.CleanupDisk(_restoreImagePath);
- }
- catch (Exception ex)
- {
- TestContext.Progress.WriteLine($"Warning: Failed to cleanup restore disk image: {ex.Message}");
- }
-
- try
- {
- _diskHelper.Dispose();
- }
- catch (Exception ex)
- {
- TestContext.Progress.WriteLine($"Warning: Failed to dispose disk helper: {ex.Message}");
- }
-
- DiskImageTestHelpers.SafeDeleteFile(_sourceImagePath);
- DiskImageTestHelpers.SafeDeleteFile(_restoreImagePath);
- }
-
- #region GPT Single Partition
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_FAT32_Async() =>
- FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.FAT32, 0)]);
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_NTFS_Async()
- {
- if (!OperatingSystem.IsWindows())
- Assert.Ignore("Test_GPT_NTFS is only supported on Windows.");
- return FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.NTFS, 0)]);
- }
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_APFS_Async()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("APFS is only supported on macOS.");
- return FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.APFS, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_HFSPlus_Async()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("HFSPlus is only supported on macOS.");
- return FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.HFSPlus, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_ExFAT_Async() =>
- FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.ExFAT, 0)]);
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_Ext2_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Ext2 is only supported on Linux.");
- return FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.Ext2, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_Ext3_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Ext3 is only supported on Linux.");
- return FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.Ext3, 0)]);
- }
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_Ext4_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Ext4 is only supported on Linux.");
- return FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.Ext4, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_XFS_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("XFS is only supported on Linux.");
- return FullRoundTripAsync((int)(310 * MiB), PartitionTableType.GPT, [(FileSystemType.XFS, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_Btrfs_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Btrfs is only supported on Linux.");
- return FullRoundTripAsync((int)(110 * MiB), PartitionTableType.GPT, [(FileSystemType.Btrfs, 0)]);
- }
-
- #endregion
-
- #region MBR Single Partition
-
- // APFS is only supported on GPT partition tables.
-
- [Test, Category("DiskImage")]
- public Task Test_MBR_FAT32_Async() =>
- FullRoundTripAsync((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.FAT32, 0)]);
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_HFSPlus_Async()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("HFSPlus is only supported on macOS.");
- return FullRoundTripAsync((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.HFSPlus, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_ExFAT_Async() =>
- FullRoundTripAsync((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.ExFAT, 0)]);
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_NTFS_Async()
- {
- if (!OperatingSystem.IsWindows())
- Assert.Ignore("Test_MBR_NTFS is only supported on Windows.");
- return FullRoundTripAsync((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.NTFS, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_Ext2_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Ext2 is only supported on Linux.");
- return FullRoundTripAsync((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.Ext2, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_Ext3_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Ext3 is only supported on Linux.");
- return FullRoundTripAsync((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.Ext3, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_Ext4_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Ext4 is only supported on Linux.");
- return FullRoundTripAsync((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.Ext4, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_XFS_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("XFS is only supported on Linux.");
- return FullRoundTripAsync((int)(310 * MiB), PartitionTableType.MBR, [(FileSystemType.XFS, 0)]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_Btrfs_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Btrfs is only supported on Linux.");
- return FullRoundTripAsync((int)(110 * MiB), PartitionTableType.MBR, [(FileSystemType.Btrfs, 0)]);
- }
-
- #endregion
-
- #region Unknown Partition Table
-
- [Test, Category("DiskImage")]
- public Task Test_Unknown_NoPartitions_Async() =>
- FullRoundTripAsync((int)(50 * MiB), PartitionTableType.Unknown, []);
-
- #endregion
-
- #region GPT Two Partitions
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_FAT32_FAT32_Async() =>
- FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.FAT32, 0)
- ]);
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_APFS_APFS_Async()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("APFS is only supported on macOS.");
- return FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.APFS, 50 * MiB),
- (FileSystemType.APFS, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_HFSPlus_HFSPlus_Async()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("HFSPlus is only supported on macOS.");
- return FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.HFSPlus, 50 * MiB),
- (FileSystemType.HFSPlus, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_APFS_HFSPlus_Async()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("APFS and HFSPlus are only supported on macOS.");
- return FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.APFS, 50 * MiB),
- (FileSystemType.HFSPlus, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_FAT32_APFS_Async()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("APFS is only supported on macOS.");
- return FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.APFS, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_ExFAT_HFSPlus_Async()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("HFSPlus is only supported on macOS.");
- return FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.ExFAT, 50 * MiB),
- (FileSystemType.HFSPlus, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_FAT32_ExFAT_Async() =>
- FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.ExFAT, 0)
- ]);
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_NTFS_FAT32_Async()
- {
- if (!OperatingSystem.IsWindows())
- Assert.Ignore("This test is only supported on Windows.");
- return FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.NTFS, 50 * MiB),
- (FileSystemType.FAT32, 0)
- ]);
- }
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_NTFS_NTFS_Async()
- {
- if (!OperatingSystem.IsWindows())
- Assert.Ignore("This test is only supported on Windows.");
- return FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.NTFS, 50 * MiB),
- (FileSystemType.NTFS, 0)
- ]);
- }
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_Ext4_Ext4_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Ext4 is only supported on Linux.");
- return FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.Ext4, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_Ext4_FAT32_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.FAT32, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_Ext4_XFS_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTripAsync((int)(360 * MiB), PartitionTableType.GPT, [
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.XFS, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_XFS_FAT32_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTripAsync((int)(360 * MiB), PartitionTableType.GPT, [
- (FileSystemType.XFS, 310 * MiB),
- (FileSystemType.FAT32, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_Btrfs_FAT32_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTripAsync((int)(160 * MiB), PartitionTableType.GPT, [
- (FileSystemType.Btrfs, 110 * MiB),
- (FileSystemType.FAT32, 0)
- ]);
- }
-
- #endregion
-
- #region MBR Two Partitions
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_FAT32_FAT32_Async() =>
- FullRoundTripAsync((int)(100 * MiB), PartitionTableType.MBR, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.FAT32, 0)
- ]);
-
- // APFS is only supported on GPT partition tables.
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_HFSPlus_HFSPlus_Async()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("HFSPlus is only supported on macOS.");
- return FullRoundTripAsync((int)(100 * MiB), PartitionTableType.MBR, [
- (FileSystemType.HFSPlus, 50 * MiB),
- (FileSystemType.HFSPlus, 0)
- ]);
- }
-
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_FAT32_ExFAT_Async()
- {
- return FullRoundTripAsync((int)(100 * MiB), PartitionTableType.MBR, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.ExFAT, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_FAT32_HFSPlus_Async()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("HFSPlus is only supported on macOS.");
- return FullRoundTripAsync((int)(100 * MiB), PartitionTableType.MBR, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.HFSPlus, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_NTFS_FAT32_Async()
- {
- if (!OperatingSystem.IsWindows())
- Assert.Ignore("This test is only supported on Windows.");
- return FullRoundTripAsync((int)(100 * MiB), PartitionTableType.MBR, [
- (FileSystemType.NTFS, 50 * MiB),
- (FileSystemType.FAT32, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_Ext4_Ext4_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Ext4 is only supported on Linux.");
- return FullRoundTripAsync((int)(100 * MiB), PartitionTableType.MBR, [
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.Ext4, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_FAT32_Ext4_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTripAsync((int)(100 * MiB), PartitionTableType.MBR, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.Ext4, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_XFS_Ext4_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTripAsync((int)(360 * MiB), PartitionTableType.MBR, [
- (FileSystemType.XFS, 310 * MiB),
- (FileSystemType.Ext4, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_Btrfs_Ext4_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTripAsync((int)(160 * MiB), PartitionTableType.MBR, [
- (FileSystemType.Btrfs, 110 * MiB),
- (FileSystemType.Ext4, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_XFS_Btrfs_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTripAsync((int)(420 * MiB), PartitionTableType.MBR, [
- (FileSystemType.XFS, 310 * MiB),
- (FileSystemType.Btrfs, 0)
- ]);
- }
-
- #endregion
-
- #region GPT Three Partitions
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_FAT32_FAT32_FAT32_Async()
- {
- return FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.FAT32, 0)
- ]);
- }
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_APFS_APFS_APFS_Async()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("APFS is only supported on macOS.");
- return FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
- (FileSystemType.APFS, 50 * MiB),
- (FileSystemType.APFS, 50 * MiB),
- (FileSystemType.APFS, 0)
- ]);
- }
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_NTFS_NTFS_NTFS_Async()
- {
- if (!OperatingSystem.IsWindows())
- Assert.Ignore("NTFS is only supported on Windows.");
- return FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
- (FileSystemType.NTFS, 50 * MiB),
- (FileSystemType.NTFS, 50 * MiB),
- (FileSystemType.NTFS, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_HFSPlus_HFSPlus_HFSPlus_Async()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("HFSPlus is only supported on macOS.");
- return FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
- (FileSystemType.HFSPlus, 50 * MiB),
- (FileSystemType.HFSPlus, 50 * MiB),
- (FileSystemType.HFSPlus, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_APFS_HFSPlus_APFS_Async()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("APFS and HFSPlus are only supported on macOS.");
- return FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
- (FileSystemType.APFS, 50 * MiB),
- (FileSystemType.HFSPlus, 50 * MiB),
- (FileSystemType.APFS, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_FAT32_APFS_HFSPlus_Async()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("APFS and HFSPlus are only supported on macOS.");
- return FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.APFS, 50 * MiB),
- (FileSystemType.HFSPlus, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_ExFAT_APFS_HFSPlus_Async()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("APFS and HFSPlus are only supported on macOS.");
- return FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
- (FileSystemType.ExFAT, 50 * MiB),
- (FileSystemType.APFS, 50 * MiB),
- (FileSystemType.HFSPlus, 0)
- ]);
- }
-
- [Test, Category("DiskImage")]
- public Task Test_GPT_Ext4_Ext4_Ext4_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Ext4 is only supported on Linux.");
- return FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.Ext4, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_FAT32_Ext4_XFS_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTripAsync((int)(410 * MiB), PartitionTableType.GPT, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.XFS, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_Ext4_XFS_Btrfs_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTripAsync((int)(470 * MiB), PartitionTableType.GPT, [
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.XFS, 310 * MiB),
- (FileSystemType.Btrfs, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_XFS_Ext4_Ext4_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTripAsync((int)(410 * MiB), PartitionTableType.GPT, [
- (FileSystemType.XFS, 310 * MiB),
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.Ext4, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_Btrfs_Ext4_FAT32_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTripAsync((int)(210 * MiB), PartitionTableType.GPT, [
- (FileSystemType.Btrfs, 110 * MiB),
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.FAT32, 0)
- ]);
- }
-
- #endregion
-
- #region MBR Three Partitions
-
- // APFS is only supported on GPT partition tables.
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_FAT32_FAT32_FAT32_Async()
- {
- return FullRoundTripAsync((int)(150 * MiB), PartitionTableType.MBR, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.FAT32, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_HFSPlus_HFSPlus_HFSPlus_Async()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("HFSPlus is only supported on macOS.");
- return FullRoundTripAsync((int)(150 * MiB), PartitionTableType.MBR, [
- (FileSystemType.HFSPlus, 50 * MiB),
- (FileSystemType.HFSPlus, 50 * MiB),
- (FileSystemType.HFSPlus, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_FAT32_ExFAT_HFSPlus_Async()
- {
- if (!OperatingSystem.IsMacOS())
- Assert.Ignore("HFSPlus is only supported on macOS.");
- return FullRoundTripAsync((int)(150 * MiB), PartitionTableType.MBR, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.ExFAT, 50 * MiB),
- (FileSystemType.HFSPlus, 0)
- ]);
- }
-
- // GPT NTFS for Windows - local-only (tested via single partition)
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_GPT_NTFS_FAT32_ExFAT_Async()
- {
- if (!OperatingSystem.IsWindows())
- Assert.Ignore("This test is only supported on Windows.");
- return FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
- (FileSystemType.NTFS, 50 * MiB),
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.ExFAT, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_Ext4_Ext4_Ext4_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("Ext4 is only supported on Linux.");
- return FullRoundTripAsync((int)(150 * MiB), PartitionTableType.MBR, [
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.Ext4, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_FAT32_Ext4_XFS_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTripAsync((int)(410 * MiB), PartitionTableType.MBR, [
- (FileSystemType.FAT32, 50 * MiB),
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.XFS, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_Ext4_XFS_Btrfs_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTripAsync((int)(470 * MiB), PartitionTableType.MBR, [
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.XFS, 310 * MiB),
- (FileSystemType.Btrfs, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_XFS_Btrfs_Ext4_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTripAsync((int)(470 * MiB), PartitionTableType.MBR, [
- (FileSystemType.XFS, 310 * MiB),
- (FileSystemType.Btrfs, 110 * MiB),
- (FileSystemType.Ext4, 0)
- ]);
- }
-
- [Test, Category("DiskImage"), Category("DiskImageLocal")]
- public Task Test_MBR_Btrfs_Ext4_XFS_Async()
- {
- if (!OperatingSystem.IsLinux())
- Assert.Ignore("This test is only supported on Linux.");
- return FullRoundTripAsync((int)(470 * MiB), PartitionTableType.MBR, [
- (FileSystemType.Btrfs, 110 * MiB),
- (FileSystemType.Ext4, 50 * MiB),
- (FileSystemType.XFS, 0)
- ]);
- }
-
- #endregion
-
- public async Task FullRoundTripAsync(int size, PartitionTableType tableType, (FileSystemType, long)[] partitions)
- {
- await TestContext.Progress.WriteLineAsync("Test: Full Round-Trip Backup + Restore");
-
- var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, size);
- await TestContext.Progress.WriteLineAsync($"Source Disk created at: {_sourceImagePath}");
-
- var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, tableType, partitions);
- await TestContext.Progress.WriteLineAsync($"Source Disk initialized with partition(s): {string.Join(", ", sourcePartitions)}");
-
- // Populate source partition with test data
- foreach (var partition in sourcePartitions)
- await ToolTests.GenerateTestDataAsync(partition, 10, 5, 2, 1024);
- _diskHelper.FlushDisk(sourceDrivePath);
- _diskHelper.Unmount(sourceDrivePath);
- await TestContext.Progress.WriteLineAsync($"Test data generated on source partition(s)");
-
- // Backup
- var backupResults = await RunBackupAsync(sourceDrivePath);
- TestUtils.AssertResults(backupResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
- await TestContext.Progress.WriteLineAsync($"Backup completed successfully");
-
- // Setup restore target disk image with same geometry
- var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, size);
- _diskHelper.InitializeDisk(restoreDrivePath, PartitionTableType.GPT, []);
- _diskHelper.Unmount(restoreDrivePath);
- await TestContext.Progress.WriteLineAsync($"Restore disk image created at: {_restoreImagePath}");
-
- // Restore
- var restoreResults = await RunRestoreAsync(restoreDrivePath);
- TestUtils.AssertResults(restoreResults);
- await TestContext.Progress.WriteLineAsync($"Restore completed successfully");
-
- // Reattach drives in readonly
- sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
- restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, tableType, readOnly: true);
- await TestContext.Progress.WriteLineAsync($"Source and restore disks re-attached as read-only for verification");
-
- // Verify partition table matches. Mount before verification, to make disks online on Windows.
- string[] restorePartitions = [];
- if (tableType != PartitionTableType.Unknown)
- {
- var fsTypes = partitions.Select(p => p.Item1).ToArray();
- sourcePartitions = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true, fileSystemTypes: fsTypes);
- restorePartitions = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true, fileSystemTypes: fsTypes);
- }
- VerifyPartitionTableMatches(sourceDrivePath, restoreDrivePath);
- await TestContext.Progress.WriteLineAsync($"Partition table verified to match source");
-
- // Verify data matches byte-for-byte
- foreach (var (sourcePartition, restorePartition) in sourcePartitions.Zip(restorePartitions, (s, r) => (s, r)))
- CompareDirectories(sourcePartition, restorePartition);
-
- await TestContext.Progress.WriteLineAsync($"Restored data verified to match source");
- }
-
- [Test]
- [Category("DiskImage")]
- public async Task Test_GeometryMetadata_Verification_Async()
- {
- await TestContext.Progress.WriteLineAsync("Test: Full Round-Trip Backup + Restore");
-
- var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, 100 * MiB);
- await TestContext.Progress.WriteLineAsync($"Source Disk created at: {_sourceImagePath}");
-
- (FileSystemType, long)[] partitions = [(FileSystemType.FAT32, 50 * MiB), (FileSystemType.FAT32, 0)];
- var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, PartitionTableType.GPT, partitions);
- _diskHelper.Unmount(sourceDrivePath);
- await TestContext.Progress.WriteLineAsync($"Source Disk initialized with partition(s): {string.Join(", ", sourcePartitions)}");
-
- // Backup
- var backupResults = await RunBackupAsync(sourceDrivePath);
- TestUtils.AssertResults(backupResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
-
- // List backup contents and verify geometry.json is present
- using (var c = new Controller("file://" + TARGETFOLDER, TestOptions, null))
- {
- var listResults = await c.ListAsync("*").ConfigureAwait(false);
- TestUtils.AssertResults(listResults);
-
- // Check that geometry.json is in the backup
- var files = listResults.Files;
- Assert.That(files, Is.Not.Null);
- var geometryFile = files.FirstOrDefault(f => f.Path.EndsWith("geometry.json"));
- Assert.That(geometryFile, Is.Not.Null, "geometry.json should be present in the backup");
- }
-
- // Verify geometry metadata contains correct information
- await VerifyGeometryMetadataAsync(TARGETFOLDER, TestOptions, partitions);
- }
-
- [Test]
- [Category("DiskImage")]
- public async Task Test_SourceProvider_Enumeration_Async()
- {
- await TestContext.Progress.WriteLineAsync("Test: SourceProvider Enumeration");
-
- // Setup: Create GPT disk with one FAT32 partition
- var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, (100 * MiB));
- var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, PartitionTableType.GPT, [(FileSystemType.FAT32, 0)]);
- await TestContext.Progress.WriteLineAsync($"Source disk image created at: {_sourceImagePath}");
-
- // Directly instantiate SourceProvider
- // Note: When directly instantiating SourceProvider, we don't use the @ prefix
- // The @ prefix is only for Controller to recognize it as a remote source
- var sourceUrl = $"diskimage://{sourceDrivePath}";
- using var provider = new SourceProvider(sourceUrl, "", new Dictionary());
-
- // Initialize the provider
- await provider.InitializeAsync(CancellationToken.None);
-
- // Enumerate entries
- var diskEntries = new List();
- await foreach (var entry in provider.EnumerateAsync(CancellationToken.None))
- {
- diskEntries.Add(entry);
- await TestContext.Progress.WriteLineAsync($"Found entry: {entry.Path} (IsFolder: {entry.IsFolder}, Size: {entry.Size})");
- }
-
- // Verify hierarchy: disk → partition → filesystem → files
- Assert.That(diskEntries.Count, Is.GreaterThan(0), "Should have at least one entry (the disk)");
-
- // Fetch the rest of the enumaration.
- // Check for disk entry
- var diskEntry = diskEntries.FirstOrDefault(e => e.IsRootEntry);
- Assert.That(diskEntry, Is.Not.Null, "Should have a root disk entry");
-
- var entries = new List();
- await foreach (var entry in diskEntry!.Enumerate(CancellationToken.None))
- entries.Add(entry);
-
- await TestContext.Progress.WriteLineAsync($"Total entries found: {entries.Count}");
-
- // Check for geometry.json
- var geometryEntry = entries.FirstOrDefault(e => e.Path.EndsWith("geometry.json"));
- Assert.That(geometryEntry, Is.Not.Null, "Should have geometry.json entry");
-
- // Check for partition entries
- var partitionEntries = entries.Where(e => e.Path.Contains("part_")).ToList();
- Assert.That(partitionEntries.Count, Is.GreaterThan(0), "Should have at least one partition entry");
-
- // Add all of the filesystems.
- var fsEntries = new List();
- foreach (var partitionEntry in partitionEntries)
- await foreach (var entry in partitionEntry.Enumerate(CancellationToken.None))
- fsEntries.Add(entry);
-
- // Check for filesystem entries
- Assert.That(fsEntries.All(x => x.Path.Contains("fs_")));
- Assert.That(fsEntries.Count, Is.GreaterThan(0), "Should have at least one filesystem entry");
- }
-
- [Test]
- [Category("DiskImage")]
- public async Task Test_AutoUnmountOption_RestoreWhileOnline_Async()
- {
- await TestContext.Progress.WriteLineAsync("Test: Auto Unmount Option - Restore While Disk Online");
-
- // Setup source disk image: 100MB, GPT, single FAT32 partition
- var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, (100 * MiB));
- var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, PartitionTableType.GPT, [(FileSystemType.FAT32, 0)]);
- await TestContext.Progress.WriteLineAsync($"Source disk image created at: {_sourceImagePath}");
-
- // Generate some test data
- await ToolTests.GenerateTestDataAsync(sourcePartitions.First(), 10, 5, 2, 1024);
- _diskHelper.FlushDisk(sourceDrivePath);
- _diskHelper.Unmount(sourceDrivePath);
- await TestContext.Progress.WriteLineAsync($"Test data generated on source partition");
-
- // Backup
- var backupResults = await RunBackupAsync(sourceDrivePath);
- TestUtils.AssertResults(backupResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
- await TestContext.Progress.WriteLineAsync($"Backup completed successfully");
-
- // Create and attach disk image
- var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, (100 * MiB));
-
- // Initialize disk (the restore will overwrite this, but we need it formatted)
- _diskHelper.InitializeDisk(restoreDrivePath, PartitionTableType.GPT, [(FileSystemType.FAT32, 50 * MiB), (FileSystemType.FAT32, 0)]);
- _diskHelper.Mount(restoreDrivePath, _restoreMountPath);
- await TestContext.Progress.WriteLineAsync($"Restore disk image created and kept online at: {_restoreImagePath}");
-
- // First attempt: Restore without auto-unmount option should fail
- // because the disk is online and in use
- await TestContext.Progress.WriteLineAsync("Attempting restore without auto-unmount (should fail)...");
- var options = new Dictionary(TestOptions)
- {
- ["restore-path"] = $"@diskimage://{restoreDrivePath}",
- ["overwrite"] = "true",
- ["restore-file-processors"] = "1",
- // Explicitly disable auto-unmount (though it's disabled by default)
- ["diskimage-restore-auto-unmount"] = "false"
- };
-
- using (var c = new Controller("file://" + TARGETFOLDER, options, null))
- {
- try
- {
- var results = await c.RestoreAsync(["*"]).ConfigureAwait(false);
-
- // Verify that the restore failed (has errors)
- Assert.That(results.Errors.Any(), Is.True,
- "Restore should fail when target disk is online and auto-unmount is not enabled");
- await TestContext.Progress.WriteLineAsync($"Restore failed as expected with errors: {string.Join(", ", results.Errors)}");
- }
- catch (IOException)
- {
- // Assumed to fail
- }
- catch (UserInformationException)
- {
- // Assumed to fail
- }
- }
-
- // Second attempt: Restore with auto-unmount option should succeed
- await TestContext.Progress.WriteLineAsync("Attempting restore with auto-unmount enabled (should succeed)...");
-
- options["diskimage-restore-auto-unmount"] = "true";
- using (var c = new Controller("file://" + TARGETFOLDER, options, null))
- {
- var results = await c.RestoreAsync(["*"]).ConfigureAwait(false);
-
- Assert.That(!results.Errors.Any() && results.Warnings.Count() <= 1);
- }
- await TestContext.Progress.WriteLineAsync($"Restore completed successfully with auto-unmount option");
-
- // Mount before verification, to make disks online on Windows.
- sourcePartitions = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true);
- var restorePartitions = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true);
-
- // Verify partition table matches
- VerifyPartitionTableMatches(sourceDrivePath, restoreDrivePath);
-
- // Verify data matches byte-for-byte
- var sourcePartition = sourcePartitions.First();
- var restorePartition = restorePartitions.First();
- CompareDirectories(sourcePartition, restorePartition);
- }
-
- #region Helper Methods
-
- ///
- /// Runs a backup operation using the Controller.
- ///
- /// The physical drive path to backup.
- /// If true, treats filesystem as unknown (forces raw block-based backup).
- /// The backup results.
- private async Task RunBackupAsync(string physicalDrivePath, bool treatFilesystemAsUnknown = false)
- {
- var options = new Dictionary(TestOptions);
- options["enable-module"] = "diskimage";
- options["concurrency-fileprocessors"] = "1";
-
- if (!treatFilesystemAsUnknown)
- {
- options["diskimage-filesystem-parsed"] = "true";
- }
-
- using var c = new Controller("file://" + TARGETFOLDER, options, null);
- var sourceUrl = $"@/testdisk|diskimage://{physicalDrivePath}";
- return await c.BackupAsync(new[] { sourceUrl }).ConfigureAwait(false);
- }
-
- ///
- /// Runs a restore operation using the Controller.
- ///
- /// The physical drive path to restore to.
- /// The restore results.
- private Task RunRestoreAsync(string restoreDrivePath)
- {
- var options = new Dictionary(TestOptions);
- options["restore-path"] = $"@diskimage://{restoreDrivePath}";
- options["overwrite"] = "true";
- options["restore-file-processors"] = "1";
-
- using var c = new Controller("file://" + TARGETFOLDER, options, null);
- return c.RestoreAsync(new[] { "*" });
- }
-
- ///
- /// Recursively compares two directories for structural and content equality.
- ///
- /// The source directory path.
- /// The restored directory path.
- private void CompareDirectories(string sourcePath, string restorePath)
- {
- var options = new EnumerationOptions
- {
- RecurseSubdirectories = true,
- IgnoreInaccessible = true,
- AttributesToSkip = FileAttributes.System | FileAttributes.ReparsePoint
- };
-
- // Get all files in source, except for system files, which cannot be read directly
- var sourceFiles = Directory.EnumerateFiles(sourcePath, "*", options)
- .Select(f => Path.GetRelativePath(sourcePath, f))
- .Where(f => !string.IsNullOrEmpty(f))
- .OrderBy(f => f)
- .ToList();
- var restoreFiles = Directory.EnumerateFiles(restorePath, "*", options)
- .Select(f => Path.GetRelativePath(restorePath, f))
- .Where(f => !string.IsNullOrEmpty(f))
- .OrderBy(f => f)
- .ToList();
-
- Assert.That(sourceFiles.Count, Is.EqualTo(restoreFiles.Count),
- $"Number of files in source and restore should match. Source: {sourceFiles.Count}, Restore: {restoreFiles.Count}");
- Assert.That(sourceFiles.Count, Is.GreaterThan(0),
- "Source partition should contain files to verify");
- Assert.That(restoreFiles.Count, Is.GreaterThan(0),
- "Restore partition should contain files to verify");
-
- // Check that all source files exist in restore
- foreach (var relativePath in sourceFiles)
- {
- Assert.That(restoreFiles, Does.Contain(relativePath),
- $"Restored drive should contain file: {relativePath}");
- }
-
- // Compare file contents byte-by-byte
- foreach (var relativeSourceFile in sourceFiles)
- {
- var sourceFile = Path.Combine(sourcePath, relativeSourceFile);
- var restoreFile = Path.Combine(restorePath, relativeSourceFile);
-
- if (File.Exists(restoreFile))
- {
- var sourceBytes = File.ReadAllBytes(sourceFile);
- var restoreBytes = File.ReadAllBytes(restoreFile);
-
- Assert.That(restoreBytes.Length, Is.EqualTo(sourceBytes.Length),
- $"File size mismatch for {sourceFile}");
- Assert.That(restoreBytes, Is.EqualTo(sourceBytes),
- $"File content mismatch for {sourceFile}");
- }
- }
-
- TestContext.Progress.WriteLine($"Verified {sourceFiles.Count} files match between source and restored drives");
- }
-
- ///
- /// Verifies that the geometry metadata is present and valid.
- ///
- /// The backup target URL.
- /// The options to use when accessing the backup.
- /// The expected partitions and their sizes.
- private async Task VerifyGeometryMetadataAsync(string target, Dictionary options, (FileSystemType, long)[] partitions)
- {
- await TestContext.Progress.WriteLineAsync("Verifying geometry metadata...");
-
- using (var c = new Controller("file://" + target, options, null))
- {
- var listResults = await c.ListAsync("*").ConfigureAwait(false);
- Assert.That(listResults.Files, Is.Not.Null);
-
- var geometryFile = listResults.Files.FirstOrDefault(f => f.Path.EndsWith("geometry.json"));
- Assert.That(geometryFile, Is.Not.Null, "geometry.json should be present in the backup");
-
- // Restore and parse the geometry.json file
- var geometryPath = Path.Combine(RESTOREFOLDER, $"geometry.json");
- try
- {
- var restoreOptions = new Dictionary(options)
- {
- ["restore-path"] = RESTOREFOLDER,
- ["overwrite"] = "true"
- };
-
- using (var restoreController = new Controller("file://" + target, restoreOptions, null))
- {
- var restoreResults = await restoreController.RestoreAsync(new[] { geometryFile!.Path });
- TestUtils.AssertResults(restoreResults);
- }
-
- // Read and parse the geometry.json
- if (File.Exists(geometryPath))
- {
- var json = File.ReadAllText(geometryPath);
- var geometry = GeometryMetadata.FromJson(json);
-
- Assert.That(geometry, Is.Not.Null, "geometry.json should deserialize successfully");
- Assert.That(geometry!.Version, Is.GreaterThan(0), "Geometry version should be set");
- Assert.That(geometry.Disk, Is.Not.Null, "Disk geometry should be present");
- Assert.That(geometry.Disk!.Size, Is.GreaterThan(0), "Disk size should be greater than 0");
- Assert.That(geometry.Disk.SectorSize, Is.GreaterThan(0), "Sector size should be greater than 0");
- Assert.That(geometry.PartitionTable, Is.Not.Null, "Partition table should be present");
- Assert.That(geometry.Partitions, Is.Not.Null, "Partitions list should be present");
- Assert.That(geometry.Partitions!.Count, Is.GreaterThan(0), "Should have at least one partition");
-
- await TestContext.Progress.WriteLineAsync($"Geometry metadata verified: Disk size={geometry.Disk.Size}, " +
- $"Sector size={geometry.Disk.SectorSize}, Partitions={geometry.Partitions.Count}, " +
- $"Table type={geometry.PartitionTable!.Type}");
-
- for (int i = 0; i < geometry.Partitions.Count; i++)
- {
- var partition = geometry.Partitions[i];
- await TestContext.Progress.WriteLineAsync($"Partition {i}: Type={partition.Type}, Start={partition.StartOffset}, Size={partition.Size}");
-
- Assert.That(partition.Size, Is.GreaterThan(0), $"Partition {i} size should be greater than 0");
- if (partitions[i].Item2 > 0)
- {
- Assert.That(partition.Size, Is.EqualTo(partitions[i].Item2), $"Partition {i} size should match expected");
- }
- Assert.That(partition.StartOffset, Is.GreaterThanOrEqualTo(0), $"Partition {i} start offset should be non-negative");
- Assert.That(partition.Type, Is.Not.Null, $"Partition {i} type should be present");
- Assert.That(partition.FilesystemType, Is.Not.Null, $"Partition {i} filesystem type should be present");
- // Type shouldn't be checked until implemented.
- //Assert.That(partition.FilesystemType, Is.EqualTo(partitions[i].Item1), $"Partition {i} filesystem type should be known");
- }
- }
- }
- finally
- {
- try
- {
- if (File.Exists(geometryPath))
- {
- File.Delete(geometryPath);
- }
- }
- catch
- {
- // Ignore cleanup errors
- }
- }
- }
- }
-
- ///
- /// Verifies that the restored partition table matches the source.
- ///
- /// The physical drive path of the source disk.
- /// The physical drive path of the restored disk.
- private void VerifyPartitionTableMatches(string sourceDrivePath, string restoreDrivePath)
- {
- TestContext.Progress.WriteLine("Verifying partition table matches...");
-
- // Get source disk details
- var sourceTable = _diskHelper.GetPartitionTable(sourceDrivePath);
- TestContext.Progress.WriteLine("Source disk details:\n" + sourceTable);
-
- // Get restore disk details
- var restoreTable = _diskHelper.GetPartitionTable(restoreDrivePath);
- TestContext.Progress.WriteLine("Restore disk details:\n" + restoreTable);
-
- Assert.AreEqual(sourceTable.Type, restoreTable.Type, "Partition table types should match");
- Assert.AreEqual(sourceTable.SectorSize, restoreTable.SectorSize, "Sector sizes should match");
- Assert.AreEqual(sourceTable.Size, restoreTable.Size, "Size of the disks should match");
-
- // Retrieve partition information
- var sourcePartitions = _diskHelper.GetPartitions(sourceDrivePath);
- var restorePartitions = _diskHelper.GetPartitions(restoreDrivePath);
-
- // Verify partition counts match
- Assert.AreEqual(sourcePartitions.Length, restorePartitions.Length, "Number of partitions should match");
-
- // Verify partitions match
- for (int i = 0; i < sourcePartitions.Length; i++)
- {
- Assert.AreEqual(sourcePartitions[i].Type, restorePartitions[i].Type, $"Partition {i} types should match");
- Assert.AreEqual(sourcePartitions[i].StartOffset, restorePartitions[i].StartOffset, $"Partition {i} offsets should match");
- Assert.AreEqual(sourcePartitions[i].Size, restorePartitions[i].Size, $"Partition {i} sizes should match");
- }
-
- TestContext.Progress.WriteLine($"Partition table verified.");
- }
-
- #endregion
-
- #region Filesystem-Aware Integration Tests
-
- ///
- /// Tests that incremental backup with filesystem awareness skips unchanged blocks.
- /// Creates a disk with the specified filesystem, performs initial backup, modifies one file,
- /// performs second backup, and verifies only changed blocks were processed.
- ///
- [TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.FAT32)]
- [TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.Unknown)]
- [Category("DiskImage")]
- [Category("DiskImageFileSystem")]
- public async Task Test_FileSystem_IncrementalBackup_UnchangedBlocks_Skipped_Async(int size, PartitionTableType tableType, FileSystemType fsType)
- {
- await TestContext.Progress.WriteLineAsync($"Test: {fsType} Incremental Backup - Unchanged Blocks Skipped");
-
- // When testing Unknown filesystem, format source as FAT32 to allow test data generation,
- // but treat it as unknown during backup
- var sourceFsType = fsType == FileSystemType.Unknown ? FileSystemType.FAT32 : fsType;
- var treatFilesystemAsUnknown = fsType == FileSystemType.Unknown;
-
- // Create source disk with specified partition
- var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, size);
- var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, tableType, [(sourceFsType, 0)]);
- await TestContext.Progress.WriteLineAsync($"Source disk created at: {_sourceImagePath}");
-
- // Generate initial test data
- await ToolTests.GenerateTestDataAsync(sourcePartitions.First(), 10, 3, 1, 1024);
- _diskHelper.FlushDisk(sourceDrivePath);
- _diskHelper.Unmount(sourceDrivePath);
- await TestContext.Progress.WriteLineAsync("Initial test data generated");
-
- // First backup
- var firstBackupResults = await RunBackupAsync(sourceDrivePath, treatFilesystemAsUnknown);
-
- // When treating filesystem as unknown, the option may not be recognized by the Controller
- // so we only check for errors, not warnings
- if (treatFilesystemAsUnknown)
- {
- Assert.That(firstBackupResults.Errors.Count(), Is.EqualTo(0),
- "Backup should have no errors");
- }
- else
- {
- TestUtils.AssertResults(firstBackupResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
- }
-
- var firstBackupSize = firstBackupResults.SizeOfOpenedFiles;
- await TestContext.Progress.WriteLineAsync($"First backup completed: {firstBackupResults.OpenedFiles} files opened, {firstBackupSize} bytes");
-
- // Verify first backup processed the expected amount of data
- // With FAT32 awareness, unallocated blocks are returned as zeros (no disk read)
- Assert.That(firstBackupResults.SizeOfOpenedFiles, Is.GreaterThan(0),
- "First backup should have opened and processed files");
-
- // Create restore disk for initial restore verification
- var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, size);
- _diskHelper.InitializeDisk(restoreDrivePath, tableType, []);
- _diskHelper.Unmount(restoreDrivePath);
- await TestContext.Progress.WriteLineAsync($"Restore disk created at: {_restoreImagePath}");
-
- var restoreResults = await RunRestoreAsync(restoreDrivePath);
- TestUtils.AssertResults(restoreResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
- await TestContext.Progress.WriteLineAsync("Initial restore completed successfully");
-
- // Reattach and verify initial restore
- sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
- restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, tableType, readOnly: true);
-
- var fsTypes = new[] { sourceFsType };
- var sourceMounted = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true, fileSystemTypes: fsTypes);
- var restoreMounted = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true, fileSystemTypes: fsTypes);
-
- CompareDirectories(sourceMounted.First(), restoreMounted.First());
- await TestContext.Progress.WriteLineAsync("Initial restore verified successfully");
-
- // Unmount both disks to prepare for modification
- _diskHelper.Unmount(sourceDrivePath);
- _diskHelper.Unmount(restoreDrivePath);
- await TestContext.Progress.WriteLineAsync("Both disks unmounted");
-
- // Remount source drive in write-enabled mode
- sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: false);
- sourceMounted = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: false, fileSystemTypes: fsTypes);
- await TestContext.Progress.WriteLineAsync("Source disk remounted in write mode");
-
- // Modify a few files on the source - create new files and modify existing ones
- var sourcePartitionPath = sourceMounted.First();
- var modifiedFiles = new List();
-
- // Create a new directory with some new files
- var newDirPath = Path.Combine(sourcePartitionPath, "incremental_new_files");
- Directory.CreateDirectory(newDirPath);
- for (int i = 0; i < 3; i++)
- {
- var newFilePath = Path.Combine(newDirPath, $"new_file_{i}.txt");
- var content = $"This is new content for incremental backup test - file {i} - {Guid.NewGuid()}";
- await File.WriteAllTextAsync(newFilePath, content);
- // Set a recent modification time to ensure the backup detects the change
- File.SetLastWriteTimeUtc(newFilePath, DateTime.UtcNow);
- modifiedFiles.Add(newFilePath);
- }
- // Also update directory timestamp
- Directory.SetLastWriteTimeUtc(newDirPath, DateTime.UtcNow);
- await TestContext.Progress.WriteLineAsync($"Created {modifiedFiles.Count} new files in {newDirPath}");
-
- // Modify an existing file if one exists
- var existingFiles = Directory.GetFiles(sourcePartitionPath, "*.txt", SearchOption.AllDirectories)
- .Where(f => !f.Contains("incremental_new_files"))
- .ToArray();
- if (existingFiles.Length > 0)
- {
- var fileToModify = existingFiles[0];
- var originalContent = await File.ReadAllTextAsync(fileToModify);
- var modifiedContent = originalContent + $"\n\nModified for incremental backup test - {Guid.NewGuid()}";
- await File.WriteAllTextAsync(fileToModify, modifiedContent);
- // Update modification time to ensure backup detects the change
- File.SetLastWriteTimeUtc(fileToModify, DateTime.UtcNow.AddSeconds(1));
- modifiedFiles.Add(fileToModify);
- await TestContext.Progress.WriteLineAsync($"Modified existing file: {fileToModify}");
- }
-
- // Flush and unmount source after modifications
- _diskHelper.FlushDisk(sourceDrivePath);
- _diskHelper.Unmount(sourceDrivePath);
- await TestContext.Progress.WriteLineAsync("Modifications complete, source disk unmounted");
-
- // Re-attach the source disk for backup (disk needs to be attached for backup to access it)
- sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
- await TestContext.Progress.WriteLineAsync($"Source disk re-attached for second backup at: {sourceDrivePath}");
-
- // Second backup (incremental)
- var secondBackupResults = await RunBackupAsync(sourceDrivePath, treatFilesystemAsUnknown);
-
- // When treating filesystem as unknown, the option may not be recognized by the Controller
- // so we only check for errors, not warnings
- if (treatFilesystemAsUnknown)
- {
- Assert.That(secondBackupResults.Errors.Count(), Is.EqualTo(0),
- "Backup should have no errors");
- }
- else
- {
- TestUtils.AssertResults(secondBackupResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
- }
-
- var secondBackupSize = secondBackupResults.SizeOfOpenedFiles;
- await TestContext.Progress.WriteLineAsync($"Second backup completed: {secondBackupResults.OpenedFiles} files opened, {secondBackupSize} bytes examined, {secondBackupResults.SizeOfAddedFiles} bytes added");
-
- if (fsType is not FileSystemType.Unknown)
- {
- // Verify the second backup processed significantly fewer files than the first
- // The second backup should only process modified/new files, not all files
- Assert.That(secondBackupResults.OpenedFiles, Is.LessThan(firstBackupResults.OpenedFiles),
- "Incremental backup should open fewer files than the initial backup");
- Assert.That(secondBackupResults.SizeOfOpenedFiles, Is.LessThan(firstBackupResults.SizeOfOpenedFiles),
- "Incremental backup should examine less data than the initial backup");
- }
-
- // Also verify that the second backup actually modified some files
- Assert.That(secondBackupResults.ModifiedFiles, Is.GreaterThan(0),
- "Incremental backup should have added new or modified files");
-
- // Calculate and log the reduction ratio
- var fileReductionRatio = (double)secondBackupResults.OpenedFiles / firstBackupResults.OpenedFiles;
- var sizeReductionRatio = (double)secondBackupResults.SizeOfOpenedFiles / firstBackupResults.SizeOfOpenedFiles;
- await TestContext.Progress.WriteLineAsync($"File reduction: {fileReductionRatio:P2} ({secondBackupResults.OpenedFiles}/{firstBackupResults.OpenedFiles})");
- await TestContext.Progress.WriteLineAsync($"Size reduction: {sizeReductionRatio:P2} ({secondBackupResults.SizeOfOpenedFiles}/{firstBackupResults.SizeOfOpenedFiles})");
-
- // Restore the incremental backup to a fresh disk
- _diskHelper.CleanupDisk(_restoreImagePath);
- DiskImageTestHelpers.SafeDeleteFile(_restoreImagePath);
-
- restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, size);
- _diskHelper.InitializeDisk(restoreDrivePath, tableType, []);
- _diskHelper.Unmount(restoreDrivePath);
- await TestContext.Progress.WriteLineAsync($"Fresh restore disk created for incremental restore");
-
- var incrementalRestoreResults = await RunRestoreAsync(restoreDrivePath);
- TestUtils.AssertResults(incrementalRestoreResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
- await TestContext.Progress.WriteLineAsync("Incremental restore completed successfully");
-
- // Reattach and verify the incremental restore matches the modified source
- sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
- restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, tableType, readOnly: true);
-
- sourceMounted = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true, fileSystemTypes: fsTypes);
- restoreMounted = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true, fileSystemTypes: fsTypes);
-
- CompareDirectories(sourceMounted.First(), restoreMounted.First());
-
- await TestContext.Progress.WriteLineAsync($"{fsType} incremental backup and restore verified successfully");
- }
-
- ///
- /// Tests that unallocated space compresses well due to zero-block deduplication.
- /// Creates a disk with the specified filesystem and sparse data (few files, lots of free space),
- /// backs it up, and verifies the backup size is significantly smaller than the disk size.
- ///
- [TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.FAT32)]
- [TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.Unknown)]
- [Category("DiskImage")]
- [Category("DiskImageFileSystem")]
- public async Task Test_FileSystem_UnallocatedSpace_CompressesWell_Async(int size, PartitionTableType tableType, FileSystemType fsType)
- {
- await TestContext.Progress.WriteLineAsync($"Test: {fsType} Unallocated Space Compression");
-
- // When testing Unknown filesystem, format source as FAT32 to allow test data generation,
- // but treat it as unknown during backup
- var sourceFsType = fsType == FileSystemType.Unknown ? FileSystemType.FAT32 : fsType;
- var treatFilesystemAsUnknown = fsType == FileSystemType.Unknown;
-
- // Create source disk with specified partition
- var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, size);
- var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, tableType, [(sourceFsType, 0)]);
- await TestContext.Progress.WriteLineAsync($"Source disk created at: {_sourceImagePath}");
-
- // Generate minimal test data (sparse data - only a few small files)
- // This leaves most of the 50MB as unallocated space
- await ToolTests.GenerateTestDataAsync(sourcePartitions.First(), 3, 0, 0, 512);
- _diskHelper.FlushDisk(sourceDrivePath);
- _diskHelper.Unmount(sourceDrivePath);
- await TestContext.Progress.WriteLineAsync("Sparse test data generated (few files, lots of free space)");
-
- // Backup
- var backupResults = await RunBackupAsync(sourceDrivePath, treatFilesystemAsUnknown);
- // When treating filesystem as unknown, the option may not be recognized by the Controller
- // so we only check for errors, not warnings
- if (treatFilesystemAsUnknown)
- {
- Assert.That(backupResults.Errors.Count(), Is.EqualTo(0),
- "Backup should have no errors");
- }
- else
- {
- TestUtils.AssertResults(backupResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
- }
- await TestContext.Progress.WriteLineAsync($"Backup completed: {backupResults.SizeOfOpenedFiles} bytes examined");
-
- // Calculate the actual backup size from the target folder
- var backupFiles = Directory.GetFiles(TARGETFOLDER, "*", SearchOption.AllDirectories);
- long actualBackupSize = backupFiles.Sum(f => new FileInfo(f).Length);
- await TestContext.Progress.WriteLineAsync($"Actual backup size on disk: {actualBackupSize} bytes");
-
- // The backup should be significantly smaller than the full disk size
- // With zero-block deduplication, unallocated clusters return zeros which compress very well
- var compressionRatio = (double)actualBackupSize / size;
- await TestContext.Progress.WriteLineAsync($"Compression ratio: {compressionRatio:P2} ({actualBackupSize}/{size})");
-
- // Backup should be less than 50% of disk size due to zero deduplication
- // (This is a conservative estimate - actual ratio should be much better)
- Assert.That(compressionRatio, Is.LessThan(0.5),
- "Backup with sparse data should be significantly smaller than full disk size due to zero-block deduplication");
-
- // Create restore disk and verify restore works
- var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, size);
- _diskHelper.InitializeDisk(restoreDrivePath, tableType, []);
- _diskHelper.Unmount(restoreDrivePath);
-
- var restoreResults = await RunRestoreAsync(restoreDrivePath);
- TestUtils.AssertResults(restoreResults);
-
- // Reattach and verify
- sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
- restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, tableType, readOnly: true);
-
- var fsTypes = new[] { fsType };
- var sourceMounted = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true, fileSystemTypes: fsTypes);
- var restoreMounted = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true, fileSystemTypes: fsTypes);
-
- CompareDirectories(sourceMounted.First(), restoreMounted.First());
-
- await TestContext.Progress.WriteLineAsync($"{fsType} unallocated space compression verified successfully");
- }
-
- ///
- /// Tests that a completely full disk has all blocks marked as allocated.
- /// Creates a disk filled with data and verifies the backup reads the full disk.
- ///
- [TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.FAT32)]
- [TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.Unknown)]
- [Category("DiskImage")]
- [Category("DiskImageFileSystem")]
- public async Task Test_FileSystem_FullDisk_AllBlocksAllocated_Async(int size, PartitionTableType tableType, FileSystemType fsType)
- {
- await TestContext.Progress.WriteLineAsync($"Test: {fsType} Full Disk - All Blocks Allocated");
-
- // When testing Unknown filesystem, format source as FAT32 to allow test data generation,
- // but treat it as unknown during backup
- var sourceFsType = fsType == FileSystemType.Unknown ? FileSystemType.FAT32 : fsType;
- var treatFilesystemAsUnknown = fsType == FileSystemType.Unknown;
-
- // Create source disk with specified partition (must be at least 32MB for FAT32)
- var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, size);
- var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, tableType, [(sourceFsType, 0)]);
- await TestContext.Progress.WriteLineAsync($"Source disk created at: {_sourceImagePath} ({size} bytes)");
-
- // Fill the disk with data (create enough files to fill most of the space)
- var partitionPath = sourcePartitions.First();
-
- // Generate many files to fill the disk
- // Using larger files to fill space quickly
- // Adjust batch count based on filesystem type - Unknown (raw) mode may need different amounts
- var batchCount = fsType == FileSystemType.Unknown ? 5 : 10;
- for (int batch = 0; batch < batchCount; batch++)
- {
- await ToolTests.GenerateTestDataAsync(Path.Combine(partitionPath, $"batch_{batch}"), 10, 0, 0, 4096);
- }
- _diskHelper.FlushDisk(sourceDrivePath);
- _diskHelper.Unmount(sourceDrivePath);
- await TestContext.Progress.WriteLineAsync("Disk filled with data");
-
- // Backup
- var backupResults = await RunBackupAsync(sourceDrivePath, treatFilesystemAsUnknown);
-
- // When treating filesystem as unknown, the option may not be recognized by the Controller
- // so we only check for errors, not warnings
- if (treatFilesystemAsUnknown)
- {
- Assert.That(backupResults.Errors.Count(), Is.EqualTo(0),
- "Backup should have no errors");
- }
- else
- {
- TestUtils.AssertResults(backupResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
- }
-
- await TestContext.Progress.WriteLineAsync($"Backup completed: {backupResults.SizeOfOpenedFiles} bytes examined, {backupResults.SizeOfAddedFiles} bytes added");
-
- // With a full disk, most blocks should be allocated
- // The backup should have examined a significant portion of the disk
- // Note: We examine files (blocks), and with a full disk, SizeOfOpenedFiles
- // should be close to the partition size
- Assert.That(backupResults.SizeOfOpenedFiles, Is.GreaterThan(size * 0.5),
- "Full disk backup should examine most of the disk size");
-
- // Create restore disk and verify restore
- var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, size);
- _diskHelper.InitializeDisk(restoreDrivePath, tableType, []);
- _diskHelper.Unmount(restoreDrivePath);
-
- var restoreResults = await RunRestoreAsync(restoreDrivePath);
- TestUtils.AssertResults(restoreResults);
-
- // Reattach and verify
- sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
- restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, tableType, readOnly: true);
-
- var fsTypes = new[] { fsType };
- var sourceMounted = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true, fileSystemTypes: fsTypes);
- var restoreMounted = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true, fileSystemTypes: fsTypes);
-
- CompareDirectories(sourceMounted.First(), restoreMounted.First());
-
- await TestContext.Progress.WriteLineAsync($"{fsType} full disk backup verified successfully");
- }
-
- #endregion
-}
diff --git a/Duplicati/UnitTest/DiskImage/Helpers/IDiskImageHelper.cs b/Duplicati/UnitTest/DiskImage/Helpers/IDiskImageHelper.cs
index b8ac1d996..b802c7979 100644
--- a/Duplicati/UnitTest/DiskImage/Helpers/IDiskImageHelper.cs
+++ b/Duplicati/UnitTest/DiskImage/Helpers/IDiskImageHelper.cs
@@ -109,4 +109,12 @@ public interface IDiskImageHelper : IDisposable
/// Indicates whether the disk should be re-attached as read-only.
/// The identifier of the re-attached disk.
string ReAttach(string imagePath, string diskIdentifier, PartitionTableType tableType, bool readOnly = false);
+
+ ///
+ /// Checks if a specific file system type is supported on the current platform. This can be used to conditionally run tests that require certain file systems, or to skip tests if the required file system is not available.
+ /// It also returns a message indicating the reason if the file system type is not supported.
+ ///
+ /// The file system type to check.
+ /// true if the file system type is supported; otherwise, false.
+ (bool, string) IsFileSystemTypeSupported(FileSystemType fsType);
}
diff --git a/Duplicati/UnitTest/DiskImage/Helpers/LinuxDiskImageHelper.cs b/Duplicati/UnitTest/DiskImage/Helpers/LinuxDiskImageHelper.cs
index f50a923a4..1479751f2 100644
--- a/Duplicati/UnitTest/DiskImage/Helpers/LinuxDiskImageHelper.cs
+++ b/Duplicati/UnitTest/DiskImage/Helpers/LinuxDiskImageHelper.cs
@@ -526,6 +526,29 @@ internal class LinuxDiskImageHelper : IDiskImageHelper
return newLoopDevice;
}
+ ///
+ /// Set of file system types that are supported on Linux.
+ ///
+ private readonly HashSet _supportedFileSystems = [
+ FileSystemType.Btrfs,
+ FileSystemType.Ext2,
+ FileSystemType.Ext3,
+ FileSystemType.Ext4,
+ FileSystemType.ExFAT,
+ FileSystemType.FAT32,
+ FileSystemType.XFS,
+ FileSystemType.Unknown
+ ];
+
+ ///
+ public (bool, string) IsFileSystemTypeSupported(FileSystemType fsType)
+ {
+ if (_supportedFileSystems.Contains(fsType))
+ return (true, $"{fsType} is supported on Linux");
+
+ return (false, $"{fsType} is not supported on Linux");
+ }
+
///
public void Dispose()
{
diff --git a/Duplicati/UnitTest/DiskImage/Helpers/MacOSDiskImageHelper.cs b/Duplicati/UnitTest/DiskImage/Helpers/MacOSDiskImageHelper.cs
index cfb0ef6de..fdcbdda95 100644
--- a/Duplicati/UnitTest/DiskImage/Helpers/MacOSDiskImageHelper.cs
+++ b/Duplicati/UnitTest/DiskImage/Helpers/MacOSDiskImageHelper.cs
@@ -640,6 +640,26 @@ internal class MacOSDiskImageHelper : IDiskImageHelper
.First().First();
}
+ ///
+ /// Set of file system types that are supported on macOS.
+ ///
+ private readonly HashSet _supportedFileSystems = [
+ FileSystemType.APFS,
+ FileSystemType.ExFAT,
+ FileSystemType.FAT32,
+ FileSystemType.HFSPlus,
+ FileSystemType.Unknown
+ ];
+
+ ///
+ public (bool, string) IsFileSystemTypeSupported(FileSystemType fsType)
+ {
+ if (_supportedFileSystems.Contains(fsType))
+ return (true, $"{fsType} is supported on macOS");
+
+ return (false, $"{fsType} is not supported on macOS");
+ }
+
public static string RunProcess(string fileName, string arguments, int retryCount = 0)
{
for (int i = 0; i <= retryCount; i++)
diff --git a/Duplicati/UnitTest/DiskImage/Helpers/WindowsDiskImageHelper.cs b/Duplicati/UnitTest/DiskImage/Helpers/WindowsDiskImageHelper.cs
index 674723ca5..36592da9e 100644
--- a/Duplicati/UnitTest/DiskImage/Helpers/WindowsDiskImageHelper.cs
+++ b/Duplicati/UnitTest/DiskImage/Helpers/WindowsDiskImageHelper.cs
@@ -846,6 +846,25 @@ internal class WindowsDiskImageHelper : IDiskImageHelper
return $@"\\.\PhysicalDrive{newDiskNumber}";
}
+ ///
+ /// Defines the set of supported filesystem types on Windows.
+ ///
+ private readonly HashSet _supportedFileSystems = [
+ FileSystemType.ExFAT,
+ FileSystemType.FAT32,
+ FileSystemType.NTFS,
+ FileSystemType.Unknown
+ ];
+
+ ///
+ public (bool, string) IsFileSystemTypeSupported(FileSystemType fsType)
+ {
+ if (_supportedFileSystems.Contains(fsType))
+ return (true, $"{fsType} is supported on Windows");
+
+ return (false, $"{fsType} is not supported on Windows");
+ }
+
///
public void Dispose()
{
diff --git a/Duplicati/UnitTest/DiskImage/IntegrationTests/FilesystemAwareTests.cs b/Duplicati/UnitTest/DiskImage/IntegrationTests/FilesystemAwareTests.cs
new file mode 100644
index 000000000..66f578c9a
--- /dev/null
+++ b/Duplicati/UnitTest/DiskImage/IntegrationTests/FilesystemAwareTests.cs
@@ -0,0 +1,425 @@
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Duplicati.Proprietary.DiskImage.General;
+using Duplicati.UnitTest.DiskImage.Helpers;
+using NUnit.Framework;
+
+namespace Duplicati.UnitTest.DiskImage.IntegrationTests;
+
+public class FilesystemAwareTests : DiskImageTests
+{
+ ///
+ /// Tests that incremental backup with filesystem awareness skips unchanged blocks.
+ /// Creates a disk with the specified filesystem, performs initial backup, modifies one file,
+ /// performs second backup, and verifies only changed blocks were processed.
+ ///
+ [TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.FAT32)]
+ [TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.NTFS)]
+ [TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.Unknown)]
+ [Category("DiskImage")]
+ [Category("DiskImageFileSystem")]
+ public async Task Test_FileSystem_IncrementalBackup_UnchangedBlocks_Skipped_Async(int size, PartitionTableType tableType, FileSystemType fsType)
+ {
+ var stopwatch = new Stopwatch();
+ stopwatch.Start();
+
+ var (isSupported, reason) = _diskHelper.IsFileSystemTypeSupported(fsType);
+ if (!isSupported)
+ Assert.Ignore(reason);
+
+ stopwatch.Stop();
+ await TestContext.Progress.WriteLineAsync($"Test: {fsType} Incremental Backup - Unchanged Blocks Skipped ({stopwatch.ElapsedMilliseconds}ms)");
+ stopwatch.Restart();
+
+ // When testing Unknown filesystem, format source as FAT32 to allow test data generation,
+ // but treat it as unknown during backup
+ var sourceFsType = fsType == FileSystemType.Unknown ? FileSystemType.FAT32 : fsType;
+ var treatFilesystemAsUnknown = fsType == FileSystemType.Unknown;
+
+ // Create source disk with specified partition
+ var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, size);
+ var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, tableType, [(sourceFsType, 0)]);
+ stopwatch.Stop();
+ await TestContext.Progress.WriteLineAsync($"Source disk created at: {_sourceImagePath} ({stopwatch.ElapsedMilliseconds}ms)");
+ stopwatch.Restart();
+
+ // Generate initial test data
+ await ToolTests.GenerateTestDataAsync(sourcePartitions.First(), 10, 3, 1, 1024);
+ _diskHelper.FlushDisk(sourceDrivePath);
+ _diskHelper.Unmount(sourceDrivePath);
+ stopwatch.Stop();
+ await TestContext.Progress.WriteLineAsync($"Initial test data generated ({stopwatch.ElapsedMilliseconds}ms)");
+ stopwatch.Restart();
+
+ // First backup
+ var firstBackupResults = await RunBackupAsync(sourceDrivePath, treatFilesystemAsUnknown);
+
+ // When treating filesystem as unknown, the option may not be recognized by the Controller
+ // so we only check for errors, not warnings
+ if (treatFilesystemAsUnknown)
+ {
+ Assert.That(firstBackupResults.Errors.Count(), Is.EqualTo(0),
+ "Backup should have no errors");
+ }
+ else
+ {
+ TestUtils.AssertResults(firstBackupResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
+ }
+
+ var firstBackupSize = firstBackupResults.SizeOfOpenedFiles;
+ stopwatch.Stop();
+ await TestContext.Progress.WriteLineAsync($"First backup completed: {firstBackupResults.OpenedFiles} files opened, {firstBackupSize} bytes ({stopwatch.ElapsedMilliseconds}ms)");
+ stopwatch.Restart();
+
+ // Verify first backup processed the expected amount of data
+ // With FAT32 awareness, unallocated blocks are returned as zeros (no disk read)
+ Assert.That(firstBackupResults.SizeOfOpenedFiles, Is.GreaterThan(0),
+ "First backup should have opened and processed files");
+
+ // Create restore disk for initial restore verification
+ var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, size);
+ _diskHelper.InitializeDisk(restoreDrivePath, tableType, []);
+ _diskHelper.Unmount(restoreDrivePath);
+ stopwatch.Stop();
+ await TestContext.Progress.WriteLineAsync($"Restore disk created at: {_restoreImagePath} ({stopwatch.ElapsedMilliseconds}ms)");
+ stopwatch.Restart();
+
+ var restoreResults = await RunRestoreAsync(restoreDrivePath);
+ TestUtils.AssertResults(restoreResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
+ stopwatch.Stop();
+ await TestContext.Progress.WriteLineAsync($"Initial restore completed successfully ({stopwatch.ElapsedMilliseconds}ms)");
+ stopwatch.Restart();
+
+ // Reattach and verify initial restore
+ sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
+ restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, tableType, readOnly: true);
+
+ var fsTypes = new[] { sourceFsType };
+ var sourceMounted = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true, fileSystemTypes: fsTypes);
+ var restoreMounted = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true, fileSystemTypes: fsTypes);
+
+ CompareDirectories(sourceMounted.First(), restoreMounted.First());
+ stopwatch.Stop();
+ await TestContext.Progress.WriteLineAsync($"Initial restore verified successfully ({stopwatch.ElapsedMilliseconds}ms)");
+ stopwatch.Restart();
+
+ // Unmount both disks to prepare for modification
+ _diskHelper.Unmount(sourceDrivePath);
+ _diskHelper.Unmount(restoreDrivePath);
+ stopwatch.Stop();
+ await TestContext.Progress.WriteLineAsync($"Both disks unmounted ({stopwatch.ElapsedMilliseconds}ms)");
+ stopwatch.Restart();
+
+ // Remount source drive in write-enabled mode
+ sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: false);
+ sourceMounted = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: false, fileSystemTypes: fsTypes);
+ stopwatch.Stop();
+ await TestContext.Progress.WriteLineAsync($"Source disk remounted in write mode ({stopwatch.ElapsedMilliseconds}ms)");
+ stopwatch.Restart();
+
+ // Modify a few files on the source - create new files and modify existing ones
+ var sourcePartitionPath = sourceMounted.First();
+ var modifiedFiles = new List();
+
+ // Create a new directory with some new files
+ var newDirPath = Path.Combine(sourcePartitionPath, "incremental_new_files");
+ Directory.CreateDirectory(newDirPath);
+ for (int i = 0; i < 3; i++)
+ {
+ var newFilePath = Path.Combine(newDirPath, $"new_file_{i}.txt");
+ var content = $"This is new content for incremental backup test - file {i} - {Guid.NewGuid()}";
+ await File.WriteAllTextAsync(newFilePath, content);
+ // Set a recent modification time to ensure the backup detects the change
+ File.SetLastWriteTimeUtc(newFilePath, DateTime.UtcNow);
+ modifiedFiles.Add(newFilePath);
+ }
+ // Also update directory timestamp
+ Directory.SetLastWriteTimeUtc(newDirPath, DateTime.UtcNow);
+ stopwatch.Stop();
+ await TestContext.Progress.WriteLineAsync($"Created {modifiedFiles.Count} new files in {newDirPath} ({stopwatch.ElapsedMilliseconds}ms)");
+ stopwatch.Restart();
+
+ // Modify an existing file if one exists
+ var existingFiles = GetNonSystemFiles(sourcePartitionPath)
+ .Where(f => f.EndsWith(".txt") && !f.Contains("incremental_new_files"))
+ .Select(f => Path.Combine(sourcePartitionPath, f))
+ .ToArray();
+ if (existingFiles.Length > 0)
+ {
+ var fileToModify = existingFiles[0];
+ var originalContent = await File.ReadAllTextAsync(fileToModify);
+ var modifiedContent = originalContent + $"\n\nModified for incremental backup test - {Guid.NewGuid()}";
+ await File.WriteAllTextAsync(fileToModify, modifiedContent);
+ // Update modification time to ensure backup detects the change
+ File.SetLastWriteTimeUtc(fileToModify, DateTime.UtcNow.AddSeconds(1));
+ modifiedFiles.Add(fileToModify);
+ stopwatch.Stop();
+ await TestContext.Progress.WriteLineAsync($"Modified existing file: {fileToModify} ({stopwatch.ElapsedMilliseconds}ms)");
+ stopwatch.Restart();
+ }
+
+ // Flush and unmount source after modifications
+ _diskHelper.FlushDisk(sourceDrivePath);
+ _diskHelper.Unmount(sourceDrivePath);
+ stopwatch.Stop();
+ await TestContext.Progress.WriteLineAsync($"Modifications complete, source disk unmounted ({stopwatch.ElapsedMilliseconds}ms)");
+ stopwatch.Restart();
+
+ // Re-attach the source disk for backup (disk needs to be attached for backup to access it)
+ sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
+ stopwatch.Stop();
+ await TestContext.Progress.WriteLineAsync($"Source disk re-attached for second backup at: {sourceDrivePath} ({stopwatch.ElapsedMilliseconds}ms)");
+ stopwatch.Restart();
+
+ // Second backup (incremental)
+ var secondBackupResults = await RunBackupAsync(sourceDrivePath, treatFilesystemAsUnknown);
+
+ // When treating filesystem as unknown, the option may not be recognized by the Controller
+ // so we only check for errors, not warnings
+ if (treatFilesystemAsUnknown)
+ {
+ Assert.That(secondBackupResults.Errors.Count(), Is.EqualTo(0),
+ "Backup should have no errors");
+ }
+ else
+ {
+ TestUtils.AssertResults(secondBackupResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
+ }
+
+ var secondBackupSize = secondBackupResults.SizeOfOpenedFiles;
+ stopwatch.Stop();
+ await TestContext.Progress.WriteLineAsync($"Second backup completed: {secondBackupResults.OpenedFiles} files opened, {secondBackupSize} bytes examined, {secondBackupResults.SizeOfAddedFiles} bytes added ({stopwatch.ElapsedMilliseconds}ms)");
+ stopwatch.Restart();
+
+ if (fsType is not FileSystemType.Unknown)
+ {
+ // Verify the second backup processed significantly fewer files than the first
+ // The second backup should only process modified/new files, not all files
+ Assert.That(secondBackupResults.OpenedFiles, Is.LessThan(firstBackupResults.OpenedFiles),
+ "Incremental backup should open fewer files than the initial backup");
+ Assert.That(secondBackupResults.SizeOfOpenedFiles, Is.LessThan(firstBackupResults.SizeOfOpenedFiles),
+ "Incremental backup should examine less data than the initial backup");
+ }
+
+ // Also verify that the second backup actually modified some files
+ Assert.That(secondBackupResults.ModifiedFiles, Is.GreaterThan(0),
+ "Incremental backup should have added new or modified files");
+
+ // Calculate and log the reduction ratio
+ var fileReductionRatio = (double)secondBackupResults.OpenedFiles / firstBackupResults.OpenedFiles;
+ var sizeReductionRatio = (double)secondBackupResults.SizeOfOpenedFiles / firstBackupResults.SizeOfOpenedFiles;
+ stopwatch.Stop();
+ await TestContext.Progress.WriteLineAsync($"File reduction: {fileReductionRatio:P2} ({secondBackupResults.OpenedFiles}/{firstBackupResults.OpenedFiles}) ({stopwatch.ElapsedMilliseconds}ms)");
+ stopwatch.Restart();
+ await TestContext.Progress.WriteLineAsync($"Size reduction: {sizeReductionRatio:P2} ({secondBackupResults.SizeOfOpenedFiles}/{firstBackupResults.SizeOfOpenedFiles}) ({stopwatch.ElapsedMilliseconds}ms)");
+ stopwatch.Restart();
+
+ // Restore the incremental backup to a fresh disk
+ _diskHelper.CleanupDisk(_restoreImagePath);
+ DiskImageTestHelpers.SafeDeleteFile(_restoreImagePath);
+
+ restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, size);
+ _diskHelper.InitializeDisk(restoreDrivePath, tableType, []);
+ _diskHelper.Unmount(restoreDrivePath);
+ stopwatch.Stop();
+ await TestContext.Progress.WriteLineAsync($"Fresh restore disk created for incremental restore ({stopwatch.ElapsedMilliseconds}ms)");
+ stopwatch.Restart();
+
+ var incrementalRestoreResults = await RunRestoreAsync(restoreDrivePath);
+ TestUtils.AssertResults(incrementalRestoreResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
+ stopwatch.Stop();
+ await TestContext.Progress.WriteLineAsync($"Incremental restore completed successfully ({stopwatch.ElapsedMilliseconds}ms)");
+ stopwatch.Restart();
+
+ // Reattach and verify the incremental restore matches the modified source
+ sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
+ restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, tableType, readOnly: true);
+
+ sourceMounted = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true, fileSystemTypes: fsTypes);
+ restoreMounted = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true, fileSystemTypes: fsTypes);
+
+ CompareDirectories(sourceMounted.First(), restoreMounted.First());
+
+ stopwatch.Stop();
+ await TestContext.Progress.WriteLineAsync($"{fsType} incremental backup and restore verified successfully ({stopwatch.ElapsedMilliseconds}ms)");
+ stopwatch.Restart();
+ }
+
+ ///
+ /// Tests that unallocated space compresses well due to zero-block deduplication.
+ /// Creates a disk with the specified filesystem and sparse data (few files, lots of free space),
+ /// backs it up, and verifies the backup size is significantly smaller than the disk size.
+ ///
+ [TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.FAT32)]
+ [TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.NTFS)]
+ [TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.Unknown)]
+ [Category("DiskImage")]
+ [Category("DiskImageFileSystem")]
+ public async Task Test_FileSystem_UnallocatedSpace_CompressesWell_Async(int size, PartitionTableType tableType, FileSystemType fsType)
+ {
+ var (isSupported, reason) = _diskHelper.IsFileSystemTypeSupported(fsType);
+ if (!isSupported)
+ Assert.Ignore(reason);
+
+ await TestContext.Progress.WriteLineAsync($"Test: {fsType} Unallocated Space Compression");
+
+ // When testing Unknown filesystem, format source as FAT32 to allow test data generation,
+ // but treat it as unknown during backup
+ var sourceFsType = fsType == FileSystemType.Unknown ? FileSystemType.FAT32 : fsType;
+ var treatFilesystemAsUnknown = fsType == FileSystemType.Unknown;
+
+ // Create source disk with specified partition
+ var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, size);
+ var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, tableType, [(sourceFsType, 0)]);
+ await TestContext.Progress.WriteLineAsync($"Source disk created at: {_sourceImagePath}");
+
+ // Generate minimal test data (sparse data - only a few small files)
+ // This leaves most of the 50MB as unallocated space
+ await ToolTests.GenerateTestDataAsync(sourcePartitions.First(), 3, 0, 0, 512);
+ _diskHelper.FlushDisk(sourceDrivePath);
+ _diskHelper.Unmount(sourceDrivePath);
+ await TestContext.Progress.WriteLineAsync("Sparse test data generated (few files, lots of free space)");
+
+ // Backup
+ var backupResults = await RunBackupAsync(sourceDrivePath, treatFilesystemAsUnknown);
+ // When treating filesystem as unknown, the option may not be recognized by the Controller
+ // so we only check for errors, not warnings
+ if (treatFilesystemAsUnknown)
+ {
+ Assert.That(backupResults.Errors.Count(), Is.EqualTo(0),
+ "Backup should have no errors");
+ }
+ else
+ {
+ TestUtils.AssertResults(backupResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
+ }
+ await TestContext.Progress.WriteLineAsync($"Backup completed: {backupResults.SizeOfOpenedFiles} bytes examined");
+
+ // Calculate the actual backup size from the target folder
+ var backupFiles = Directory.GetFiles(TARGETFOLDER, "*", SearchOption.AllDirectories);
+ long actualBackupSize = backupFiles.Sum(f => new FileInfo(f).Length);
+ await TestContext.Progress.WriteLineAsync($"Actual backup size on disk: {actualBackupSize} bytes");
+
+ // The backup should be significantly smaller than the full disk size
+ // With zero-block deduplication, unallocated clusters return zeros which compress very well
+ var compressionRatio = (double)actualBackupSize / size;
+ await TestContext.Progress.WriteLineAsync($"Compression ratio: {compressionRatio:P2} ({actualBackupSize}/{size})");
+
+ // Backup should be less than 50% of disk size due to zero deduplication
+ // (This is a conservative estimate - actual ratio should be much better)
+ Assert.That(compressionRatio, Is.LessThan(0.5),
+ "Backup with sparse data should be significantly smaller than full disk size due to zero-block deduplication");
+
+ // Create restore disk and verify restore works
+ var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, size);
+ _diskHelper.InitializeDisk(restoreDrivePath, tableType, []);
+ _diskHelper.Unmount(restoreDrivePath);
+
+ var restoreResults = await RunRestoreAsync(restoreDrivePath);
+ TestUtils.AssertResults(restoreResults);
+
+ // Reattach and verify
+ sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
+ restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, tableType, readOnly: true);
+
+ var fsTypes = new[] { fsType };
+ var sourceMounted = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true, fileSystemTypes: fsTypes);
+ var restoreMounted = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true, fileSystemTypes: fsTypes);
+
+ CompareDirectories(sourceMounted.First(), restoreMounted.First());
+
+ await TestContext.Progress.WriteLineAsync($"{fsType} unallocated space compression verified successfully");
+ }
+
+ ///
+ /// Tests that a completely full disk has all blocks marked as allocated.
+ /// Creates a disk filled with data and verifies the backup reads the full disk.
+ ///
+ [TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.FAT32)]
+ [TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.NTFS)]
+ [TestCase((int)(50 * MiB), PartitionTableType.GPT, FileSystemType.Unknown)]
+ [Category("DiskImage")]
+ [Category("DiskImageFileSystem")]
+ public async Task Test_FileSystem_FullDisk_AllBlocksAllocated_Async(int size, PartitionTableType tableType, FileSystemType fsType)
+ {
+ var (isSupported, reason) = _diskHelper.IsFileSystemTypeSupported(fsType);
+ if (!isSupported)
+ Assert.Ignore(reason);
+
+ await TestContext.Progress.WriteLineAsync($"Test: {fsType} Full Disk - All Blocks Allocated");
+
+ // When testing Unknown filesystem, format source as FAT32 to allow test data generation,
+ // but treat it as unknown during backup
+ var sourceFsType = fsType == FileSystemType.Unknown ? FileSystemType.FAT32 : fsType;
+ var treatFilesystemAsUnknown = fsType == FileSystemType.Unknown;
+
+ // Create source disk with specified partition (must be at least 32MB for FAT32)
+ var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, size);
+ var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, tableType, [(sourceFsType, 0)]);
+ await TestContext.Progress.WriteLineAsync($"Source disk created at: {_sourceImagePath} ({size} bytes)");
+
+ // Fill the disk with data (create enough files to fill most of the space)
+ var partitionPath = sourcePartitions.First();
+
+ // Generate many files to fill the disk
+ // Using larger files to fill space quickly
+ // Adjust batch count based on filesystem type - Unknown (raw) mode may need different amounts
+ var batchCount = fsType == FileSystemType.Unknown ? 5 : 10;
+ for (int batch = 0; batch < batchCount; batch++)
+ {
+ await ToolTests.GenerateTestDataAsync(Path.Combine(partitionPath, $"batch_{batch}"), 10, 0, 0, 4096);
+ }
+ _diskHelper.FlushDisk(sourceDrivePath);
+ _diskHelper.Unmount(sourceDrivePath);
+ await TestContext.Progress.WriteLineAsync("Disk filled with data");
+
+ // Backup
+ var backupResults = await RunBackupAsync(sourceDrivePath, treatFilesystemAsUnknown);
+
+ // When treating filesystem as unknown, the option may not be recognized by the Controller
+ // so we only check for errors, not warnings
+ if (treatFilesystemAsUnknown)
+ {
+ Assert.That(backupResults.Errors.Count(), Is.EqualTo(0),
+ "Backup should have no errors");
+ }
+ else
+ {
+ TestUtils.AssertResults(backupResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
+ }
+
+ await TestContext.Progress.WriteLineAsync($"Backup completed: {backupResults.SizeOfOpenedFiles} bytes examined, {backupResults.SizeOfAddedFiles} bytes added");
+
+ // With a full disk, most blocks should be allocated
+ // The backup should have examined a significant portion of the disk
+ // Note: We examine files (blocks), and with a full disk, SizeOfOpenedFiles
+ // should be close to the partition size
+ Assert.That(backupResults.SizeOfOpenedFiles, Is.GreaterThan(size * 0.5),
+ "Full disk backup should examine most of the disk size");
+
+ // Create restore disk and verify restore
+ var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, size);
+ _diskHelper.InitializeDisk(restoreDrivePath, tableType, []);
+ _diskHelper.Unmount(restoreDrivePath);
+
+ var restoreResults = await RunRestoreAsync(restoreDrivePath);
+ TestUtils.AssertResults(restoreResults);
+
+ // Reattach and verify
+ sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
+ restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, tableType, readOnly: true);
+
+ var fsTypes = new[] { fsType };
+ var sourceMounted = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true, fileSystemTypes: fsTypes);
+ var restoreMounted = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true, fileSystemTypes: fsTypes);
+
+ CompareDirectories(sourceMounted.First(), restoreMounted.First());
+
+ await TestContext.Progress.WriteLineAsync($"{fsType} full disk backup verified successfully");
+ }
+}
\ No newline at end of file
diff --git a/Duplicati/UnitTest/DiskImage/IntegrationTests/FullRoundTripTests.cs b/Duplicati/UnitTest/DiskImage/IntegrationTests/FullRoundTripTests.cs
new file mode 100644
index 000000000..6d4b10e27
--- /dev/null
+++ b/Duplicati/UnitTest/DiskImage/IntegrationTests/FullRoundTripTests.cs
@@ -0,0 +1,525 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Duplicati.Proprietary.DiskImage.General;
+using NUnit.Framework;
+
+namespace Duplicati.UnitTest.DiskImage.IntegrationTests;
+
+public class FullRoundTripTests : DiskImageTests
+{
+ #region GPT Single Partition
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_FAT32_Async() =>
+ FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.FAT32, 0)]);
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_NTFS_Async() =>
+ FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.NTFS, 0)]);
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_APFS_Async() =>
+ FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.APFS, 0)]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_HFSPlus_Async() =>
+ FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.HFSPlus, 0)]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_ExFAT_Async() =>
+ FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.ExFAT, 0)]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_Ext2_Async() =>
+ FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.Ext2, 0)]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_Ext3_Async() =>
+ FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.Ext3, 0)]);
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_Ext4_Async() =>
+ FullRoundTripAsync((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.Ext4, 0)]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_XFS_Async() =>
+ FullRoundTripAsync((int)(310 * MiB), PartitionTableType.GPT, [(FileSystemType.XFS, 0)]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_Btrfs_Async() =>
+ FullRoundTripAsync((int)(110 * MiB), PartitionTableType.GPT, [(FileSystemType.Btrfs, 0)]);
+
+ #endregion
+
+ #region MBR Single Partition
+
+ // APFS is only supported on GPT partition tables.
+
+ [Test, Category("DiskImage")]
+ public Task Test_MBR_FAT32_Async() =>
+ FullRoundTripAsync((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.FAT32, 0)]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_HFSPlus_Async() =>
+ FullRoundTripAsync((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.HFSPlus, 0)]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_ExFAT_Async() =>
+ FullRoundTripAsync((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.ExFAT, 0)]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_NTFS_Async() =>
+ FullRoundTripAsync((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.NTFS, 0)]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_Ext2_Async() =>
+ FullRoundTripAsync((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.Ext2, 0)]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_Ext3_Async() =>
+ FullRoundTripAsync((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.Ext3, 0)]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_Ext4_Async() =>
+ FullRoundTripAsync((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.Ext4, 0)]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_XFS_Async() =>
+ FullRoundTripAsync((int)(310 * MiB), PartitionTableType.MBR, [(FileSystemType.XFS, 0)]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_Btrfs_Async() =>
+ FullRoundTripAsync((int)(110 * MiB), PartitionTableType.MBR, [(FileSystemType.Btrfs, 0)]);
+
+ #endregion
+
+ #region Unknown Partition Table
+
+ [Test, Category("DiskImage")]
+ public Task Test_Unknown_NoPartitions_Async() =>
+ FullRoundTripAsync((int)(50 * MiB), PartitionTableType.Unknown, []);
+
+ #endregion
+
+ #region GPT Two Partitions
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_FAT32_FAT32_Async() =>
+ FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.FAT32, 0)
+ ]);
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_APFS_APFS_Async() =>
+ FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.APFS, 50 * MiB),
+ (FileSystemType.APFS, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_HFSPlus_HFSPlus_Async() =>
+ FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.HFSPlus, 50 * MiB),
+ (FileSystemType.HFSPlus, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_APFS_HFSPlus_Async() =>
+ FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.APFS, 50 * MiB),
+ (FileSystemType.HFSPlus, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_FAT32_APFS_Async() =>
+ FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.APFS, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_ExFAT_HFSPlus_Async() =>
+ FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.ExFAT, 50 * MiB),
+ (FileSystemType.HFSPlus, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_FAT32_ExFAT_Async() =>
+ FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.ExFAT, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_NTFS_FAT32_Async() =>
+ FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.NTFS, 50 * MiB),
+ (FileSystemType.FAT32, 0)
+ ]);
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_NTFS_NTFS_Async() =>
+ FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.NTFS, 50 * MiB),
+ (FileSystemType.NTFS, 0)
+ ]);
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_Ext4_Ext4_Async() =>
+ FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.Ext4, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_Ext4_FAT32_Async() =>
+ FullRoundTripAsync((int)(100 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.FAT32, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_Ext4_XFS_Async() =>
+ FullRoundTripAsync((int)(360 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.XFS, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_XFS_FAT32_Async() =>
+ FullRoundTripAsync((int)(360 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.XFS, 310 * MiB),
+ (FileSystemType.FAT32, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_Btrfs_FAT32_Async() =>
+ FullRoundTripAsync((int)(160 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.Btrfs, 110 * MiB),
+ (FileSystemType.FAT32, 0)
+ ]);
+
+ #endregion
+
+ #region MBR Two Partitions
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_FAT32_FAT32_Async() =>
+ FullRoundTripAsync((int)(100 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.FAT32, 0)
+ ]);
+
+ // APFS is only supported on GPT partition tables.
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_HFSPlus_HFSPlus_Async() =>
+ FullRoundTripAsync((int)(100 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.HFSPlus, 50 * MiB),
+ (FileSystemType.HFSPlus, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_FAT32_ExFAT_Async() =>
+ FullRoundTripAsync((int)(100 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.ExFAT, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_FAT32_HFSPlus_Async() =>
+ FullRoundTripAsync((int)(100 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.HFSPlus, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_NTFS_FAT32_Async() =>
+ FullRoundTripAsync((int)(100 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.NTFS, 50 * MiB),
+ (FileSystemType.FAT32, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_Ext4_Ext4_Async() =>
+ FullRoundTripAsync((int)(100 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.Ext4, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_FAT32_Ext4_Async() =>
+ FullRoundTripAsync((int)(100 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.Ext4, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_XFS_Ext4_Async() =>
+ FullRoundTripAsync((int)(360 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.XFS, 310 * MiB),
+ (FileSystemType.Ext4, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_Btrfs_Ext4_Async() =>
+ FullRoundTripAsync((int)(160 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.Btrfs, 110 * MiB),
+ (FileSystemType.Ext4, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_XFS_Btrfs_Async() =>
+ FullRoundTripAsync((int)(420 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.XFS, 310 * MiB),
+ (FileSystemType.Btrfs, 0)
+ ]);
+
+ #endregion
+
+ #region GPT Three Partitions
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_FAT32_FAT32_FAT32_Async() =>
+ FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.FAT32, 0)
+ ]);
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_APFS_APFS_APFS_Async() =>
+ FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.APFS, 50 * MiB),
+ (FileSystemType.APFS, 50 * MiB),
+ (FileSystemType.APFS, 0)
+ ]);
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_NTFS_NTFS_NTFS_Async() =>
+ FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.NTFS, 50 * MiB),
+ (FileSystemType.NTFS, 50 * MiB),
+ (FileSystemType.NTFS, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_HFSPlus_HFSPlus_HFSPlus_Async() =>
+ FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.HFSPlus, 50 * MiB),
+ (FileSystemType.HFSPlus, 50 * MiB),
+ (FileSystemType.HFSPlus, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_APFS_HFSPlus_APFS_Async() =>
+ FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.APFS, 50 * MiB),
+ (FileSystemType.HFSPlus, 50 * MiB),
+ (FileSystemType.APFS, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_FAT32_APFS_HFSPlus_Async() =>
+ FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.APFS, 50 * MiB),
+ (FileSystemType.HFSPlus, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_ExFAT_APFS_HFSPlus_Async() =>
+ FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.ExFAT, 50 * MiB),
+ (FileSystemType.APFS, 50 * MiB),
+ (FileSystemType.HFSPlus, 0)
+ ]);
+
+ [Test, Category("DiskImage")]
+ public Task Test_GPT_Ext4_Ext4_Ext4_Async() =>
+ FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.Ext4, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_FAT32_Ext4_XFS_Async() =>
+ FullRoundTripAsync((int)(410 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.XFS, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_Ext4_XFS_Btrfs_Async() =>
+ FullRoundTripAsync((int)(470 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.XFS, 310 * MiB),
+ (FileSystemType.Btrfs, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_XFS_Ext4_Ext4_Async() =>
+ FullRoundTripAsync((int)(410 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.XFS, 310 * MiB),
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.Ext4, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_Btrfs_Ext4_FAT32_Async() =>
+ FullRoundTripAsync((int)(210 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.Btrfs, 110 * MiB),
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.FAT32, 0)
+ ]);
+
+ #endregion
+
+ #region MBR Three Partitions
+
+ // APFS is only supported on GPT partition tables.
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_FAT32_FAT32_FAT32_Async() =>
+ FullRoundTripAsync((int)(150 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.FAT32, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_HFSPlus_HFSPlus_HFSPlus_Async() =>
+ FullRoundTripAsync((int)(150 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.HFSPlus, 50 * MiB),
+ (FileSystemType.HFSPlus, 50 * MiB),
+ (FileSystemType.HFSPlus, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_FAT32_ExFAT_HFSPlus_Async() =>
+ FullRoundTripAsync((int)(150 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.ExFAT, 50 * MiB),
+ (FileSystemType.HFSPlus, 0)
+ ]);
+
+ // GPT NTFS for Windows - local-only (tested via single partition)
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_GPT_NTFS_FAT32_ExFAT_Async() =>
+ FullRoundTripAsync((int)(150 * MiB), PartitionTableType.GPT, [
+ (FileSystemType.NTFS, 50 * MiB),
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.ExFAT, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_Ext4_Ext4_Ext4_Async() =>
+ FullRoundTripAsync((int)(150 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.Ext4, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_FAT32_Ext4_XFS_Async() =>
+ FullRoundTripAsync((int)(410 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.FAT32, 50 * MiB),
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.XFS, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_Ext4_XFS_Btrfs_Async() =>
+ FullRoundTripAsync((int)(470 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.XFS, 310 * MiB),
+ (FileSystemType.Btrfs, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_XFS_Btrfs_Ext4_Async() =>
+ FullRoundTripAsync((int)(470 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.XFS, 310 * MiB),
+ (FileSystemType.Btrfs, 110 * MiB),
+ (FileSystemType.Ext4, 0)
+ ]);
+
+ [Test, Category("DiskImage"), Category("DiskImageLocal")]
+ public Task Test_MBR_Btrfs_Ext4_XFS_Async() =>
+ FullRoundTripAsync((int)(470 * MiB), PartitionTableType.MBR, [
+ (FileSystemType.Btrfs, 110 * MiB),
+ (FileSystemType.Ext4, 50 * MiB),
+ (FileSystemType.XFS, 0)
+ ]);
+
+ #endregion
+
+ public async Task FullRoundTripAsync(int size, PartitionTableType tableType, (FileSystemType, long)[] partitions)
+ {
+ // Check if all specified file system types are supported before starting the test
+ foreach (var (fsType, partSize) in partitions)
+ {
+ var (isSupported, reason) = _diskHelper.IsFileSystemTypeSupported(fsType);
+ if (!isSupported)
+ Assert.Ignore(reason);
+ }
+
+ await TestContext.Progress.WriteLineAsync("Test: Full Round-Trip Backup + Restore");
+
+ var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, size);
+ await TestContext.Progress.WriteLineAsync($"Source Disk created at: {_sourceImagePath}");
+
+ var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, tableType, partitions);
+ await TestContext.Progress.WriteLineAsync($"Source Disk initialized with partition(s): {string.Join(", ", sourcePartitions)}");
+
+ // Populate source partition with test data
+ foreach (var partition in sourcePartitions)
+ await ToolTests.GenerateTestDataAsync(partition, 10, 5, 2, 1024);
+ _diskHelper.FlushDisk(sourceDrivePath);
+ _diskHelper.Unmount(sourceDrivePath);
+ await TestContext.Progress.WriteLineAsync($"Test data generated on source partition(s)");
+
+ // Backup
+ var backupResults = await RunBackupAsync(sourceDrivePath);
+ TestUtils.AssertResults(backupResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
+ await TestContext.Progress.WriteLineAsync($"Backup completed successfully");
+
+ // Setup restore target disk image with same geometry
+ var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, size);
+ _diskHelper.InitializeDisk(restoreDrivePath, PartitionTableType.GPT, []);
+ _diskHelper.Unmount(restoreDrivePath);
+ await TestContext.Progress.WriteLineAsync($"Restore disk image created at: {_restoreImagePath}");
+
+ // Restore
+ var restoreResults = await RunRestoreAsync(restoreDrivePath);
+ TestUtils.AssertResults(restoreResults);
+ await TestContext.Progress.WriteLineAsync($"Restore completed successfully");
+
+ // Reattach drives in readonly
+ sourceDrivePath = _diskHelper.ReAttach(_sourceImagePath, sourceDrivePath, tableType, readOnly: true);
+ restoreDrivePath = _diskHelper.ReAttach(_restoreImagePath, restoreDrivePath, tableType, readOnly: true);
+ await TestContext.Progress.WriteLineAsync($"Source and restore disks re-attached as read-only for verification");
+
+ // Verify partition table matches. Mount before verification, to make disks online on Windows.
+ string[] restorePartitions = [];
+ if (tableType != PartitionTableType.Unknown)
+ {
+ var fsTypes = partitions.Select(p => p.Item1).ToArray();
+ sourcePartitions = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true, fileSystemTypes: fsTypes);
+ restorePartitions = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true, fileSystemTypes: fsTypes);
+ }
+ VerifyPartitionTableMatches(sourceDrivePath, restoreDrivePath);
+ await TestContext.Progress.WriteLineAsync($"Partition table verified to match source");
+
+ // Verify data matches byte-for-byte
+ foreach (var (sourcePartition, restorePartition) in sourcePartitions.Zip(restorePartitions, (s, r) => (s, r)))
+ CompareDirectories(sourcePartition, restorePartition);
+
+ await TestContext.Progress.WriteLineAsync($"Restored data verified to match source");
+ }
+}
\ No newline at end of file
diff --git a/Duplicati/UnitTest/DiskImage/IntegrationTests/Helpers.cs b/Duplicati/UnitTest/DiskImage/IntegrationTests/Helpers.cs
new file mode 100644
index 000000000..a580972e1
--- /dev/null
+++ b/Duplicati/UnitTest/DiskImage/IntegrationTests/Helpers.cs
@@ -0,0 +1,240 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Duplicati.Library.Interface;
+using Duplicati.Library.Main;
+using Duplicati.Proprietary.DiskImage.General;
+using NUnit.Framework;
+using Assert = NUnit.Framework.Legacy.ClassicAssert;
+
+namespace Duplicati.UnitTest.DiskImage.IntegrationTests;
+
+public partial class DiskImageTests : BasicSetupHelper
+{
+ ///
+ /// Runs a backup operation using the Controller.
+ ///
+ /// The physical drive path to backup.
+ /// If true, treats filesystem as unknown (forces raw block-based backup).
+ /// The backup results.
+ protected Task RunBackupAsync(string physicalDrivePath, bool treatFilesystemAsUnknown = false)
+ {
+ var options = new Dictionary(TestOptions);
+ options["enable-module"] = "diskimage";
+ options["concurrency-fileprocessors"] = "1";
+
+ if (!treatFilesystemAsUnknown)
+ {
+ options["diskimage-filesystem-parsed"] = "true";
+ }
+
+ using var c = new Controller("file://" + TARGETFOLDER, options, null);
+ var sourceUrl = $"@/testdisk|diskimage://{physicalDrivePath}";
+ return c.BackupAsync(new[] { sourceUrl });
+ }
+
+ ///
+ /// Runs a restore operation using the Controller.
+ ///
+ /// The physical drive path to restore to.
+ /// The restore results.
+ protected Task RunRestoreAsync(string restoreDrivePath)
+ {
+ var options = new Dictionary(TestOptions);
+ options["restore-path"] = $"@diskimage://{restoreDrivePath}";
+ options["overwrite"] = "true";
+ options["restore-file-processors"] = "1";
+
+ using var c = new Controller("file://" + TARGETFOLDER, options, null);
+ return c.RestoreAsync(new[] { "*" });
+ }
+
+ protected static IEnumerable GetNonSystemFiles(string directoryPath)
+ {
+ var options = new EnumerationOptions
+ {
+ RecurseSubdirectories = true,
+ IgnoreInaccessible = true,
+ AttributesToSkip = FileAttributes.System | FileAttributes.ReparsePoint
+ };
+
+ return Directory.EnumerateFiles(directoryPath, "*", options)
+ .Where(f => !string.IsNullOrEmpty(f))
+ .Select(f => Path.GetRelativePath(directoryPath, f))
+ .OrderBy(f => f);
+ }
+
+ ///
+ /// Recursively compares two directories for structural and content equality.
+ ///
+ /// The source directory path.
+ /// The restored directory path.
+ protected void CompareDirectories(string sourcePath, string restorePath)
+ {
+ // Get all files in source, except for system files, which cannot be read directly
+ var sourceFiles = GetNonSystemFiles(sourcePath).ToList();
+ var restoreFiles = GetNonSystemFiles(restorePath).ToList();
+
+ Assert.That(sourceFiles.Count, Is.EqualTo(restoreFiles.Count),
+ $"Number of files in source and restore should match. Source: {sourceFiles.Count}, Restore: {restoreFiles.Count}");
+ Assert.That(sourceFiles.Count, Is.GreaterThan(0),
+ "Source partition should contain files to verify");
+ Assert.That(restoreFiles.Count, Is.GreaterThan(0),
+ "Restore partition should contain files to verify");
+
+ // Check that all source files exist in restore
+ foreach (var relativePath in sourceFiles)
+ {
+ Assert.That(restoreFiles, Does.Contain(relativePath),
+ $"Restored drive should contain file: {relativePath}");
+ }
+
+ // Compare file contents byte-by-byte
+ foreach (var relativeSourceFile in sourceFiles)
+ {
+ var sourceFile = Path.Combine(sourcePath, relativeSourceFile);
+ var restoreFile = Path.Combine(restorePath, relativeSourceFile);
+
+ if (File.Exists(restoreFile))
+ {
+ var sourceBytes = File.ReadAllBytes(sourceFile);
+ var restoreBytes = File.ReadAllBytes(restoreFile);
+
+ Assert.That(restoreBytes.Length, Is.EqualTo(sourceBytes.Length),
+ $"File size mismatch for {sourceFile}");
+ Assert.That(restoreBytes, Is.EqualTo(sourceBytes),
+ $"File content mismatch for {sourceFile}");
+ }
+ }
+
+ TestContext.Progress.WriteLine($"Verified {sourceFiles.Count} files match between source and restored drives");
+ }
+
+ ///
+ /// Verifies that the geometry metadata is present and valid.
+ ///
+ /// The backup target URL.
+ /// The options to use when accessing the backup.
+ /// The expected partitions and their sizes.
+ protected async Task VerifyGeometryMetadataAsync(string target, Dictionary options, (FileSystemType, long)[] partitions)
+ {
+ await TestContext.Progress.WriteLineAsync("Verifying geometry metadata...");
+
+ using (var c = new Controller("file://" + target, options, null))
+ {
+ var listResults = await c.ListAsync("*");
+ Assert.That(listResults.Files, Is.Not.Null);
+
+ var geometryFile = listResults.Files.FirstOrDefault(f => f.Path.EndsWith("geometry.json"));
+ Assert.That(geometryFile, Is.Not.Null, "geometry.json should be present in the backup");
+
+ // Restore and parse the geometry.json file
+ var geometryPath = Path.Combine(RESTOREFOLDER, $"geometry.json");
+ try
+ {
+ var restoreOptions = new Dictionary(options)
+ {
+ ["restore-path"] = RESTOREFOLDER,
+ ["overwrite"] = "true"
+ };
+
+ using (var restoreController = new Controller("file://" + target, restoreOptions, null))
+ {
+ var restoreResults = await restoreController.RestoreAsync(new[] { geometryFile!.Path });
+ TestUtils.AssertResults(restoreResults);
+ }
+
+ // Read and parse the geometry.json
+ if (File.Exists(geometryPath))
+ {
+ var json = File.ReadAllText(geometryPath);
+ var geometry = GeometryMetadata.FromJson(json);
+
+ Assert.That(geometry, Is.Not.Null, "geometry.json should deserialize successfully");
+ Assert.That(geometry!.Version, Is.GreaterThan(0), "Geometry version should be set");
+ Assert.That(geometry.Disk, Is.Not.Null, "Disk geometry should be present");
+ Assert.That(geometry.Disk!.Size, Is.GreaterThan(0), "Disk size should be greater than 0");
+ Assert.That(geometry.Disk.SectorSize, Is.GreaterThan(0), "Sector size should be greater than 0");
+ Assert.That(geometry.PartitionTable, Is.Not.Null, "Partition table should be present");
+ Assert.That(geometry.Partitions, Is.Not.Null, "Partitions list should be present");
+ Assert.That(geometry.Partitions!.Count, Is.GreaterThan(0), "Should have at least one partition");
+
+ await TestContext.Progress.WriteLineAsync($"Geometry metadata verified: Disk size={geometry.Disk.Size}, " +
+ $"Sector size={geometry.Disk.SectorSize}, Partitions={geometry.Partitions.Count}, " +
+ $"Table type={geometry.PartitionTable!.Type}");
+
+ for (int i = 0; i < geometry.Partitions.Count; i++)
+ {
+ var partition = geometry.Partitions[i];
+ await TestContext.Progress.WriteLineAsync($"Partition {i}: Type={partition.Type}, Start={partition.StartOffset}, Size={partition.Size}");
+
+ Assert.That(partition.Size, Is.GreaterThan(0), $"Partition {i} size should be greater than 0");
+ if (partitions[i].Item2 > 0)
+ {
+ Assert.That(partition.Size, Is.EqualTo(partitions[i].Item2), $"Partition {i} size should match expected");
+ }
+ Assert.That(partition.StartOffset, Is.GreaterThanOrEqualTo(0), $"Partition {i} start offset should be non-negative");
+ Assert.That(partition.Type, Is.Not.Null, $"Partition {i} type should be present");
+ Assert.That(partition.FilesystemType, Is.Not.Null, $"Partition {i} filesystem type should be present");
+ // Type shouldn't be checked until implemented.
+ //Assert.That(partition.FilesystemType, Is.EqualTo(partitions[i].Item1), $"Partition {i} filesystem type should be known");
+ }
+ }
+ }
+ finally
+ {
+ try
+ {
+ if (File.Exists(geometryPath))
+ {
+ File.Delete(geometryPath);
+ }
+ }
+ catch
+ {
+ // Ignore cleanup errors
+ }
+ }
+ }
+ }
+
+ ///
+ /// Verifies that the restored partition table matches the source.
+ ///
+ /// The physical drive path of the source disk.
+ /// The physical drive path of the restored disk.
+ protected void VerifyPartitionTableMatches(string sourceDrivePath, string restoreDrivePath)
+ {
+ TestContext.Progress.WriteLine("Verifying partition table matches...");
+
+ // Get source disk details
+ var sourceTable = _diskHelper.GetPartitionTable(sourceDrivePath);
+ TestContext.Progress.WriteLine("Source disk details:\n" + sourceTable);
+
+ // Get restore disk details
+ var restoreTable = _diskHelper.GetPartitionTable(restoreDrivePath);
+ TestContext.Progress.WriteLine("Restore disk details:\n" + restoreTable);
+
+ Assert.AreEqual(sourceTable.Type, restoreTable.Type, "Partition table types should match");
+ Assert.AreEqual(sourceTable.SectorSize, restoreTable.SectorSize, "Sector sizes should match");
+ Assert.AreEqual(sourceTable.Size, restoreTable.Size, "Size of the disks should match");
+
+ // Retrieve partition information
+ var sourcePartitions = _diskHelper.GetPartitions(sourceDrivePath);
+ var restorePartitions = _diskHelper.GetPartitions(restoreDrivePath);
+
+ // Verify partition counts match
+ Assert.AreEqual(sourcePartitions.Length, restorePartitions.Length, "Number of partitions should match");
+
+ // Verify partitions match
+ for (int i = 0; i < sourcePartitions.Length; i++)
+ {
+ Assert.AreEqual(sourcePartitions[i].Type, restorePartitions[i].Type, $"Partition {i} types should match");
+ Assert.AreEqual(sourcePartitions[i].StartOffset, restorePartitions[i].StartOffset, $"Partition {i} offsets should match");
+ Assert.AreEqual(sourcePartitions[i].Size, restorePartitions[i].Size, $"Partition {i} sizes should match");
+ }
+
+ TestContext.Progress.WriteLine($"Partition table verified.");
+ }
+}
\ No newline at end of file
diff --git a/Duplicati/UnitTest/DiskImage/IntegrationTests/ParsingTests.cs b/Duplicati/UnitTest/DiskImage/IntegrationTests/ParsingTests.cs
new file mode 100644
index 000000000..9b68b005f
--- /dev/null
+++ b/Duplicati/UnitTest/DiskImage/IntegrationTests/ParsingTests.cs
@@ -0,0 +1,201 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Duplicati.Library.Interface;
+using Duplicati.Library.Main;
+using Duplicati.Proprietary.DiskImage;
+using Duplicati.Proprietary.DiskImage.General;
+using NUnit.Framework;
+
+namespace Duplicati.UnitTest.DiskImage.IntegrationTests;
+
+#nullable enable
+
+public class ParsingTests : DiskImageTests
+{
+ [Test]
+ [Category("DiskImage")]
+ public async Task Test_GeometryMetadata_Verification_Async()
+ {
+ await TestContext.Progress.WriteLineAsync("Test: Full Round-Trip Backup + Restore");
+
+ var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, 100 * MiB);
+ await TestContext.Progress.WriteLineAsync($"Source Disk created at: {_sourceImagePath}");
+
+ (FileSystemType, long)[] partitions = [(FileSystemType.FAT32, 50 * MiB), (FileSystemType.FAT32, 0)];
+ var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, PartitionTableType.GPT, partitions);
+ _diskHelper.Unmount(sourceDrivePath);
+ await TestContext.Progress.WriteLineAsync($"Source Disk initialized with partition(s): {string.Join(", ", sourcePartitions)}");
+
+ // Backup
+ var backupResults = await RunBackupAsync(sourceDrivePath);
+ TestUtils.AssertResults(backupResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
+
+ // List backup contents and verify geometry.json is present
+ using (var c = new Controller("file://" + TARGETFOLDER, TestOptions, null))
+ {
+ var listResults = await c.ListAsync("*");
+ TestUtils.AssertResults(listResults);
+
+ // Check that geometry.json is in the backup
+ var files = listResults.Files;
+ Assert.That(files, Is.Not.Null);
+ var geometryFile = files.FirstOrDefault(f => f.Path.EndsWith("geometry.json"));
+ Assert.That(geometryFile, Is.Not.Null, "geometry.json should be present in the backup");
+ }
+
+ // Verify geometry metadata contains correct information
+ await VerifyGeometryMetadataAsync(TARGETFOLDER, TestOptions, partitions);
+ }
+
+ [Test]
+ [Category("DiskImage")]
+ public async Task Test_SourceProvider_Enumeration_Async()
+ {
+ await TestContext.Progress.WriteLineAsync("Test: SourceProvider Enumeration");
+
+ // Setup: Create GPT disk with one FAT32 partition
+ var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, (100 * MiB));
+ var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, PartitionTableType.GPT, [(FileSystemType.FAT32, 0)]);
+ await TestContext.Progress.WriteLineAsync($"Source disk image created at: {_sourceImagePath}");
+
+ // Directly instantiate SourceProvider
+ // Note: When directly instantiating SourceProvider, we don't use the @ prefix
+ // The @ prefix is only for Controller to recognize it as a remote source
+ var sourceUrl = $"diskimage://{sourceDrivePath}";
+ using var provider = new SourceProvider(sourceUrl, "", new Dictionary());
+
+ // Initialize the provider
+ await provider.InitializeAsync(CancellationToken.None);
+
+ // Enumerate entries
+ var diskEntries = new List();
+ await foreach (var entry in provider.EnumerateAsync(CancellationToken.None))
+ {
+ diskEntries.Add(entry);
+ await TestContext.Progress.WriteLineAsync($"Found entry: {entry.Path} (IsFolder: {entry.IsFolder}, Size: {entry.Size})");
+ }
+
+ // Verify hierarchy: disk → partition → filesystem → files
+ Assert.That(diskEntries.Count, Is.GreaterThan(0), "Should have at least one entry (the disk)");
+
+ // Fetch the rest of the enumaration.
+ // Check for disk entry
+ var diskEntry = diskEntries.FirstOrDefault(e => e.IsRootEntry);
+ Assert.That(diskEntry, Is.Not.Null, "Should have a root disk entry");
+
+ var entries = new List();
+ await foreach (var entry in diskEntry!.Enumerate(CancellationToken.None))
+ entries.Add(entry);
+
+ await TestContext.Progress.WriteLineAsync($"Total entries found: {entries.Count}");
+
+ // Check for geometry.json
+ var geometryEntry = entries.FirstOrDefault(e => e.Path.EndsWith("geometry.json"));
+ Assert.That(geometryEntry, Is.Not.Null, "Should have geometry.json entry");
+
+ // Check for partition entries
+ var partitionEntries = entries.Where(e => e.Path.Contains("part_")).ToList();
+ Assert.That(partitionEntries.Count, Is.GreaterThan(0), "Should have at least one partition entry");
+
+ // Add all of the filesystems.
+ var fsEntries = new List();
+ foreach (var partitionEntry in partitionEntries)
+ await foreach (var entry in partitionEntry.Enumerate(CancellationToken.None))
+ fsEntries.Add(entry);
+
+ // Check for filesystem entries
+ Assert.That(fsEntries.All(x => x.Path.Contains("fs_")));
+ Assert.That(fsEntries.Count, Is.GreaterThan(0), "Should have at least one filesystem entry");
+ }
+
+ [Test]
+ [Category("DiskImage")]
+ public async Task Test_AutoUnmountOption_RestoreWhileOnline_Async()
+ {
+ await TestContext.Progress.WriteLineAsync("Test: Auto Unmount Option - Restore While Disk Online");
+
+ // Setup source disk image: 100MB, GPT, single FAT32 partition
+ var sourceDrivePath = _diskHelper.CreateDisk(_sourceImagePath, (100 * MiB));
+ var sourcePartitions = _diskHelper.InitializeDisk(sourceDrivePath, PartitionTableType.GPT, [(FileSystemType.FAT32, 0)]);
+ await TestContext.Progress.WriteLineAsync($"Source disk image created at: {_sourceImagePath}");
+
+ // Generate some test data
+ await ToolTests.GenerateTestDataAsync(sourcePartitions.First(), 10, 5, 2, 1024);
+ _diskHelper.FlushDisk(sourceDrivePath);
+ _diskHelper.Unmount(sourceDrivePath);
+ await TestContext.Progress.WriteLineAsync($"Test data generated on source partition");
+
+ // Backup
+ var backupResults = await RunBackupAsync(sourceDrivePath);
+ TestUtils.AssertResults(backupResults, ignoredWarnings: ["diskimage-filesystem-parsed"]);
+ await TestContext.Progress.WriteLineAsync($"Backup completed successfully");
+
+ // Create and attach disk image
+ var restoreDrivePath = _diskHelper.CreateDisk(_restoreImagePath, (100 * MiB));
+
+ // Initialize disk (the restore will overwrite this, but we need it formatted)
+ _diskHelper.InitializeDisk(restoreDrivePath, PartitionTableType.GPT, [(FileSystemType.FAT32, 50 * MiB), (FileSystemType.FAT32, 0)]);
+ _diskHelper.Mount(restoreDrivePath, _restoreMountPath);
+ await TestContext.Progress.WriteLineAsync($"Restore disk image created and kept online at: {_restoreImagePath}");
+
+ // First attempt: Restore without auto-unmount option should fail
+ // because the disk is online and in use
+ await TestContext.Progress.WriteLineAsync("Attempting restore without auto-unmount (should fail)...");
+ var options = new Dictionary(TestOptions)
+ {
+ ["restore-path"] = $"@diskimage://{restoreDrivePath}",
+ ["overwrite"] = "true",
+ ["restore-file-processors"] = "1",
+ // Explicitly disable auto-unmount (though it's disabled by default)
+ ["diskimage-restore-auto-unmount"] = "false"
+ };
+
+ using (var c = new Controller("file://" + TARGETFOLDER, options, null))
+ {
+ try
+ {
+ var results = await c.RestoreAsync(["*"]);
+
+ // Verify that the restore failed (has errors)
+ Assert.That(results.Errors.Any(), Is.True,
+ "Restore should fail when target disk is online and auto-unmount is not enabled");
+ await TestContext.Progress.WriteLineAsync($"Restore failed as expected with errors: {string.Join(", ", results.Errors)}");
+ }
+ catch (IOException)
+ {
+ // Assumed to fail
+ }
+ catch (UserInformationException)
+ {
+ // Assumed to fail
+ }
+ }
+
+ // Second attempt: Restore with auto-unmount option should succeed
+ await TestContext.Progress.WriteLineAsync("Attempting restore with auto-unmount enabled (should succeed)...");
+
+ options["diskimage-restore-auto-unmount"] = "true";
+ using (var c = new Controller("file://" + TARGETFOLDER, options, null))
+ {
+ var results = await c.RestoreAsync(["*"]);
+
+ Assert.That(!results.Errors.Any() && results.Warnings.Count() <= 1);
+ }
+ await TestContext.Progress.WriteLineAsync($"Restore completed successfully with auto-unmount option");
+
+ // Mount before verification, to make disks online on Windows.
+ sourcePartitions = _diskHelper.Mount(sourceDrivePath, _sourceMountPath, readOnly: true);
+ var restorePartitions = _diskHelper.Mount(restoreDrivePath, _restoreMountPath, readOnly: true);
+
+ // Verify partition table matches
+ VerifyPartitionTableMatches(sourceDrivePath, restoreDrivePath);
+
+ // Verify data matches byte-for-byte
+ var sourcePartition = sourcePartitions.First();
+ var restorePartition = restorePartitions.First();
+ CompareDirectories(sourcePartition, restorePartition);
+ }
+}
\ No newline at end of file
diff --git a/Duplicati/UnitTest/DiskImage/IntegrationTests/SetupTeardown.cs b/Duplicati/UnitTest/DiskImage/IntegrationTests/SetupTeardown.cs
new file mode 100644
index 000000000..b43b68a8b
--- /dev/null
+++ b/Duplicati/UnitTest/DiskImage/IntegrationTests/SetupTeardown.cs
@@ -0,0 +1,89 @@
+using System;
+using System.IO;
+using Duplicati.UnitTest.DiskImage.Helpers;
+using NUnit.Framework;
+
+namespace Duplicati.UnitTest.DiskImage.IntegrationTests;
+
+///
+/// Unit tests for the DiskImage backup and restore functionality.
+/// These tests use disk image files managed to test the full backup and restore flow.
+///
+[TestFixture]
+[Category("DiskImage")]
+[Platform("Win,MacOsX,Linux")]
+public partial class DiskImageTests : BasicSetupHelper
+{
+ protected string _sourceImagePath = null!;
+ protected string _restoreImagePath = null!;
+ protected string _sourceMountPath = null!;
+ protected string _restoreMountPath = null!;
+ protected IDiskImageHelper _diskHelper = null!;
+
+ protected const long MiB = 1024 * 1024;
+
+ ///
+ /// Sets up the test environment before each test.
+ /// Creates the disk helper and temporary disk image paths.
+ ///
+ [SetUp]
+ public void DiskImageSetUp()
+ {
+ // Create the appropriate disk image helper for the current platform
+ _diskHelper = DiskImageHelperFactory.Create();
+
+ // Check for admin privileges
+ if (!_diskHelper.HasRequiredPrivileges())
+ {
+ Assert.Ignore("DiskImage tests require administrator privileges");
+ }
+
+ // Create temp disk image paths
+ var extension = DiskImageTestHelpers.GetPlatformDiskImageExtension();
+ _sourceImagePath = Path.Combine(DATAFOLDER, $"duplicati_test_source_{Guid.NewGuid()}.{extension}");
+ _restoreImagePath = Path.Combine(DATAFOLDER, $"duplicati_test_restore_{Guid.NewGuid()}.{extension}");
+ _sourceMountPath = Path.Combine(DATAFOLDER, $"mnt_source_{Guid.NewGuid()}");
+ _restoreMountPath = Path.Combine(DATAFOLDER, $"mnt_restore_{Guid.NewGuid()}");
+ Directory.CreateDirectory(_sourceMountPath);
+ Directory.CreateDirectory(_restoreMountPath);
+ }
+
+ ///
+ /// Cleans up the test environment after each test.
+ /// Detaches and deletes disk image files.
+ ///
+ [TearDown]
+ public void DiskImageTearDown()
+ {
+ // Cleanup disk images
+ try
+ {
+ _diskHelper.CleanupDisk(_sourceImagePath);
+ }
+ catch (Exception ex)
+ {
+ TestContext.Progress.WriteLine($"Warning: Failed to cleanup source disk image: {ex.Message}");
+ }
+
+ try
+ {
+ _diskHelper.CleanupDisk(_restoreImagePath);
+ }
+ catch (Exception ex)
+ {
+ TestContext.Progress.WriteLine($"Warning: Failed to cleanup restore disk image: {ex.Message}");
+ }
+
+ try
+ {
+ _diskHelper.Dispose();
+ }
+ catch (Exception ex)
+ {
+ TestContext.Progress.WriteLine($"Warning: Failed to dispose disk helper: {ex.Message}");
+ }
+
+ DiskImageTestHelpers.SafeDeleteFile(_sourceImagePath);
+ DiskImageTestHelpers.SafeDeleteFile(_restoreImagePath);
+ }
+}
\ No newline at end of file
diff --git a/Duplicati/UnitTest/DiskImage/Ntfs/NtfsBitmapTests.cs b/Duplicati/UnitTest/DiskImage/Ntfs/NtfsBitmapTests.cs
new file mode 100644
index 000000000..55266d318
--- /dev/null
+++ b/Duplicati/UnitTest/DiskImage/Ntfs/NtfsBitmapTests.cs
@@ -0,0 +1,362 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System;
+using System.Buffers.Binary;
+using System.IO;
+using System.Linq;
+using System.Text;
+using Duplicati.Proprietary.DiskImage.Filesystem.Ntfs;
+using NUnit.Framework;
+using NUnit.Framework.Legacy;
+using Assert = NUnit.Framework.Legacy.ClassicAssert;
+
+#nullable enable
+
+namespace Duplicati.UnitTest.DiskImage.Ntfs;
+
+///
+/// Unit tests for the class.
+///
+[TestFixture]
+[Category("DiskImageUnit")]
+public class NtfsBitmapTests
+{
+ ///
+ /// Creates a valid NTFS boot sector with known values for testing.
+ ///
+ private static byte[] CreateValidBootSector()
+ {
+ var bootSector = new byte[512];
+
+ // OEM ID at offset 0x03 (8 bytes: "NTFS ")
+ var oemId = Encoding.ASCII.GetBytes("NTFS ");
+ oemId.CopyTo(bootSector, 0x03);
+
+ // Bytes per sector at offset 0x0B (512 = 0x0200)
+ BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0B, 2), 512);
+
+ // Sectors per cluster at offset 0x0D (8 sectors)
+ bootSector[0x0D] = 8;
+
+ // Total sectors at offset 0x28 (1000 sectors = 125 clusters)
+ BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x28, 8), 1000);
+
+ // MFT cluster number at offset 0x30 (cluster 2)
+ BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x30, 8), 2);
+
+ // MFT mirror cluster number at offset 0x38 (cluster 100)
+ BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x38, 8), 100);
+
+ // Clusters per MFT record at offset 0x40 (-10 means 2^10 = 1024 bytes)
+ bootSector[0x40] = unchecked((byte)-10);
+
+ // Clusters per index block at offset 0x44 (-12 means 2^12 = 4096 bytes)
+ bootSector[0x44] = unchecked((byte)-12);
+
+ // Volume serial number at offset 0x48
+ BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x48, 8), 0x123456789ABCDEF0);
+
+ // Boot sector signature at offset 510
+ BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(510, 2), 0xAA55);
+
+ return bootSector;
+ }
+
+ [Test]
+ public void Test_NtfsBitmap_FreeCluster_NotAllocated()
+ {
+ var bootSectorData = CreateValidBootSector();
+ var bootSector = new NtfsBootSector(bootSectorData);
+
+ // Create all-zero bitmap (all clusters free)
+ var bitmapData = new byte[(bootSector.TotalClusters + 7) / 8];
+
+ var bitmap = new NtfsBitmap(bootSector, bitmapData);
+
+ Assert.IsFalse(bitmap.IsClusterAllocated(0), "Cluster 0 should be free");
+ Assert.IsFalse(bitmap.IsClusterAllocated(10), "Cluster 10 should be free");
+ Assert.IsFalse(bitmap.IsClusterAllocated(100), "Cluster 100 should be free");
+ }
+
+ [Test]
+ public void Test_NtfsBitmap_AllocatedCluster_IsAllocated()
+ {
+ var bootSectorData = CreateValidBootSector();
+ var bootSector = new NtfsBootSector(bootSectorData);
+
+ // Create bitmap with specific bits set
+ var bitmapData = new byte[(bootSector.TotalClusters + 7) / 8];
+ // Set bit 0 (cluster 0) - LSB of byte 0
+ bitmapData[0] = 0x01;
+ // Set bit 10 (cluster 10) - bit 2 of byte 1
+ bitmapData[1] = 0x04;
+
+ var bitmap = new NtfsBitmap(bootSector, bitmapData);
+
+ Assert.IsTrue(bitmap.IsClusterAllocated(0), "Cluster 0 should be allocated");
+ Assert.IsTrue(bitmap.IsClusterAllocated(10), "Cluster 10 should be allocated");
+ Assert.IsFalse(bitmap.IsClusterAllocated(1), "Cluster 1 should be free");
+ Assert.IsFalse(bitmap.IsClusterAllocated(5), "Cluster 5 should be free");
+ }
+
+ [Test]
+ public void Test_NtfsBitmap_MixedAllocation()
+ {
+ var bootSectorData = CreateValidBootSector();
+ var bootSector = new NtfsBootSector(bootSectorData);
+
+ // Create bitmap with mix of allocated and free clusters
+ var bitmapData = new byte[(bootSector.TotalClusters + 7) / 8];
+ // Byte 0: bits 0, 2, 4, 6 allocated = 0x55
+ bitmapData[0] = 0x55;
+ // Byte 1: bits 1, 3, 5, 7 allocated = 0xAA
+ bitmapData[1] = 0xAA;
+
+ var bitmap = new NtfsBitmap(bootSector, bitmapData);
+
+ // 8 bits set in first two bytes
+ Assert.AreEqual(8, bitmap.AllocatedClusters, "Should have 8 allocated clusters");
+ Assert.AreEqual(bootSector.TotalClusters - 8, bitmap.FreeClusters, "Free clusters should match");
+ }
+
+ [Test]
+ public void Test_NtfsBitmap_AllAllocated()
+ {
+ var bootSectorData = CreateValidBootSector();
+ var bootSector = new NtfsBootSector(bootSectorData);
+
+ // Create all-ones bitmap
+ var bitmapData = new byte[(bootSector.TotalClusters + 7) / 8];
+ for (var i = 0; i < bitmapData.Length; i++)
+ {
+ bitmapData[i] = 0xFF;
+ }
+
+ var bitmap = new NtfsBitmap(bootSector, bitmapData);
+
+ Assert.AreEqual(0, bitmap.FreeClusters, "All clusters should be allocated");
+ Assert.AreEqual(bootSector.TotalClusters, bitmap.AllocatedClusters, "Allocated clusters should equal total");
+ Assert.IsTrue(bitmap.IsClusterAllocated(0), "Cluster 0 should be allocated");
+ Assert.IsTrue(bitmap.IsClusterAllocated((int)bootSector.TotalClusters - 1), "Last cluster should be allocated");
+ }
+
+ [Test]
+ public void Test_NtfsBitmap_ClusterOutOfRange_Throws()
+ {
+ var bootSectorData = CreateValidBootSector();
+ var bootSector = new NtfsBootSector(bootSectorData);
+
+ var bitmapData = new byte[(bootSector.TotalClusters + 7) / 8];
+ var bitmap = new NtfsBitmap(bootSector, bitmapData);
+
+ // Negative cluster number
+ Assert.Throws(() => bitmap.IsClusterAllocated(-1));
+
+ // Cluster number beyond total clusters
+ Assert.Throws(() => bitmap.IsClusterAllocated(bootSector.TotalClusters));
+ Assert.Throws(() => bitmap.IsClusterAllocated(bootSector.TotalClusters + 100));
+ }
+
+ [Test]
+ public void Test_NtfsBitmap_TotalClusters_Correct()
+ {
+ var bootSectorData = CreateValidBootSector();
+ var bootSector = new NtfsBootSector(bootSectorData);
+
+ var bitmapData = new byte[(bootSector.TotalClusters + 7) / 8];
+ var bitmap = new NtfsBitmap(bootSector, bitmapData);
+
+ // Total clusters should be 1000 sectors / 8 sectors per cluster = 125
+ Assert.AreEqual(125, bitmap.TotalClusters, "Total clusters should match boot sector");
+ }
+
+ [Test]
+ public void Test_NtfsBitmap_BitOrdering()
+ {
+ var bootSectorData = CreateValidBootSector();
+ var bootSector = new NtfsBootSector(bootSectorData);
+
+ // Create bitmap testing LSB-first ordering
+ var bitmapData = new byte[4]; // 32 clusters worth
+ // Bit 0 of byte 0 -> cluster 0
+ bitmapData[0] = 0x01;
+ // Bit 7 of byte 0 -> cluster 7
+ // Bit 0 of byte 1 -> cluster 8
+ bitmapData[1] = 0x01;
+
+ var bitmap = new NtfsBitmap(bootSector, bitmapData);
+
+ Assert.IsTrue(bitmap.IsClusterAllocated(0), "Bit 0 of byte 0 should be cluster 0");
+ Assert.IsFalse(bitmap.IsClusterAllocated(1), "Bit 1 should not be set");
+ Assert.IsFalse(bitmap.IsClusterAllocated(7), "Bit 7 should not be set");
+ Assert.IsTrue(bitmap.IsClusterAllocated(8), "Bit 0 of byte 1 should be cluster 8");
+
+ // Test bit 7 of byte 0
+ bitmapData[0] = 0x80;
+ bitmapData[1] = 0x00;
+ var bitmap2 = new NtfsBitmap(bootSector, bitmapData);
+
+ Assert.IsFalse(bitmap2.IsClusterAllocated(0), "Bit 0 should not be set");
+ Assert.IsTrue(bitmap2.IsClusterAllocated(7), "Bit 7 of byte 0 should be cluster 7");
+ }
+
+ [Test]
+ public void Test_NtfsBitmap_FixupArray_Applied()
+ {
+ // Create a fake MFT record with fixup array
+ var recordSize = 1024; // Standard MFT record size
+ var recordData = new byte[recordSize];
+
+ // FILE magic at offset 0
+ BinaryPrimitives.WriteUInt32LittleEndian(recordData.AsSpan(0, 4), 0x454C4946);
+
+ // Fixup offset at 0x04 (offset 48 = 0x30)
+ BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(0x04, 2), 0x30);
+
+ // Fixup count at 0x06 (3 entries = 2 sectors + sequence number)
+ BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(0x06, 2), 3);
+
+ // Set up fixup array at offset 0x30
+ // First entry is sequence number
+ BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(0x30, 2), 0x1234);
+ // Second entry is original value for sector 1
+ BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(0x32, 2), 0xABCD);
+ // Third entry is original value for sector 2
+ BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(0x34, 2), 0xEF01);
+
+ // Place sequence numbers at end of sectors (positions 510 and 1022)
+ BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(510, 2), 0x1234);
+ BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(1022, 2), 0x1234);
+
+ // Apply fixup
+ NtfsBitmap.ApplyFixupArray(recordData);
+
+ // Verify the original values were restored
+ Assert.AreEqual(0xABCD, BinaryPrimitives.ReadUInt16LittleEndian(recordData.AsSpan(510, 2)), "Sector 1 original value should be restored");
+ Assert.AreEqual(0xEF01, BinaryPrimitives.ReadUInt16LittleEndian(recordData.AsSpan(1022, 2)), "Sector 2 original value should be restored");
+ }
+
+ [Test]
+ public void Test_NtfsBitmap_FixupArray_Mismatch_Throws()
+ {
+ // Create a fake MFT record with incorrect fixup values
+ var recordSize = 1024;
+ var recordData = new byte[recordSize];
+
+ // FILE magic
+ BinaryPrimitives.WriteUInt32LittleEndian(recordData.AsSpan(0, 4), 0x454C4946);
+
+ // Fixup offset
+ BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(0x04, 2), 0x30);
+
+ // Fixup count
+ BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(0x06, 2), 3);
+
+ // Sequence number
+ BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(0x30, 2), 0x1234);
+
+ // Put wrong sequence number at end of sector 1
+ BinaryPrimitives.WriteUInt16LittleEndian(recordData.AsSpan(510, 2), 0x5678);
+
+ // This should throw due to mismatch
+ Assert.Throws(() => NtfsBitmap.ApplyFixupArray(recordData));
+ }
+
+ [Test]
+ public void Test_NtfsBitmap_DataRunParsing_SingleRun()
+ {
+ // Data run: header 0x11 means 1 byte for length, 1 byte for offset
+ // Run length: 0x10 (16 clusters)
+ // Run offset: 0x05 (cluster 5, absolute since first run)
+ var data = new byte[] { 0x11, 0x10, 0x05, 0x00 };
+
+ var runs = NtfsBitmap.ParseDataRuns(data.AsSpan(), 0);
+
+ Assert.AreEqual(1, runs.Count, "Should have 1 run");
+ Assert.AreEqual(5, runs[0].startCluster, "Start cluster should be 5");
+ Assert.AreEqual(16, runs[0].clusterCount, "Run length should be 16 clusters");
+ }
+
+ [Test]
+ public void Test_NtfsBitmap_DataRunParsing_MultipleRuns()
+ {
+ // Multiple data runs
+ // Run 1: 0x11, 0x0A, 0x05 -> 10 clusters starting at cluster 5
+ // Run 2: 0x11, 0x14, 0x0A -> 20 clusters starting at cluster 15 (5 + 10)
+ // Terminator: 0x00
+ var data = new byte[] { 0x11, 0x0A, 0x05, 0x11, 0x14, 0x0A, 0x00 };
+
+ var runs = NtfsBitmap.ParseDataRuns(data.AsSpan(), 0);
+
+ Assert.AreEqual(2, runs.Count, "Should have 2 runs");
+ Assert.AreEqual(5, runs[0].startCluster, "First run should start at cluster 5");
+ Assert.AreEqual(10, runs[0].clusterCount, "First run should be 10 clusters");
+ Assert.AreEqual(15, runs[1].startCluster, "Second run should start at cluster 15");
+ Assert.AreEqual(20, runs[1].clusterCount, "Second run should be 20 clusters");
+ }
+
+ [Test]
+ public void Test_NtfsBitmap_DataRunParsing_NegativeOffset()
+ {
+ // Test negative offset (for fragmented files that go backwards)
+ // Run 1: 0x11, 0x0A, 0x64 -> 10 clusters starting at cluster 100
+ // Run 2: 0x11, 0x05, 0xF6 -> 5 clusters, offset = -10 (0xF6 as signed byte)
+ // Terminator: 0x00
+ var data = new byte[] { 0x11, 0x0A, 0x64, 0x11, 0x05, 0xF6, 0x00 };
+
+ var runs = NtfsBitmap.ParseDataRuns(data.AsSpan(), 0);
+
+ Assert.AreEqual(2, runs.Count, "Should have 2 runs");
+ Assert.AreEqual(100, runs[0].startCluster, "First run should start at cluster 100");
+ Assert.AreEqual(90, runs[1].startCluster, "Second run should start at cluster 90 (100 - 10)");
+ }
+
+ [Test]
+ public void Test_NtfsBitmap_DataRunParsing_SparseRun()
+ {
+ // Test sparse run (zero offset means sparse/unallocated extent)
+ // 0x11, 0x0A, 0x00 -> 10 clusters with zero offset = sparse
+ var data = new byte[] { 0x11, 0x0A, 0x00, 0x00 };
+
+ var runs = NtfsBitmap.ParseDataRuns(data.AsSpan(), 0);
+
+ Assert.AreEqual(1, runs.Count, "Should have 1 run");
+ Assert.AreEqual(0, runs[0].startCluster, "Sparse run should have startCluster = 0");
+ Assert.AreEqual(10, runs[0].clusterCount, "Run should be 10 clusters");
+ }
+
+ [Test]
+ public void Test_NtfsBitmap_DataRunParsing_MultibyteValues()
+ {
+ // Test data run with multi-byte values
+ // 0x23, 0x40, 0x42, 0x0F, 0x00 ->
+ // 3 bytes for length, 2 bytes for offset
+ // Length: 0x000F4240 = 1,000,000 clusters
+ // Offset: 0x0040 = 64
+ var data = new byte[] { 0x23, 0x40, 0x42, 0x0F, 0x40, 0x00, 0x00 };
+
+ var runs = NtfsBitmap.ParseDataRuns(data.AsSpan(), 0);
+
+ Assert.AreEqual(1, runs.Count, "Should have 1 run");
+ Assert.AreEqual(64, runs[0].startCluster, "Start cluster should be 64");
+ Assert.AreEqual(1000000, runs[0].clusterCount, "Run should be 1,000,000 clusters");
+ }
+
+ [Test]
+ public void Test_NtfsBitmap_DataRunParsing_InvalidHeader_Throws()
+ {
+ // Invalid header: 0x90 means 0 bytes for length (invalid)
+ var data = new byte[] { 0x90, 0x10, 0x05, 0x00 };
+
+ Assert.Throws(() => NtfsBitmap.ParseDataRuns(data.AsSpan(), 0));
+ }
+
+ [Test]
+ public void Test_NtfsBitmap_NullBitmapData_Throws()
+ {
+ var bootSectorData = CreateValidBootSector();
+ var bootSector = new NtfsBootSector(bootSectorData);
+
+ Assert.Throws(() => new NtfsBitmap(bootSector, null!));
+ }
+}
\ No newline at end of file
diff --git a/Duplicati/UnitTest/DiskImage/Ntfs/NtfsBootSectorTests.cs b/Duplicati/UnitTest/DiskImage/Ntfs/NtfsBootSectorTests.cs
new file mode 100644
index 000000000..301e1c4ab
--- /dev/null
+++ b/Duplicati/UnitTest/DiskImage/Ntfs/NtfsBootSectorTests.cs
@@ -0,0 +1,218 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System;
+using System.Buffers.Binary;
+using System.Text;
+using Duplicati.Proprietary.DiskImage.Filesystem.Ntfs;
+using NUnit.Framework;
+using NUnit.Framework.Legacy;
+using Assert = NUnit.Framework.Legacy.ClassicAssert;
+
+#nullable enable
+
+namespace Duplicati.UnitTest.DiskImage.Ntfs;
+
+///
+/// Unit tests for the parser.
+///
+[TestFixture]
+[Category("DiskImageUnit")]
+public class NtfsBootSectorTests
+{
+ ///
+ /// Creates a valid NTFS boot sector with known values for testing.
+ ///
+ private static byte[] CreateValidBootSector()
+ {
+ var bootSector = new byte[512];
+
+ // OEM ID at offset 0x03 (8 bytes: "NTFS ")
+ var oemId = Encoding.ASCII.GetBytes("NTFS ");
+ oemId.CopyTo(bootSector, 0x03);
+
+ // Bytes per sector at offset 0x0B (512 = 0x0200)
+ BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0B, 2), 512);
+
+ // Sectors per cluster at offset 0x0D (8 sectors)
+ bootSector[0x0D] = 8;
+
+ // Total sectors at offset 0x28 (2,000,000 sectors)
+ BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x28, 8), 2_000_000);
+
+ // MFT cluster number at offset 0x30 (cluster 100)
+ BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x30, 8), 100);
+
+ // MFT mirror cluster number at offset 0x38 (cluster 125000)
+ BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x38, 8), 125_000);
+
+ // Clusters per MFT record at offset 0x40 (-10 means 2^10 = 1024 bytes)
+ bootSector[0x40] = unchecked((byte)-10);
+
+ // Clusters per index block at offset 0x44 (-12 means 2^12 = 4096 bytes)
+ bootSector[0x44] = unchecked((byte)-12);
+
+ // Volume serial number at offset 0x48
+ BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x48, 8), 0x123456789ABCDEF0);
+
+ // Boot sector signature at offset 510: bytes 0x55, 0xAA on disk (0xAA55 as little-endian uint16)
+ BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(510, 2), 0xAA55);
+
+ return bootSector;
+ }
+
+ [Test]
+ public void Test_NtfsBootSector_ParseValidBootSector()
+ {
+ var bootSectorData = CreateValidBootSector();
+ var bootSector = new NtfsBootSector(bootSectorData);
+
+ Assert.AreEqual(512, bootSector.BytesPerSector);
+ Assert.AreEqual(8, bootSector.SectorsPerCluster);
+ Assert.AreEqual(2_000_000ul, bootSector.TotalSectors);
+ Assert.AreEqual(100ul, bootSector.MftClusterNumber);
+ Assert.AreEqual(125_000ul, bootSector.MftMirrorClusterNumber);
+ Assert.AreEqual(-10, bootSector.ClustersPerMftRecord);
+ Assert.AreEqual(-12, bootSector.ClustersPerIndexBlock);
+ Assert.AreEqual(0x123456789ABCDEF0ul, bootSector.VolumeSerialNumber);
+ }
+
+ [Test]
+ public void Test_NtfsBootSector_InvalidSignature_Throws()
+ {
+ var bootSectorData = CreateValidBootSector();
+ // Change the signature to an invalid value
+ BinaryPrimitives.WriteUInt16LittleEndian(bootSectorData.AsSpan(510, 2), 0x1234);
+
+ var ex = Assert.Throws(() => new NtfsBootSector(bootSectorData));
+ StringAssert.Contains("signature", ex!.Message);
+ }
+
+ [Test]
+ public void Test_NtfsBootSector_InvalidOemId_Throws()
+ {
+ var bootSectorData = CreateValidBootSector();
+ // Change OEM ID to "FAT32 "
+ var oemId = Encoding.ASCII.GetBytes("FAT32 ");
+ oemId.CopyTo(bootSectorData, 0x03);
+
+ var ex = Assert.Throws(() => new NtfsBootSector(bootSectorData));
+ StringAssert.Contains("OEM ID", ex!.Message);
+ }
+
+ [Test]
+ public void Test_NtfsBootSector_InvalidBytesPerSector_Throws()
+ {
+ var bootSectorData = CreateValidBootSector();
+ // Set bytes per sector to 768 (not a power of 2)
+ BinaryPrimitives.WriteUInt16LittleEndian(bootSectorData.AsSpan(0x0B, 2), 768);
+
+ var ex = Assert.Throws(() => new NtfsBootSector(bootSectorData));
+ StringAssert.Contains("BytesPerSector", ex!.Message);
+ }
+
+ [Test]
+ public void Test_NtfsBootSector_InvalidSectorsPerCluster_Throws()
+ {
+ var bootSectorData = CreateValidBootSector();
+ // Set sectors per cluster to 3 (not a power of 2)
+ bootSectorData[0x0D] = 3;
+
+ var ex = Assert.Throws(() => new NtfsBootSector(bootSectorData));
+ StringAssert.Contains("SectorsPerCluster", ex!.Message);
+ }
+
+ [Test]
+ public void Test_NtfsBootSector_DerivedProperties_Correct()
+ {
+ var bootSectorData = CreateValidBootSector();
+ var bootSector = new NtfsBootSector(bootSectorData);
+
+ // ClusterSize = BytesPerSector * SectorsPerCluster = 512 * 8 = 4096
+ Assert.AreEqual(4096, bootSector.ClusterSize);
+
+ // MftRecordSize = 2^10 = 1024 (ClustersPerMftRecord = -10)
+ Assert.AreEqual(1024, bootSector.MftRecordSize);
+
+ // MftByteOffset = MftClusterNumber * ClusterSize = 100 * 4096 = 409600
+ Assert.AreEqual(409600, bootSector.MftByteOffset);
+
+ // TotalClusters = TotalSectors / SectorsPerCluster = 2,000,000 / 8 = 250,000
+ Assert.AreEqual(250_000, bootSector.TotalClusters);
+
+ // TotalSize = TotalSectors * BytesPerSector = 2,000,000 * 512 = 1,024,000,000
+ Assert.AreEqual(1_024_000_000, bootSector.TotalSize);
+ }
+
+ [Test]
+ public void Test_NtfsBootSector_NegativeClustersPerMftRecord()
+ {
+ var bootSectorData = CreateValidBootSector();
+ // -10 should yield 1024-byte records (2^10)
+ bootSectorData[0x40] = unchecked((byte)-10);
+
+ var bootSector = new NtfsBootSector(bootSectorData);
+ Assert.AreEqual(1024, bootSector.MftRecordSize);
+ }
+
+ [Test]
+ public void Test_NtfsBootSector_PositiveClustersPerMftRecord()
+ {
+ var bootSectorData = CreateValidBootSector();
+ // 2 should yield 2 * ClusterSize = 2 * 4096 = 8192 bytes
+ bootSectorData[0x40] = 2;
+
+ var bootSector = new NtfsBootSector(bootSectorData);
+ Assert.AreEqual(8192, bootSector.MftRecordSize);
+ }
+
+ [Test]
+ public void Test_NtfsBootSector_TooSmallBuffer_Throws()
+ {
+ var smallBuffer = new byte[256];
+
+ var ex = Assert.Throws(() => new NtfsBootSector(smallBuffer));
+ StringAssert.Contains("512", ex!.Message);
+ }
+
+ [Test]
+ public void Test_NtfsBootSector_ValidBytesPerSectorValues()
+ {
+ ushort[] validValues = { 512, 1024, 2048, 4096 };
+
+ foreach (var bytesPerSector in validValues)
+ {
+ var bootSectorData = CreateValidBootSector();
+ BinaryPrimitives.WriteUInt16LittleEndian(bootSectorData.AsSpan(0x0B, 2), bytesPerSector);
+
+ var bootSector = new NtfsBootSector(bootSectorData);
+ Assert.AreEqual(bytesPerSector, bootSector.BytesPerSector);
+ }
+ }
+
+ [Test]
+ public void Test_NtfsBootSector_ValidSectorsPerClusterValues()
+ {
+ byte[] validValues = { 1, 2, 4, 8, 16, 32, 64, 128 };
+
+ foreach (var sectorsPerCluster in validValues)
+ {
+ var bootSectorData = CreateValidBootSector();
+ bootSectorData[0x0D] = sectorsPerCluster;
+
+ var bootSector = new NtfsBootSector(bootSectorData);
+ Assert.AreEqual(sectorsPerCluster, bootSector.SectorsPerCluster);
+ }
+ }
+
+ [Test]
+ public void Test_NtfsBootSector_LargerBuffer_Works()
+ {
+ // Test that a buffer larger than 512 bytes works (e.g., 4KB sector size)
+ var bootSectorData = new byte[4096];
+ var validData = CreateValidBootSector();
+ validData.CopyTo(bootSectorData, 0);
+
+ var bootSector = new NtfsBootSector(bootSectorData);
+ Assert.AreEqual(512, bootSector.BytesPerSector);
+ }
+}
diff --git a/Duplicati/UnitTest/DiskImage/Ntfs/NtfsFilesystemTests.cs b/Duplicati/UnitTest/DiskImage/Ntfs/NtfsFilesystemTests.cs
new file mode 100644
index 000000000..e3e7c1fae
--- /dev/null
+++ b/Duplicati/UnitTest/DiskImage/Ntfs/NtfsFilesystemTests.cs
@@ -0,0 +1,151 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System;
+using Duplicati.Proprietary.DiskImage.Filesystem;
+using Duplicati.Proprietary.DiskImage.Filesystem.Ntfs;
+using NUnit.Framework;
+using NUnit.Framework.Legacy;
+using Assert = NUnit.Framework.Legacy.ClassicAssert;
+
+#nullable enable
+
+namespace Duplicati.UnitTest.DiskImage.Ntfs;
+
+///
+/// Unit tests for the class and related types.
+///
+[TestFixture]
+[Category("DiskImageUnit")]
+public class NtfsFilesystemTests
+{
+ [Test]
+ public void Test_NtfsFilesystemMetadata_Properties()
+ {
+ var metadata = new NtfsFilesystemMetadata
+ {
+ BlockSize = 1024 * 1024,
+ ClusterSize = 4096,
+ TotalClusters = 1000,
+ AllocatedClusters = 500,
+ FreeClusters = 500,
+ MftRecordSize = 1024
+ };
+
+ Assert.AreEqual(1024 * 1024, metadata.BlockSize);
+ Assert.AreEqual(4096, metadata.ClusterSize);
+ Assert.AreEqual(1000, metadata.TotalClusters);
+ Assert.AreEqual(500, metadata.AllocatedClusters);
+ Assert.AreEqual(500, metadata.FreeClusters);
+ Assert.AreEqual(1024, metadata.MftRecordSize);
+ }
+
+ [Test]
+ public void Test_NtfsFilesystemMetadata_RecordEquality()
+ {
+ var metadata1 = new NtfsFilesystemMetadata
+ {
+ BlockSize = 1024 * 1024,
+ ClusterSize = 4096,
+ TotalClusters = 1000,
+ AllocatedClusters = 500,
+ FreeClusters = 500,
+ MftRecordSize = 1024
+ };
+
+ var metadata2 = new NtfsFilesystemMetadata
+ {
+ BlockSize = 1024 * 1024,
+ ClusterSize = 4096,
+ TotalClusters = 1000,
+ AllocatedClusters = 500,
+ FreeClusters = 500,
+ MftRecordSize = 1024
+ };
+
+ var metadata3 = new NtfsFilesystemMetadata
+ {
+ BlockSize = 512 * 1024, // Different
+ ClusterSize = 4096,
+ TotalClusters = 1000,
+ AllocatedClusters = 500,
+ FreeClusters = 500,
+ MftRecordSize = 1024
+ };
+
+ Assert.AreEqual(metadata1, metadata2);
+ Assert.AreNotEqual(metadata1, metadata3);
+ }
+
+ [Test]
+ public void Test_NtfsFilesystemMetadata_HasJournal_True()
+ {
+ var metadata = new NtfsFilesystemMetadata();
+
+ Assert.IsTrue(metadata.HasJournal, "HasJournal should always be true for NTFS");
+ }
+
+ [Test]
+ public void Test_NtfsFile_DefaultValues()
+ {
+ var file = new NtfsFile();
+
+ Assert.IsNull(file.Path);
+ Assert.IsNull(file.Address);
+ Assert.AreEqual(0, file.Size);
+ Assert.IsFalse(file.IsDirectory);
+ Assert.IsNull(file.LastModified);
+ Assert.IsFalse(file.IsAllocated);
+ }
+
+ [Test]
+ public void Test_NtfsFile_WithValues()
+ {
+ var timestamp = new DateTime(2023, 6, 15, 10, 30, 0, DateTimeKind.Utc);
+ var file = new NtfsFile
+ {
+ Path = "test_block",
+ Address = 1024,
+ Size = 1024 * 1024,
+ LastModified = timestamp,
+ IsAllocated = true
+ };
+
+ Assert.AreEqual("test_block", file.Path);
+ Assert.AreEqual(1024, file.Address);
+ Assert.AreEqual(1024 * 1024, file.Size);
+ Assert.IsFalse(file.IsDirectory);
+ Assert.AreEqual(timestamp, file.LastModified);
+ Assert.IsTrue(file.IsAllocated);
+ }
+
+ [Test]
+ public void Test_NtfsFile_ImplementsIFile()
+ {
+ var file = new NtfsFile();
+ Assert.IsInstanceOf(file);
+ }
+
+ [Test]
+ public void Test_NtfsFile_LastModified_Nullable()
+ {
+ var file1 = new NtfsFile { LastModified = DateTime.UtcNow };
+ var file2 = new NtfsFile { LastModified = null };
+
+ Assert.IsTrue(file1.LastModified.HasValue);
+ Assert.IsFalse(file2.LastModified.HasValue);
+ }
+
+ [Test]
+ public void Test_NtfsFile_IsAllocated_FalseByDefault()
+ {
+ var file = new NtfsFile();
+ Assert.IsFalse(file.IsAllocated);
+ }
+
+ [Test]
+ public void Test_NtfsFile_IsAllocated_CanBeTrue()
+ {
+ var file = new NtfsFile { IsAllocated = true };
+ Assert.IsTrue(file.IsAllocated);
+ }
+}
diff --git a/Duplicati/UnitTest/DiskImage/Ntfs/NtfsMftWalkerTests.cs b/Duplicati/UnitTest/DiskImage/Ntfs/NtfsMftWalkerTests.cs
new file mode 100644
index 000000000..d2148a075
--- /dev/null
+++ b/Duplicati/UnitTest/DiskImage/Ntfs/NtfsMftWalkerTests.cs
@@ -0,0 +1,688 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Duplicati.Proprietary.DiskImage.Filesystem.Ntfs;
+using NUnit.Framework;
+using NUnit.Framework.Legacy;
+using Assert = NUnit.Framework.Legacy.ClassicAssert;
+
+#nullable enable
+
+namespace Duplicati.UnitTest.DiskImage.Ntfs;
+
+///
+/// Unit tests for the class.
+///
+[TestFixture]
+[Category("DiskImageUnit")]
+public class NtfsMftWalkerTests
+{
+ ///
+ /// Creates a valid NTFS boot sector with known values for testing.
+ ///
+ private static byte[] CreateValidBootSector()
+ {
+ var bootSector = new byte[512];
+
+ // OEM ID at offset 0x03 (8 bytes: "NTFS ")
+ var oemId = Encoding.ASCII.GetBytes("NTFS ");
+ oemId.CopyTo(bootSector, 0x03);
+
+ // Bytes per sector at offset 0x0B (512 = 0x0200)
+ BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0B, 2), 512);
+
+ // Sectors per cluster at offset 0x0D (8 sectors = 4096 bytes per cluster)
+ bootSector[0x0D] = 8;
+
+ // Total sectors at offset 0x28 (10000 sectors = 1250 clusters)
+ BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x28, 8), 10000);
+
+ // MFT cluster number at offset 0x30 (cluster 4)
+ BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x30, 8), 4);
+
+ // MFT mirror cluster number at offset 0x38 (cluster 500)
+ BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x38, 8), 500);
+
+ // Clusters per MFT record at offset 0x40 (-10 means 2^10 = 1024 bytes)
+ bootSector[0x40] = unchecked((byte)-10);
+
+ // Clusters per index block at offset 0x44 (-12 means 2^12 = 4096 bytes)
+ bootSector[0x44] = unchecked((byte)-12);
+
+ // Volume serial number at offset 0x48
+ BinaryPrimitives.WriteUInt64LittleEndian(bootSector.AsSpan(0x48, 8), 0x123456789ABCDEF0);
+
+ // Boot sector signature at offset 510
+ BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(510, 2), 0xAA55);
+
+ return bootSector;
+ }
+
+ ///
+ /// Creates bitmap data from an array of allocation flags.
+ ///
+ private static byte[] CreateBitmapData(params bool[] allocated)
+ {
+ var byteCount = (allocated.Length + 7) / 8;
+ var result = new byte[byteCount];
+
+ for (var i = 0; i < allocated.Length; i++)
+ if (allocated[i])
+ {
+ var byteIndex = i / 8;
+ var bitIndex = i % 8;
+ result[byteIndex] |= (byte)(1 << bitIndex);
+ }
+
+ return result;
+ }
+
+ ///
+ /// Creates an MFT record with the specified attributes.
+ ///
+ private static byte[] CreateMftRecord(
+ long fileTimestamp,
+ List<(long offset, long length)>? dataRuns = null,
+ bool inUse = true)
+ {
+ var recordSize = 1024; // Typical MFT record size
+ var record = new byte[recordSize];
+
+ // MFT Record Header:
+ // 0x00: Magic "FILE" (4 bytes)
+ // 0x04: Fixup offset (2 bytes)
+ // 0x06: Fixup count (2 bytes) - number of entries (also number of sectors)
+ // 0x08: LSN (8 bytes)
+ // 0x10: Sequence number (2 bytes)
+ // 0x12: Hard link count (2 bytes)
+ // 0x14: Offset to first attribute (2 bytes)
+ // 0x16: Flags (2 bytes) - 0x01 = in use
+ // 0x18: Used size (4 bytes)
+ // 0x1C: Allocated size (4 bytes)
+ // 0x20: Base file record (8 bytes)
+ // 0x28: Next attribute ID (2 bytes)
+ // 0x2A: Align to 8 bytes
+ // 0x30: MFT record number (8 bytes) - Win2K+ only
+
+ // Write FILE magic
+ BinaryPrimitives.WriteUInt32LittleEndian(record.AsSpan(0, 4), 0x454C4946); // "FILE"
+
+ // Write fixup offset (at 0x04) - point to end of record for fixup array
+ var fixupOffset = 48; // After header
+ BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(0x04, 2), (ushort)fixupOffset);
+
+ // Write fixup count (at 0x06) - 3 entries for 1024-byte record (2 sectors)
+ BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(0x06, 2), 3);
+
+ // Write first attribute offset (at 0x14) - after header (48 bytes)
+ var firstAttributeOffset = 56; // After header and some padding
+ BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(0x14, 2), (ushort)firstAttributeOffset);
+
+ // Write flags (at 0x16) - 0x01 = in use
+ var flags = inUse ? (ushort)0x01 : (ushort)0x00;
+ BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(0x16, 2), flags);
+
+ // Initialize fixup array
+ // First entry is the sequence number
+ BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(fixupOffset, 2), 0x1234);
+ // Next entries are the original values for each sector's last 2 bytes
+ BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(fixupOffset + 2, 2), 0x0000);
+ BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(fixupOffset + 4, 2), 0x0000);
+
+ // Write fixup signature bytes at end of each 512-byte sector
+ BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(512 - 2, 2), 0x1234);
+ BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(1024 - 2, 2), 0x1234);
+
+ var currentOffset = firstAttributeOffset;
+
+ // Add $STANDARD_INFORMATION attribute (0x10)
+ // Resident attribute structure:
+ // 0x00: Type (4 bytes)
+ // 0x04: Length (4 bytes)
+ // 0x08: Non-resident flag (1 byte)
+ // 0x09: Name length (1 byte)
+ // 0x0A: Name offset (2 bytes)
+ // 0x0C: Flags (2 bytes)
+ // 0x0E: Attribute ID (2 bytes)
+ // 0x10: Data length (4 bytes)
+ // 0x14: Data offset (2 bytes)
+ // 0x16: Resident flags (1 byte)
+ // 0x17: Padding (1 byte)
+ // Data follows
+
+ var stdInfoAttrSize = 72; // Header + data
+ BinaryPrimitives.WriteUInt32LittleEndian(record.AsSpan(currentOffset, 4), 0x10); // $STANDARD_INFORMATION
+ BinaryPrimitives.WriteUInt32LittleEndian(record.AsSpan(currentOffset + 4, 4), (uint)stdInfoAttrSize);
+ record[currentOffset + 8] = 0x00; // Resident
+ record[currentOffset + 9] = 0x00; // No name
+ BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(currentOffset + 0x0A, 2), 0x18); // Name offset
+ BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(currentOffset + 0x0C, 2), 0x0000); // Flags
+ BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(currentOffset + 0x0E, 2), 0x0000); // Attribute ID
+ BinaryPrimitives.WriteUInt32LittleEndian(record.AsSpan(currentOffset + 0x10, 4), 48); // Data length
+ BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(currentOffset + 0x14, 2), 0x18); // Data offset
+ record[currentOffset + 0x16] = 0x00; // Resident flags
+ record[currentOffset + 0x17] = 0x00; // Padding
+
+ // $STANDARD_INFORMATION data:
+ // 0x00: Creation time (8 bytes)
+ // 0x08: Modification time (8 bytes)
+ // 0x10: MFT modification time (8 bytes)
+ // 0x18: Access time (8 bytes)
+ // 0x20: DOS flags (4 bytes)
+ // 0x24: Max versions (4 bytes)
+ // 0x28: Version (4 bytes)
+ // 0x2C: Class ID (4 bytes)
+ // 0x30: Owner ID (4 bytes) - Win2K+
+ // 0x34: Security ID (4 bytes) - Win2K+
+ // 0x38: Quota charged (8 bytes) - Win2K+
+ // 0x40: Update sequence number (8 bytes) - Win2K+
+
+ var stdInfoDataOffset = currentOffset + 0x18;
+ var filetime = fileTimestamp == 0 ? 0 : fileTimestamp;
+ BinaryPrimitives.WriteInt64LittleEndian(record.AsSpan(stdInfoDataOffset + 0x00, 8), filetime); // Creation
+ BinaryPrimitives.WriteInt64LittleEndian(record.AsSpan(stdInfoDataOffset + 0x08, 8), filetime); // Modification
+ BinaryPrimitives.WriteInt64LittleEndian(record.AsSpan(stdInfoDataOffset + 0x10, 8), filetime); // MFT modification
+ BinaryPrimitives.WriteInt64LittleEndian(record.AsSpan(stdInfoDataOffset + 0x18, 8), filetime); // Access
+
+ currentOffset += stdInfoAttrSize;
+
+ // Add $DATA attribute (0x80) if data runs are provided
+ if (dataRuns != null && dataRuns.Count > 0)
+ {
+ // Non-resident $DATA attribute
+ // Header:
+ // 0x00: Attribute type (4 bytes)
+ // 0x04: Attribute length (4 bytes)
+ // 0x08: Non-resident flag (1 byte)
+ // 0x09: Name length (1 byte)
+ // 0x0A: Name offset (2 bytes)
+ // 0x0C: Flags (2 bytes)
+ // 0x0E: Attribute ID (2 bytes)
+ // 0x10: Starting VCN (8 bytes)
+ // 0x18: Ending VCN (8 bytes)
+ // 0x20: Run offset (2 bytes) - offset to data run list from start of attribute
+ // 0x22: Compression unit size (2 bytes)
+ // 0x24: Padding (4 bytes)
+ // 0x28: Allocated size (8 bytes)
+ // 0x30: Data size (8 bytes)
+ // 0x38: Initialized size (8 bytes)
+ // 0x40: Data runs start at offset specified by run offset field. If the attribute is named, then the name starts here. Otherwise, the data runs start here.
+
+ var attrHeaderSize = 0x40;
+ var dataRunBytes = CreateDataRunBytes(dataRuns);
+ var attrSize = attrHeaderSize + dataRunBytes.Length;
+
+ // Align attribute size to 8 bytes
+ attrSize = (attrSize + 7) & ~7;
+
+ BinaryPrimitives.WriteUInt32LittleEndian(record.AsSpan(currentOffset, 4), 0x80); // $DATA
+ BinaryPrimitives.WriteUInt32LittleEndian(record.AsSpan(currentOffset + 4, 4), (uint)attrSize);
+ record[currentOffset + 8] = 0x01; // Non-resident
+ record[currentOffset + 9] = 0x00; // No name
+ BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(currentOffset + 0x0A, 2), 0x40); // Name offset
+ BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(currentOffset + 0x0C, 2), 0); // Flags
+ BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(currentOffset + 0x0E, 2), 0); // Attribute ID
+ BinaryPrimitives.WriteInt64LittleEndian(record.AsSpan(currentOffset + 0x10, 8), 0); // Starting VCN
+
+ // Calculate ending VCN from data runs
+ var totalClusters = dataRuns.Sum(r => r.length);
+ BinaryPrimitives.WriteInt64LittleEndian(record.AsSpan(currentOffset + 0x18, 8), totalClusters - 1); // Ending VCN
+ BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(currentOffset + 0x20, 2), (ushort)attrHeaderSize); // Run offset
+ BinaryPrimitives.WriteUInt16LittleEndian(record.AsSpan(currentOffset + 0x22, 2), 0); // Compression unit size
+ BinaryPrimitives.WriteUInt32LittleEndian(record.AsSpan(currentOffset + 0x24, 4), 0); // Padding
+ BinaryPrimitives.WriteInt64LittleEndian(record.AsSpan(currentOffset + 0x28, 8), totalClusters * 4096); // Allocated size
+ BinaryPrimitives.WriteInt64LittleEndian(record.AsSpan(currentOffset + 0x30, 8), totalClusters * 4096); // Data size
+ BinaryPrimitives.WriteInt64LittleEndian(record.AsSpan(currentOffset + 0x38, 8), totalClusters * 4096); // Initialized size
+
+ // Copy data runs
+ dataRunBytes.CopyTo(record, currentOffset + attrHeaderSize);
+
+ currentOffset += attrSize;
+ }
+
+ // Add end-of-attributes marker (0xFFFFFFFF)
+ BinaryPrimitives.WriteUInt32LittleEndian(record.AsSpan(currentOffset, 4), 0xFFFFFFFF);
+
+ return record;
+ }
+
+ ///
+ /// Creates data run bytes from a list of (offset, length) pairs.
+ ///
+ private static byte[] CreateDataRunBytes(List<(long offset, long length)> runs)
+ {
+ var result = new List();
+
+ long currentCluster = 0;
+ foreach (var (offset, length) in runs)
+ {
+ var relativeOffset = offset - currentCluster;
+ currentCluster = offset;
+
+ // Determine byte counts
+ var lengthBytes = GetByteCount(length);
+ var offsetBytes = GetByteCountSigned(relativeOffset);
+
+ // Header byte: high nibble = offset bytes, low nibble = length bytes
+ result.Add((byte)((offsetBytes << 4) | lengthBytes));
+
+ // Write length (little-endian)
+ for (var i = 0; i < lengthBytes; i++)
+ result.Add((byte)((length >> (i * 8)) & 0xFF));
+
+ // Write offset (little-endian)
+ for (var i = 0; i < offsetBytes; i++)
+ result.Add((byte)((relativeOffset >> (i * 8)) & 0xFF));
+ }
+
+ // End marker
+ result.Add(0x00);
+
+ return result.ToArray();
+ }
+
+ private static int GetByteCount(long value)
+ {
+ if (value == 0) return 0;
+ if (value <= 0xFF) return 1;
+ if (value <= 0xFFFF) return 2;
+ if (value <= 0xFFFFFF) return 3;
+ if (value <= 0xFFFFFFFF) return 4;
+ if (value <= 0xFFFFFFFFFF) return 5;
+ if (value <= 0xFFFFFFFFFFFF) return 6;
+ if (value <= 0xFFFFFFFFFFFFFF) return 7;
+ return 8;
+ }
+
+ private static int GetByteCountSigned(long value)
+ {
+ if (value == 0) return 0;
+ if (value >= -0x80 && value <= 0x7F) return 1;
+ if (value >= -0x8000 && value <= 0x7FFF) return 2;
+ if (value >= -0x800000 && value <= 0x7FFFFF) return 3;
+ if (value >= -0x80000000 && value <= 0x7FFFFFFF) return 4;
+ if (value >= -0x8000000000L && value <= 0x7FFFFFFFFFL) return 5;
+ if (value >= -0x800000000000L && value <= 0x7FFFFFFFFFFFL) return 6;
+ if (value >= -0x80000000000000L && value <= 0x7FFFFFFFFFFFFFL) return 7;
+ return 8;
+ }
+
+ [Test]
+ public void Test_NtfsMftWalker_ParseFileTime_Correct()
+ {
+ // Create a known DateTime and convert to FILETIME
+ var expectedDate = new DateTime(2024, 1, 15, 12, 30, 0, DateTimeKind.Utc);
+ var filetime = expectedDate.ToFileTimeUtc();
+
+ var result = NtfsMftWalker.ParseFileTime(filetime);
+
+ Assert.AreEqual(expectedDate.Year, result.Year);
+ Assert.AreEqual(expectedDate.Month, result.Month);
+ Assert.AreEqual(expectedDate.Day, result.Day);
+ Assert.AreEqual(expectedDate.Hour, result.Hour);
+ Assert.AreEqual(expectedDate.Minute, result.Minute);
+ Assert.AreEqual(expectedDate.Second, result.Second);
+ Assert.AreEqual(DateTimeKind.Utc, result.Kind);
+ }
+
+ [Test]
+ public void Test_NtfsMftWalker_ParseFileTime_Zero_ReturnsEpoch()
+ {
+ var result = NtfsMftWalker.ParseFileTime(0);
+
+ Assert.AreEqual(DateTime.UnixEpoch, result);
+ }
+
+ [Test]
+ public async Task Test_NtfsMftWalker_SkipDeletedRecords()
+ {
+ var bootSectorData = CreateValidBootSector();
+ var bootSector = new NtfsBootSector(bootSectorData);
+
+ // Create bitmap with all clusters allocated
+ var bitmapData = CreateBitmapData([.. Enumerable.Repeat(true, (int)bootSector.TotalClusters)]);
+ var bitmap = new NtfsBitmap(bootSector, bitmapData);
+
+ // MFT record 0 describes the MFT itself - place at cluster 100
+ var mftRecord0 = CreateMftRecord(
+ DateTime.UtcNow.ToFileTimeUtc(),
+ [(100, 10)],
+ inUse: true);
+
+ // MFT record 1 - deleted (not in use)
+ var mftRecord1 = CreateMftRecord(
+ DateTime.UtcNow.ToFileTimeUtc(),
+ [(20, 5)], // Data at cluster 20, 5 clusters
+ inUse: false);
+
+ var recordCount = 0;
+ byte[]? GetMftRecord(long recordNumber)
+ {
+ recordCount++;
+ return recordNumber switch
+ {
+ 0 => mftRecord0,
+ 1 => mftRecord1,
+ _ => null
+ };
+ }
+
+ var walker = new NtfsMftWalker(bootSector, bitmap, GetMftRecord);
+ await walker.WalkAsync();
+
+ // Clusters from deleted record should NOT be in the map
+ // But system metafile clusters should be there
+ Assert.IsTrue(walker.ClusterToTimestampMap.Count > 0);
+
+ // Cluster 20-24 (from deleted record) should NOT be in the map
+ Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(20), "Deleted record's clusters should not be in map");
+ Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(24), "Deleted record's clusters should not be in map");
+ }
+
+ [Test]
+ public async Task Test_NtfsMftWalker_ClusterTimestampMap_Correct()
+ {
+ var bootSectorData = CreateValidBootSector();
+ var bootSector = new NtfsBootSector(bootSectorData);
+
+ // Create bitmap with all clusters allocated
+ var bitmapData = CreateBitmapData([.. Enumerable.Repeat(true, (int)bootSector.TotalClusters)]);
+ var bitmap = new NtfsBitmap(bootSector, bitmapData);
+
+ // FILETIME for 2023-06-01 10:00:00 UTC
+ var timestamp1 = 133303440000000000L;
+
+ // MFT record 0 describes the MFT itself - place at cluster 100
+ var mftRecord0 = CreateMftRecord(
+ DateTime.UtcNow.ToFileTimeUtc(),
+ [(100, 10)],
+ inUse: true);
+
+ // MFT record 1 - file with data at clusters 20-24
+ var mftRecord1 = CreateMftRecord(
+ timestamp1,
+ [(20, 5)], // Data at cluster 20, 5 clusters
+ inUse: true);
+
+ byte[]? GetMftRecord(long recordNumber)
+ {
+ return recordNumber switch
+ {
+ 0 => mftRecord0,
+ 1 => mftRecord1,
+ _ => null
+ };
+ }
+
+ var walker = new NtfsMftWalker(bootSector, bitmap, GetMftRecord);
+ await walker.WalkAsync();
+
+ // Check that clusters 20-24 are mapped to the correct timestamp
+ var expectedTime = NtfsMftWalker.ParseFileTime(timestamp1);
+
+ for (var cluster = 20; cluster < 25; cluster++)
+ {
+ Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(cluster), $"Cluster {cluster} should be in map");
+ Assert.AreEqual(expectedTime, walker.ClusterToTimestampMap[cluster], $"Cluster {cluster} should have correct timestamp");
+ }
+ }
+
+ [Test]
+ public async Task Test_NtfsMftWalker_SystemMetafiles_UseCurrentTimestamp()
+ {
+ var bootSectorData = CreateValidBootSector();
+ var bootSector = new NtfsBootSector(bootSectorData);
+
+ // Create bitmap with all clusters allocated
+ var bitmapData = CreateBitmapData(Enumerable.Repeat(true, (int)bootSector.TotalClusters).ToArray());
+ var bitmap = new NtfsBitmap(bootSector, bitmapData);
+
+ // MFT record 0 describes the MFT itself
+ var mftRecord0 = CreateMftRecord(
+ DateTime.UtcNow.ToFileTimeUtc(),
+ [(4, 10)], // MFT at cluster 4, 10 clusters
+ inUse: true);
+
+ byte[]? GetMftRecord(long recordNumber)
+ {
+ return recordNumber == 0 ? mftRecord0 : null;
+ }
+
+ var before = DateTime.UtcNow.AddSeconds(-1);
+ var walker = new NtfsMftWalker(bootSector, bitmap, GetMftRecord);
+ await walker.WalkAsync();
+ var after = DateTime.UtcNow.AddSeconds(1);
+
+ // System metafiles (records 0-23) should have timestamps close to now
+ // The MFT is at cluster 4 with 10 clusters (4-13)
+ var mftStartCluster = bootSector.MftByteOffset / bootSector.ClusterSize;
+
+ for (var i = 0; i < 10; i++)
+ {
+ var cluster = mftStartCluster + i;
+ if (walker.ClusterToTimestampMap.TryGetValue(cluster, out var timestamp))
+ {
+ Assert.GreaterOrEqual(timestamp, before, $"Cluster {cluster} timestamp should be >= before");
+ Assert.LessOrEqual(timestamp, after, $"Cluster {cluster} timestamp should be <= after");
+ }
+ }
+ }
+
+ [Test]
+ public async Task Test_NtfsMftWalker_OverlappingClusters_UsesMostRecentTimestamp()
+ {
+ var bootSectorData = CreateValidBootSector();
+ var bootSector = new NtfsBootSector(bootSectorData);
+
+ // Create bitmap with all clusters allocated
+ var bitmapData = CreateBitmapData(Enumerable.Repeat(true, (int)bootSector.TotalClusters).ToArray());
+ var bitmap = new NtfsBitmap(bootSector, bitmapData);
+
+ // FILETIMEs - older and newer
+ var olderTimestamp = 133000000000000000L; // ~2022
+ var newerTimestamp = 133500000000000000L; // ~2024
+
+ // MFT record 0 describes the MFT itself - place at cluster 100 to avoid overlap with file data
+ var mftRecord0 = CreateMftRecord(
+ DateTime.UtcNow.ToFileTimeUtc(),
+ [(100, 20)],
+ inUse: true);
+
+ // Record 1 - file with data at clusters 30-34, older timestamp
+ // Placed far from MFT at cluster 100 to avoid overlap
+ var mftRecord1 = CreateMftRecord(
+ olderTimestamp,
+ [(30, 5)],
+ inUse: true);
+
+ // Record 2 - file with data at clusters 32-36 (overlaps with record 1), newer timestamp
+ var mftRecord2 = CreateMftRecord(
+ newerTimestamp,
+ [(32, 5)],
+ inUse: true);
+
+ byte[]? GetMftRecord(long recordNumber)
+ {
+ return recordNumber switch
+ {
+ 0 => mftRecord0,
+ 1 => mftRecord1,
+ 2 => mftRecord2,
+ _ => null
+ };
+ }
+
+ var walker = new NtfsMftWalker(bootSector, bitmap, GetMftRecord);
+ await walker.WalkAsync();
+
+ // Clusters 30-31 should have older timestamp (only from record 1)
+ var olderTime = NtfsMftWalker.ParseFileTime(olderTimestamp);
+ var newerTime = NtfsMftWalker.ParseFileTime(newerTimestamp);
+
+ Assert.AreEqual(olderTime, walker.ClusterToTimestampMap[30], "Cluster 30 should have older timestamp");
+ Assert.AreEqual(olderTime, walker.ClusterToTimestampMap[31], "Cluster 31 should have older timestamp");
+
+ // Clusters 32-34 should have newer timestamp (overlapping, newer wins)
+ Assert.AreEqual(newerTime, walker.ClusterToTimestampMap[32], "Cluster 32 should have newer timestamp");
+ Assert.AreEqual(newerTime, walker.ClusterToTimestampMap[33], "Cluster 33 should have newer timestamp");
+ Assert.AreEqual(newerTime, walker.ClusterToTimestampMap[34], "Cluster 34 should have newer timestamp");
+
+ // Clusters 35-36 should have newer timestamp (only from record 2)
+ Assert.AreEqual(newerTime, walker.ClusterToTimestampMap[35], "Cluster 35 should have newer timestamp");
+ Assert.AreEqual(newerTime, walker.ClusterToTimestampMap[36], "Cluster 36 should have newer timestamp");
+ }
+
+ [Test]
+ public async Task Test_NtfsMftWalker_DataRunParsing_MultipleRuns()
+ {
+ var bootSectorData = CreateValidBootSector();
+ var bootSector = new NtfsBootSector(bootSectorData);
+
+ // Create bitmap with all clusters allocated
+ var bitmapData = CreateBitmapData([.. Enumerable.Repeat(true, (int)bootSector.TotalClusters)]);
+ var bitmap = new NtfsBitmap(bootSector, bitmapData);
+
+ var timestamp = 133303440000000000L; // 2023-06-01 10:00:00 UTC
+
+ // MFT record 0 describes the MFT itself - place at cluster 100
+ var mftRecord0 = CreateMftRecord(
+ DateTime.UtcNow.ToFileTimeUtc(),
+ [(100, 20)],
+ inUse: true);
+
+ // Record with multiple data runs: clusters 30-34 and 40-44
+ var mftRecord1 = CreateMftRecord(
+ timestamp,
+ new List<(long offset, long length)>
+ {
+ (30, 5), // First run: clusters 30-34
+ (40, 5) // Second run: clusters 40-44
+ },
+ inUse: true);
+
+ byte[]? GetMftRecord(long recordNumber)
+ {
+ return recordNumber switch
+ {
+ 0 => mftRecord0,
+ 1 => mftRecord1,
+ _ => null
+ };
+ }
+
+ var walker = new NtfsMftWalker(bootSector, bitmap, GetMftRecord);
+ await walker.WalkAsync();
+
+ var expectedTime = NtfsMftWalker.ParseFileTime(timestamp);
+
+ // Check first run (clusters 30-34)
+ for (var cluster = 30; cluster < 35; cluster++)
+ {
+ Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(cluster), $"Cluster {cluster} should be in map");
+ Assert.AreEqual(expectedTime, walker.ClusterToTimestampMap[cluster], $"Cluster {cluster} should have correct timestamp");
+ }
+
+ // Check second run (clusters 40-44)
+ for (var cluster = 40; cluster < 45; cluster++)
+ {
+ Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(cluster), $"Cluster {cluster} should be in map");
+ Assert.AreEqual(expectedTime, walker.ClusterToTimestampMap[cluster], $"Cluster {cluster} should have correct timestamp");
+ }
+ }
+
+ [Test]
+ public async Task Test_NtfsMftWalker_EmptyDataRun_Skipped()
+ {
+ var bootSectorData = CreateValidBootSector();
+ var bootSector = new NtfsBootSector(bootSectorData);
+
+ // Create bitmap with all clusters allocated
+ var bitmapData = CreateBitmapData([.. Enumerable.Repeat(true, (int)bootSector.TotalClusters)]);
+ var bitmap = new NtfsBitmap(bootSector, bitmapData);
+
+ var timestamp = 133303440000000000L;
+
+ // MFT record 0 describes the MFT itself - place at cluster 100
+ var mftRecord0 = CreateMftRecord(
+ DateTime.UtcNow.ToFileTimeUtc(),
+ [(100, 10)],
+ inUse: true);
+
+ // Record with no $DATA attribute (like a directory)
+ var mftRecord1 = CreateMftRecord(
+ timestamp,
+ null, // No data runs
+ inUse: true);
+
+ byte[]? GetMftRecord(long recordNumber)
+ {
+ return recordNumber switch
+ {
+ 0 => mftRecord0,
+ 1 => mftRecord1,
+ _ => null
+ };
+ }
+
+ var walker = new NtfsMftWalker(bootSector, bitmap, GetMftRecord);
+ await walker.WalkAsync();
+
+ // The record should be processed (no exception), but no additional clusters mapped
+ // System metafiles should still be present
+ Assert.IsTrue(walker.ClusterToTimestampMap.Count >= 0);
+ }
+
+ [Test]
+ public async Task Test_NtfsMftWalker_JournalClusters_AlwaysAllocated()
+ {
+ var bootSectorData = CreateValidBootSector();
+ var bootSector = new NtfsBootSector(bootSectorData);
+
+ // Create bitmap with all clusters allocated
+ var bitmapData = CreateBitmapData([.. Enumerable.Repeat(true, (int)bootSector.TotalClusters)]);
+ var bitmap = new NtfsBitmap(bootSector, bitmapData);
+
+ // MFT record 0 describes the MFT itself - place at cluster 100
+ var mftRecord0 = CreateMftRecord(
+ DateTime.UtcNow.ToFileTimeUtc(),
+ [(100, 20)],
+ inUse: true);
+
+ // MFT record 2 is $LogFile - the NTFS transaction journal
+ // Its clusters must always be backed up as they change on every write
+ var mftRecord2 = CreateMftRecord(
+ DateTime.UtcNow.ToFileTimeUtc(),
+ [(50, 10)], // $LogFile at clusters 50-59
+ inUse: true);
+
+ byte[]? GetMftRecord(long recordNumber)
+ {
+ return recordNumber switch
+ {
+ 0 => mftRecord0,
+ 2 => mftRecord2,
+ _ => null
+ };
+ }
+
+ var before = DateTime.UtcNow.AddSeconds(-1);
+ var walker = new NtfsMftWalker(bootSector, bitmap, GetMftRecord);
+ await walker.WalkAsync();
+ var after = DateTime.UtcNow.AddSeconds(1);
+
+ // $LogFile clusters (50-59) should be mapped with a current timestamp
+ for (var cluster = 50; cluster < 60; cluster++)
+ {
+ Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(cluster), $"$LogFile cluster {cluster} should be in map");
+ var timestamp = walker.ClusterToTimestampMap[cluster];
+ Assert.GreaterOrEqual(timestamp, before, $"$LogFile cluster {cluster} timestamp should be >= before");
+ Assert.LessOrEqual(timestamp, after, $"$LogFile cluster {cluster} timestamp should be <= after");
+ }
+ }
+}
diff --git a/Duplicati/UnitTest/DiskImage/Ntfs/NtfsZeroStreamTests.cs b/Duplicati/UnitTest/DiskImage/Ntfs/NtfsZeroStreamTests.cs
new file mode 100644
index 000000000..af45e4ab5
--- /dev/null
+++ b/Duplicati/UnitTest/DiskImage/Ntfs/NtfsZeroStreamTests.cs
@@ -0,0 +1,147 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using Duplicati.Proprietary.DiskImage.Filesystem.Ntfs;
+using NUnit.Framework;
+using NUnit.Framework.Legacy;
+using Assert = NUnit.Framework.Legacy.ClassicAssert;
+
+#nullable enable
+
+namespace Duplicati.UnitTest.DiskImage.Ntfs;
+
+///
+/// Unit tests for the class.
+///
+[TestFixture]
+[Category("DiskImageUnit")]
+public class NtfsZeroStreamTests
+{
+ [Test]
+ public void Test_NtfsZeroStream_Read_ReturnsZeros()
+ {
+ const int blockSize = 1024 * 1024; // 1MB
+ using var stream = new NtfsFilesystem.NtfsZeroStream(blockSize);
+
+ var buffer = new byte[1024];
+ int bytesRead = stream.Read(buffer, 0, buffer.Length);
+
+ Assert.AreEqual(1024, bytesRead, "Should read requested number of bytes");
+
+ // Verify all bytes are zero
+ for (int i = 0; i < buffer.Length; i++)
+ {
+ Assert.AreEqual(0, buffer[i], $"Byte at position {i} should be zero");
+ }
+ }
+
+ [Test]
+ public async Task Test_NtfsZeroStream_ReadAsync_ReturnsZeros()
+ {
+ const int blockSize = 1024 * 1024; // 1MB
+ using var stream = new NtfsFilesystem.NtfsZeroStream(blockSize);
+
+ var buffer = new byte[1024];
+ int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
+
+ Assert.AreEqual(1024, bytesRead, "Should read requested number of bytes");
+
+ // Verify all bytes are zero
+ for (int i = 0; i < buffer.Length; i++)
+ {
+ Assert.AreEqual(0, buffer[i], $"Byte at position {i} should be zero");
+ }
+ }
+
+ [Test]
+ public void Test_NtfsZeroStream_Length_MatchesBlockSize()
+ {
+ const int blockSize = 1024 * 1024; // 1MB
+ using var stream = new NtfsFilesystem.NtfsZeroStream(blockSize);
+
+ Assert.AreEqual(blockSize, stream.Length, "Stream length should match block size");
+ }
+
+ [Test]
+ public void Test_NtfsZeroStream_Seek_Works()
+ {
+ const int blockSize = 1024 * 1024; // 1MB
+ using var stream = new NtfsFilesystem.NtfsZeroStream(blockSize);
+
+ // Seek to beginning
+ var position = stream.Seek(0, SeekOrigin.Begin);
+ Assert.AreEqual(0, position, "Should be at position 0");
+
+ // Seek to middle
+ position = stream.Seek(blockSize / 2, SeekOrigin.Begin);
+ Assert.AreEqual(blockSize / 2, position, "Should be at middle position");
+
+ // Seek relative to current
+ position = stream.Seek(1024, SeekOrigin.Current);
+ Assert.AreEqual(blockSize / 2 + 1024, position, "Should be at relative position");
+
+ // Seek from end
+ position = stream.Seek(-1024, SeekOrigin.End);
+ Assert.AreEqual(blockSize - 1024, position, "Should be at position from end");
+ }
+
+ [Test]
+ public void Test_NtfsZeroStream_Write_ThrowsNotSupported()
+ {
+ const int blockSize = 1024 * 1024; // 1MB
+ using var stream = new NtfsFilesystem.NtfsZeroStream(blockSize);
+
+ var buffer = new byte[1024];
+ Assert.Throws(() => stream.Write(buffer, 0, buffer.Length));
+ }
+
+ [Test]
+ public void Test_NtfsZeroStream_ReadPartial_ReturnsCorrectCount()
+ {
+ const int blockSize = 1024 * 1024; // 1MB
+ using var stream = new NtfsFilesystem.NtfsZeroStream(blockSize);
+
+ // Seek to near the end
+ stream.Seek(blockSize - 100, SeekOrigin.Begin);
+
+ var buffer = new byte[1024];
+ int bytesRead = stream.Read(buffer, 0, buffer.Length);
+
+ Assert.AreEqual(100, bytesRead, "Should only read remaining bytes");
+ }
+
+ [Test]
+ public void Test_NtfsZeroStream_ReadPastEnd_ReturnsZero()
+ {
+ const int blockSize = 1024 * 1024; // 1MB
+ using var stream = new NtfsFilesystem.NtfsZeroStream(blockSize);
+
+ // Seek to end
+ stream.Seek(0, SeekOrigin.End);
+
+ var buffer = new byte[1024];
+ int bytesRead = stream.Read(buffer, 0, buffer.Length);
+
+ Assert.AreEqual(0, bytesRead, "Should return 0 when reading past end");
+ }
+
+ [Test]
+ public void Test_NtfsZeroStream_CanRead_CanWrite_CanSeek()
+ {
+ const int blockSize = 1024 * 1024; // 1MB
+ using var stream = new NtfsFilesystem.NtfsZeroStream(blockSize);
+
+ Assert.IsTrue(stream.CanRead, "CanRead should be true");
+ Assert.IsFalse(stream.CanWrite, "CanWrite should be false");
+ Assert.IsTrue(stream.CanSeek, "CanSeek should be true");
+ }
+
+ [Test]
+ public void Test_NtfsZeroStream_InvalidBlockSize_Throws()
+ {
+ Assert.Throws(() => new NtfsFilesystem.NtfsZeroStream(0));
+ Assert.Throws(() => new NtfsFilesystem.NtfsZeroStream(-1));
+ }
+}
diff --git a/proprietary/DiskImage/Filesystem/Ntfs/Ntfs.cs b/proprietary/DiskImage/Filesystem/Ntfs/Ntfs.cs
new file mode 100644
index 000000000..23f5548e3
--- /dev/null
+++ b/proprietary/DiskImage/Filesystem/Ntfs/Ntfs.cs
@@ -0,0 +1,930 @@
+// Copyright (c) 2026 Duplicati Inc. All rights reserved.
+
+using System;
+using System.Buffers;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Duplicati.Proprietary.DiskImage.Disk;
+using Duplicati.Proprietary.DiskImage.General;
+using Duplicati.Proprietary.DiskImage.Partition;
+
+namespace Duplicati.Proprietary.DiskImage.Filesystem.Ntfs;
+
+///
+/// Metadata for NTFS filesystems, storing cluster and block size information.
+///
+public sealed record NtfsFilesystemMetadata
+{
+ ///
+ /// Gets the block size used for reading/writing data.
+ ///
+ public int BlockSize { get; init; }
+
+ ///
+ /// Gets the size of a cluster in bytes.
+ ///
+ public int ClusterSize { get; init; }
+
+ ///
+ /// Gets the total number of clusters in the volume.
+ ///
+ public long TotalClusters { get; init; }
+
+ ///
+ /// Gets the number of allocated (in-use) clusters.
+ ///
+ public long AllocatedClusters { get; init; }
+
+ ///
+ /// Gets the number of free clusters.
+ ///
+ public long FreeClusters { get; init; }
+
+ ///
+ /// Gets the size of an MFT record in bytes.
+ ///
+ public int MftRecordSize { get; init; }
+
+ ///
+ /// Gets a value indicating whether the filesystem has a journal ($LogFile).
+ /// Always true for NTFS.
+ ///
+ public bool HasJournal => true;
+}
+
+///
+/// Represents a file (block) in an NTFS filesystem.
+///
+public class NtfsFile : IFile
+{
+ ///
+ public string? Path { get; init; }
+
+ ///
+ public long? Address { get; init; }
+
+ ///
+ public long Size { get; init; }
+
+ ///
+ public bool IsDirectory => false;
+
+ ///
+ /// Gets the last modification timestamp of the block.
+ /// This is the maximum timestamp of any file whose clusters overlap this block.
+ ///
+ public DateTime? LastModified { get; init; }
+
+ ///
+ /// Gets a value indicating whether the block contains any allocated clusters.
+ ///
+ public bool IsAllocated { get; init; }
+}
+
+///
+/// Represents an NTFS filesystem.
+/// Provides filesystem-aware block-level access with cluster allocation tracking.
+///
+internal class NtfsFilesystem : IFilesystem
+{
+ ///
+ /// Cache of zero buffers keyed by block size.
+ ///
+ private static readonly ConcurrentDictionary s_zeroBuffers = new();
+
+ ///
+ public FileSystemType Type => FileSystemType.NTFS;
+
+ ///
+ public IPartition Partition { get; }
+
+ ///
+ /// Gets the block size used for reading/writing data.
+ ///
+ private readonly int m_blockSize;
+
+ ///
+ /// The parsed NTFS boot sector.
+ ///
+ private readonly NtfsBootSector m_bootSector;
+
+ ///
+ /// The bitmap reader for cluster allocation.
+ ///
+ private readonly NtfsBitmap m_bitmap;
+
+ ///
+ /// The MFT walker that built the cluster-to-timestamp map.
+ ///
+ private readonly NtfsMftWalker m_mftWalker;
+
+ ///
+ /// Maps block index to block metadata (LastModified, IsAllocated).
+ ///
+ private readonly BlockMetadata[] m_blockMetadata;
+
+ ///
+ /// The total number of blocks in the partition.
+ ///
+ private readonly long m_blockCount;
+
+ ///
+ /// Flag to indicate whether the object has been disposed.
+ ///
+ private bool m_disposed;
+
+ ///
+ /// Metadata for a single block.
+ ///
+ private readonly struct BlockMetadata
+ {
+ ///
+ /// The last modification timestamp for this block.
+ ///
+ public DateTime LastModified { get; init; }
+
+ ///
+ /// Whether this block contains any allocated clusters.
+ ///
+ public bool IsAllocated { get; init; }
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The parent partition.
+ /// The block size for reading/writing (default is 1MB).
+ /// Thrown if partition is null.
+ /// Thrown if block size is invalid.
+ internal NtfsFilesystem(IPartition partition, int blockSize = 1024 * 1024)
+ {
+ ArgumentNullException.ThrowIfNull(partition);
+
+ Partition = partition;
+
+ var sectorSize = partition.PartitionTable.RawDisk?.SectorSize ?? 512;
+ if (blockSize <= 0 || blockSize % sectorSize != 0)
+ throw new ArgumentException($"Block size must be positive and a multiple of the sector size ({sectorSize} bytes).", nameof(blockSize));
+
+ m_blockSize = blockSize;
+
+ // Read and parse the boot sector
+ var bootSectorData = new byte[512];
+ using (var stream = partition.OpenReadAsync(CancellationToken.None).Result)
+ {
+ stream.ReadExactly(bootSectorData);
+ }
+ m_bootSector = new NtfsBootSector(bootSectorData);
+
+ // Validate block size is a multiple of cluster size
+ if (m_blockSize % m_bootSector.ClusterSize != 0)
+ throw new ArgumentException($"Block size ({m_blockSize}) must be a multiple of the cluster size ({m_bootSector.ClusterSize}).", nameof(blockSize));
+
+ // Read the bitmap
+ m_bitmap = new NtfsBitmap(partition, m_bootSector, CancellationToken.None);
+
+ // Walk the MFT to build cluster-to-timestamp map
+ m_mftWalker = new NtfsMftWalker(partition, m_bootSector, m_bitmap);
+ m_mftWalker.WalkAsync(CancellationToken.None).Wait();
+
+ // Pre-compute block metadata
+ m_blockCount = (partition.Size + m_blockSize - 1) / m_blockSize;
+ m_blockMetadata = BuildBlockMetadata();
+ }
+
+ ///
+ /// Builds the metadata array for all blocks.
+ ///
+ /// An array of BlockMetadata for each block.
+ private BlockMetadata[] BuildBlockMetadata()
+ {
+ var metadata = new BlockMetadata[m_blockCount];
+ var clusterToTimestampMap = m_mftWalker.ClusterToTimestampMap;
+ var clusterSize = m_bootSector.ClusterSize;
+ var totalClusters = m_bootSector.TotalClusters;
+ var now = DateTime.UtcNow;
+
+ // The boot sector area (first cluster) is always allocated with current timestamp
+ // In NTFS, data starts at cluster 0 which is immediately after the boot sector
+ var bootSectorEnd = m_bootSector.BytesPerSector * m_bootSector.SectorsPerCluster; // End of first cluster
+
+ for (var blockIndex = 0L; blockIndex < m_blockCount; blockIndex++)
+ {
+ var blockStart = blockIndex * m_blockSize;
+ var blockEnd = Math.Min(blockStart + m_blockSize, Partition.Size);
+
+ // Check if block is in the boot sector area (first cluster)
+ if (blockStart < bootSectorEnd)
+ {
+ // Boot sector area is always allocated
+ metadata[blockIndex] = new BlockMetadata
+ {
+ LastModified = now,
+ IsAllocated = true
+ };
+ continue;
+ }
+
+ // Check if block is beyond the volume
+ if (blockStart >= m_bootSector.TotalSize)
+ {
+ // Beyond the volume - treat as unallocated with epoch timestamp
+ metadata[blockIndex] = new BlockMetadata
+ {
+ LastModified = DateTime.UnixEpoch,
+ IsAllocated = false
+ };
+ continue;
+ }
+
+ // Block is in the volume - check which clusters fall within it
+ var maxTimestamp = DateTime.UnixEpoch;
+ var hasAllocatedClusters = false;
+
+ // Calculate which clusters are in this block
+ var firstClusterInBlock = blockStart / clusterSize;
+ var lastClusterInBlock = (blockEnd - 1) / clusterSize;
+
+ // Clamp to valid cluster range
+ firstClusterInBlock = Math.Max(0, firstClusterInBlock);
+ lastClusterInBlock = Math.Min(lastClusterInBlock, totalClusters - 1);
+
+ for (var cluster = firstClusterInBlock; cluster <= lastClusterInBlock; cluster++)
+ {
+ if (m_bitmap.IsClusterAllocated(cluster))
+ {
+ hasAllocatedClusters = true;
+
+ // Get the timestamp for this cluster
+ if (clusterToTimestampMap.TryGetValue(cluster, out var timestamp))
+ {
+ if (timestamp > maxTimestamp)
+ {
+ maxTimestamp = timestamp;
+ }
+ }
+ else
+ {
+ // Allocated cluster without a timestamp - use current time
+ maxTimestamp = now;
+ }
+ }
+ }
+
+ metadata[blockIndex] = new BlockMetadata
+ {
+ LastModified = maxTimestamp,
+ IsAllocated = hasAllocatedClusters
+ };
+ }
+
+ return metadata;
+ }
+
+ ///
+ /// Tries to detect whether a given partition has an NTFS file system.
+ ///
+ /// The raw disk to check for the filesystem.
+ /// The offset on the disk where the partition starts.
+ /// The size of the partition.
+ /// Cancellation token.
+ /// A task that resolves to true if a valid NTFS filesystem is detected, otherwise false.
+ public static async Task DetectAsync(IRawDisk disk, long offset, int size, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var bootSectorData = new byte[512];
+ using var stream = await disk.ReadBytesAsync(offset, size, cancellationToken);
+ var bytesRead = await stream.ReadAsync(bootSectorData, cancellationToken);
+ if (bytesRead < 512)
+ return false;
+
+ // Try NTFS: the constructor validates the boot sector signature and
+ // the "NTFS " OEM ID at offset 0x03.
+ _ = new NtfsBootSector(bootSectorData);
+ return true;
+ }
+ catch
+ {
+ // Unable to read the partition or invalid boot sector
+ }
+
+ return false;
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (m_disposed)
+ return;
+
+ m_disposed = true;
+ }
+
+ ///
+ public Task