From 2b5db218adc04a2a98bb471cc0b6eae0d405cab7 Mon Sep 17 00:00:00 2001 From: Carl Johnsen Date: Mon, 16 Mar 2026 08:57:30 +0100 Subject: [PATCH] Post restructure namespace fixes --- .../UnitTest/DiskImage/DiskImageTests.cs | 3078 ++--- .../UnitTest/DiskImage/DiskImageUnitTests.cs | 10757 ++++++++-------- .../DiskImage/Fat32/Fat32BootSectorTests.cs | 401 +- .../Fat32/Fat32DirectoryWalkerTests.cs | 1326 +- .../DiskImage/Fat32/Fat32FilesystemTests.cs | 274 +- .../DiskImage/Fat32/Fat32TableTests.cs | 641 +- .../DiskImage/Fat32/Fat32ZeroStreamTests.cs | 493 +- .../Helpers/DiskImageHelperFactory.cs | 109 +- .../DiskImage/Helpers/DiskImageTestHelpers.cs | 222 +- .../DiskImage/Helpers/IDiskImageHelper.cs | 145 +- .../DiskImage/Helpers/LinuxDiskImageHelper.cs | 1261 +- .../DiskImage/Helpers/MacOSDiskImageHelper.cs | 1131 +- .../Helpers/WindowsDiskImageHelper.cs | 1265 +- .../DiskImage/Filesystem/Fat32/Fat32.cs | 2 +- .../Filesystem/Fat32/Fat32BootSector.cs | 2 +- .../Filesystem/Fat32/Fat32DirectoryWalker.cs | 2 +- .../DiskImage/Filesystem/Fat32/Fat32Table.cs | 2 +- proprietary/DiskImage/General/Crc32.cs | 2 +- proprietary/DiskImage/General/PooledBuffer.cs | 2 +- .../DiskImage/General/PooledMemoryStream.cs | 2 +- proprietary/DiskImage/Partition/GPT.cs | 2 +- proprietary/DiskImage/Partition/MBR.cs | 2 +- proprietary/DiskImage/RestoreProvider.cs | 1 + .../SourceItems/FilesystemSourceEntry.cs | 1 + .../SourceItems/PartitionSourceEntry.cs | 1 + 25 files changed, 10558 insertions(+), 10566 deletions(-) diff --git a/Duplicati/UnitTest/DiskImage/DiskImageTests.cs b/Duplicati/UnitTest/DiskImage/DiskImageTests.cs index 5283a68ea..b06f78962 100644 --- a/Duplicati/UnitTest/DiskImage/DiskImageTests.cs +++ b/Duplicati/UnitTest/DiskImage/DiskImageTests.cs @@ -29,1597 +29,1597 @@ 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 +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; + /// - /// Unit tests for the DiskImage backup and restore functionality. - /// These tests use disk image files managed to test the full backup and restore flow. + /// Sets up the test environment before each test. + /// Creates the disk helper and temporary disk image paths. /// - [TestFixture] - [Category("DiskImage")] - [Platform("Win,MacOsX,Linux")] - public class DiskImageTests : BasicSetupHelper + [SetUp] + public void DiskImageSetUp() { - private string _sourceImagePath = null!; - private string _restoreImagePath = null!; - private string _sourceMountPath = null!; - private string _restoreMountPath = null!; - private DiskImage.IDiskImageHelper _diskHelper = null!; + // Create the appropriate disk image helper for the current platform + _diskHelper = DiskImageHelperFactory.Create(); - 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() + // Check for admin privileges + if (!_diskHelper.HasRequiredPrivileges()) { - // Create the appropriate disk image helper for the current platform - _diskHelper = DiskImage.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); + Assert.Ignore("DiskImage tests require administrator privileges"); } - /// - /// Cleans up the test environment after each test. - /// Detaches and deletes disk image files. - /// - [TearDown] - public void DiskImageTearDown() + // 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() => + FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.FAT32, 0)]); + + [Test, Category("DiskImage")] + public Task Test_GPT_NTFS() + { + if (!OperatingSystem.IsWindows()) + Assert.Ignore("Test_GPT_NTFS is only supported on Windows."); + return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.NTFS, 0)]); + } + + [Test, Category("DiskImage")] + public Task Test_GPT_APFS() + { + if (!OperatingSystem.IsMacOS()) + Assert.Ignore("APFS is only supported on macOS."); + return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.APFS, 0)]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_GPT_HFSPlus() + { + if (!OperatingSystem.IsMacOS()) + Assert.Ignore("HFSPlus is only supported on macOS."); + return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.HFSPlus, 0)]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_GPT_ExFAT() => + FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.ExFAT, 0)]); + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_GPT_Ext2() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("Ext2 is only supported on Linux."); + return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.Ext2, 0)]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_GPT_Ext3() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("Ext3 is only supported on Linux."); + return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.Ext3, 0)]); + } + + [Test, Category("DiskImage")] + public Task Test_GPT_Ext4() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("Ext4 is only supported on Linux."); + return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.Ext4, 0)]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_GPT_XFS() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("XFS is only supported on Linux."); + return FullRoundTrip((int)(310 * MiB), PartitionTableType.GPT, [(FileSystemType.XFS, 0)]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_GPT_Btrfs() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("Btrfs is only supported on Linux."); + return FullRoundTrip((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() => + FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.FAT32, 0)]); + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_MBR_HFSPlus() + { + if (!OperatingSystem.IsMacOS()) + Assert.Ignore("HFSPlus is only supported on macOS."); + return FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.HFSPlus, 0)]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_MBR_ExFAT() => + FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.ExFAT, 0)]); + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_MBR_NTFS() + { + if (!OperatingSystem.IsWindows()) + Assert.Ignore("Test_MBR_NTFS is only supported on Windows."); + return FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.NTFS, 0)]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_MBR_Ext2() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("Ext2 is only supported on Linux."); + return FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.Ext2, 0)]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_MBR_Ext3() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("Ext3 is only supported on Linux."); + return FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.Ext3, 0)]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_MBR_Ext4() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("Ext4 is only supported on Linux."); + return FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.Ext4, 0)]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_MBR_XFS() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("XFS is only supported on Linux."); + return FullRoundTrip((int)(310 * MiB), PartitionTableType.MBR, [(FileSystemType.XFS, 0)]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_MBR_Btrfs() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("Btrfs is only supported on Linux."); + return FullRoundTrip((int)(110 * MiB), PartitionTableType.MBR, [(FileSystemType.Btrfs, 0)]); + } + + #endregion + + #region Unknown Partition Table + + [Test, Category("DiskImage")] + public Task Test_Unknown_NoPartitions() => + FullRoundTrip((int)(50 * MiB), PartitionTableType.Unknown, []); + + #endregion + + #region GPT Two Partitions + + [Test, Category("DiskImage")] + public Task Test_GPT_FAT32_FAT32() => + FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ + (FileSystemType.FAT32, 50 * MiB), + (FileSystemType.FAT32, 0) + ]); + + [Test, Category("DiskImage")] + public Task Test_GPT_APFS_APFS() + { + if (!OperatingSystem.IsMacOS()) + Assert.Ignore("APFS is only supported on macOS."); + return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ + (FileSystemType.APFS, 50 * MiB), + (FileSystemType.APFS, 0) + ]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_GPT_HFSPlus_HFSPlus() + { + if (!OperatingSystem.IsMacOS()) + Assert.Ignore("HFSPlus is only supported on macOS."); + return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ + (FileSystemType.HFSPlus, 50 * MiB), + (FileSystemType.HFSPlus, 0) + ]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_GPT_APFS_HFSPlus() + { + if (!OperatingSystem.IsMacOS()) + Assert.Ignore("APFS and HFSPlus are only supported on macOS."); + return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ + (FileSystemType.APFS, 50 * MiB), + (FileSystemType.HFSPlus, 0) + ]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_GPT_FAT32_APFS() + { + if (!OperatingSystem.IsMacOS()) + Assert.Ignore("APFS is only supported on macOS."); + return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ + (FileSystemType.FAT32, 50 * MiB), + (FileSystemType.APFS, 0) + ]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_GPT_ExFAT_HFSPlus() + { + if (!OperatingSystem.IsMacOS()) + Assert.Ignore("HFSPlus is only supported on macOS."); + return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ + (FileSystemType.ExFAT, 50 * MiB), + (FileSystemType.HFSPlus, 0) + ]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_GPT_FAT32_ExFAT() => + FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ + (FileSystemType.FAT32, 50 * MiB), + (FileSystemType.ExFAT, 0) + ]); + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_GPT_NTFS_FAT32() + { + if (!OperatingSystem.IsWindows()) + Assert.Ignore("This test is only supported on Windows."); + return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ + (FileSystemType.NTFS, 50 * MiB), + (FileSystemType.FAT32, 0) + ]); + } + + [Test, Category("DiskImage")] + public Task Test_GPT_NTFS_NTFS() + { + if (!OperatingSystem.IsWindows()) + Assert.Ignore("This test is only supported on Windows."); + return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ + (FileSystemType.NTFS, 50 * MiB), + (FileSystemType.NTFS, 0) + ]); + } + + [Test, Category("DiskImage")] + public Task Test_GPT_Ext4_Ext4() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("Ext4 is only supported on Linux."); + return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ + (FileSystemType.Ext4, 50 * MiB), + (FileSystemType.Ext4, 0) + ]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_GPT_Ext4_FAT32() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("This test is only supported on Linux."); + return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ + (FileSystemType.Ext4, 50 * MiB), + (FileSystemType.FAT32, 0) + ]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_GPT_Ext4_XFS() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("This test is only supported on Linux."); + return FullRoundTrip((int)(360 * MiB), PartitionTableType.GPT, [ + (FileSystemType.Ext4, 50 * MiB), + (FileSystemType.XFS, 0) + ]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_GPT_XFS_FAT32() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("This test is only supported on Linux."); + return FullRoundTrip((int)(360 * MiB), PartitionTableType.GPT, [ + (FileSystemType.XFS, 310 * MiB), + (FileSystemType.FAT32, 0) + ]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_GPT_Btrfs_FAT32() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("This test is only supported on Linux."); + return FullRoundTrip((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() => + FullRoundTrip((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() + { + if (!OperatingSystem.IsMacOS()) + Assert.Ignore("HFSPlus is only supported on macOS."); + return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [ + (FileSystemType.HFSPlus, 50 * MiB), + (FileSystemType.HFSPlus, 0) + ]); + } + + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_MBR_FAT32_ExFAT() + { + return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [ + (FileSystemType.FAT32, 50 * MiB), + (FileSystemType.ExFAT, 0) + ]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_MBR_FAT32_HFSPlus() + { + if (!OperatingSystem.IsMacOS()) + Assert.Ignore("HFSPlus is only supported on macOS."); + return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [ + (FileSystemType.FAT32, 50 * MiB), + (FileSystemType.HFSPlus, 0) + ]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_MBR_NTFS_FAT32() + { + if (!OperatingSystem.IsWindows()) + Assert.Ignore("This test is only supported on Windows."); + return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [ + (FileSystemType.NTFS, 50 * MiB), + (FileSystemType.FAT32, 0) + ]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_MBR_Ext4_Ext4() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("Ext4 is only supported on Linux."); + return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [ + (FileSystemType.Ext4, 50 * MiB), + (FileSystemType.Ext4, 0) + ]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_MBR_FAT32_Ext4() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("This test is only supported on Linux."); + return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [ + (FileSystemType.FAT32, 50 * MiB), + (FileSystemType.Ext4, 0) + ]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_MBR_XFS_Ext4() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("This test is only supported on Linux."); + return FullRoundTrip((int)(360 * MiB), PartitionTableType.MBR, [ + (FileSystemType.XFS, 310 * MiB), + (FileSystemType.Ext4, 0) + ]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_MBR_Btrfs_Ext4() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("This test is only supported on Linux."); + return FullRoundTrip((int)(160 * MiB), PartitionTableType.MBR, [ + (FileSystemType.Btrfs, 110 * MiB), + (FileSystemType.Ext4, 0) + ]); + } + + [Test, Category("DiskImage"), Category("DiskImageLocal")] + public Task Test_MBR_XFS_Btrfs() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("This test is only supported on Linux."); + return FullRoundTrip((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() + { + return FullRoundTrip((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() + { + if (!OperatingSystem.IsMacOS()) + Assert.Ignore("APFS is only supported on macOS."); + return FullRoundTrip((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() + { + if (!OperatingSystem.IsWindows()) + Assert.Ignore("NTFS is only supported on Windows."); + return FullRoundTrip((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() + { + if (!OperatingSystem.IsMacOS()) + Assert.Ignore("HFSPlus is only supported on macOS."); + return FullRoundTrip((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() + { + if (!OperatingSystem.IsMacOS()) + Assert.Ignore("APFS and HFSPlus are only supported on macOS."); + return FullRoundTrip((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() + { + if (!OperatingSystem.IsMacOS()) + Assert.Ignore("APFS and HFSPlus are only supported on macOS."); + return FullRoundTrip((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() + { + if (!OperatingSystem.IsMacOS()) + Assert.Ignore("APFS and HFSPlus are only supported on macOS."); + return FullRoundTrip((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() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("Ext4 is only supported on Linux."); + return FullRoundTrip((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() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("This test is only supported on Linux."); + return FullRoundTrip((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() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("This test is only supported on Linux."); + return FullRoundTrip((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() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("This test is only supported on Linux."); + return FullRoundTrip((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() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("This test is only supported on Linux."); + return FullRoundTrip((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() + { + return FullRoundTrip((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() + { + if (!OperatingSystem.IsMacOS()) + Assert.Ignore("HFSPlus is only supported on macOS."); + return FullRoundTrip((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() + { + if (!OperatingSystem.IsMacOS()) + Assert.Ignore("HFSPlus is only supported on macOS."); + return FullRoundTrip((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() + { + if (!OperatingSystem.IsWindows()) + Assert.Ignore("This test is only supported on Windows."); + return FullRoundTrip((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() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("Ext4 is only supported on Linux."); + return FullRoundTrip((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() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("This test is only supported on Linux."); + return FullRoundTrip((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() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("This test is only supported on Linux."); + return FullRoundTrip((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() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("This test is only supported on Linux."); + return FullRoundTrip((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() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("This test is only supported on Linux."); + return FullRoundTrip((int)(470 * MiB), PartitionTableType.MBR, [ + (FileSystemType.Btrfs, 110 * MiB), + (FileSystemType.Ext4, 50 * MiB), + (FileSystemType.XFS, 0) + ]); + } + + #endregion + + public async Task FullRoundTrip(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.GenerateTestData(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 = RunBackup(sourceDrivePath); + TestUtils.AssertResults(backupResults); + 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 = RunRestore(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() + { + 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 = RunBackup(sourceDrivePath); + TestUtils.AssertResults(backupResults); + + // List backup contents and verify geometry.json is present + using (var c = new Controller("file://" + TARGETFOLDER, TestOptions, null)) + { + var listResults = c.List("*"); + 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 + VerifyGeometryMetadata(TARGETFOLDER, TestOptions, partitions); + } + + [Test] + [Category("DiskImage")] + public async Task Test_SourceProvider_Enumeration() + { + TestContext.Progress.WriteLine("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)]); + TestContext.Progress.WriteLine($"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.Initialize(CancellationToken.None); + + // Enumerate entries + var diskEntries = new List(); + await foreach (var entry in provider.Enumerate(CancellationToken.None)) + { + diskEntries.Add(entry); + TestContext.Progress.WriteLine($"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); + + TestContext.Progress.WriteLine($"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() + { + TestContext.Progress.WriteLine("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)]); + TestContext.Progress.WriteLine($"Source disk image created at: {_sourceImagePath}"); + + // Generate some test data + await ToolTests.GenerateTestData(sourcePartitions.First(), 10, 5, 2, 1024); + _diskHelper.FlushDisk(sourceDrivePath); + _diskHelper.Unmount(sourceDrivePath); + TestContext.Progress.WriteLine($"Test data generated on source partition"); + + // Backup + var backupResults = RunBackup(sourceDrivePath); + TestUtils.AssertResults(backupResults); + TestContext.Progress.WriteLine($"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); + TestContext.Progress.WriteLine($"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 + TestContext.Progress.WriteLine("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)) { - // 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() => - FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.FAT32, 0)]); - - [Test, Category("DiskImage")] - public Task Test_GPT_NTFS() - { - if (!OperatingSystem.IsWindows()) - Assert.Ignore("Test_GPT_NTFS is only supported on Windows."); - return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.NTFS, 0)]); - } - - [Test, Category("DiskImage")] - public Task Test_GPT_APFS() - { - if (!OperatingSystem.IsMacOS()) - Assert.Ignore("APFS is only supported on macOS."); - return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.APFS, 0)]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_GPT_HFSPlus() - { - if (!OperatingSystem.IsMacOS()) - Assert.Ignore("HFSPlus is only supported on macOS."); - return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.HFSPlus, 0)]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_GPT_ExFAT() => - FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.ExFAT, 0)]); - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_GPT_Ext2() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("Ext2 is only supported on Linux."); - return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.Ext2, 0)]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_GPT_Ext3() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("Ext3 is only supported on Linux."); - return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.Ext3, 0)]); - } - - [Test, Category("DiskImage")] - public Task Test_GPT_Ext4() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("Ext4 is only supported on Linux."); - return FullRoundTrip((int)(50 * MiB), PartitionTableType.GPT, [(FileSystemType.Ext4, 0)]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_GPT_XFS() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("XFS is only supported on Linux."); - return FullRoundTrip((int)(310 * MiB), PartitionTableType.GPT, [(FileSystemType.XFS, 0)]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_GPT_Btrfs() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("Btrfs is only supported on Linux."); - return FullRoundTrip((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() => - FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.FAT32, 0)]); - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_MBR_HFSPlus() - { - if (!OperatingSystem.IsMacOS()) - Assert.Ignore("HFSPlus is only supported on macOS."); - return FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.HFSPlus, 0)]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_MBR_ExFAT() => - FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.ExFAT, 0)]); - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_MBR_NTFS() - { - if (!OperatingSystem.IsWindows()) - Assert.Ignore("Test_MBR_NTFS is only supported on Windows."); - return FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.NTFS, 0)]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_MBR_Ext2() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("Ext2 is only supported on Linux."); - return FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.Ext2, 0)]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_MBR_Ext3() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("Ext3 is only supported on Linux."); - return FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.Ext3, 0)]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_MBR_Ext4() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("Ext4 is only supported on Linux."); - return FullRoundTrip((int)(50 * MiB), PartitionTableType.MBR, [(FileSystemType.Ext4, 0)]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_MBR_XFS() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("XFS is only supported on Linux."); - return FullRoundTrip((int)(310 * MiB), PartitionTableType.MBR, [(FileSystemType.XFS, 0)]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_MBR_Btrfs() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("Btrfs is only supported on Linux."); - return FullRoundTrip((int)(110 * MiB), PartitionTableType.MBR, [(FileSystemType.Btrfs, 0)]); - } - - #endregion - - #region Unknown Partition Table - - [Test, Category("DiskImage")] - public Task Test_Unknown_NoPartitions() => - FullRoundTrip((int)(50 * MiB), PartitionTableType.Unknown, []); - - #endregion - - #region GPT Two Partitions - - [Test, Category("DiskImage")] - public Task Test_GPT_FAT32_FAT32() => - FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ - (FileSystemType.FAT32, 50 * MiB), - (FileSystemType.FAT32, 0) - ]); - - [Test, Category("DiskImage")] - public Task Test_GPT_APFS_APFS() - { - if (!OperatingSystem.IsMacOS()) - Assert.Ignore("APFS is only supported on macOS."); - return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ - (FileSystemType.APFS, 50 * MiB), - (FileSystemType.APFS, 0) - ]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_GPT_HFSPlus_HFSPlus() - { - if (!OperatingSystem.IsMacOS()) - Assert.Ignore("HFSPlus is only supported on macOS."); - return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ - (FileSystemType.HFSPlus, 50 * MiB), - (FileSystemType.HFSPlus, 0) - ]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_GPT_APFS_HFSPlus() - { - if (!OperatingSystem.IsMacOS()) - Assert.Ignore("APFS and HFSPlus are only supported on macOS."); - return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ - (FileSystemType.APFS, 50 * MiB), - (FileSystemType.HFSPlus, 0) - ]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_GPT_FAT32_APFS() - { - if (!OperatingSystem.IsMacOS()) - Assert.Ignore("APFS is only supported on macOS."); - return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ - (FileSystemType.FAT32, 50 * MiB), - (FileSystemType.APFS, 0) - ]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_GPT_ExFAT_HFSPlus() - { - if (!OperatingSystem.IsMacOS()) - Assert.Ignore("HFSPlus is only supported on macOS."); - return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ - (FileSystemType.ExFAT, 50 * MiB), - (FileSystemType.HFSPlus, 0) - ]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_GPT_FAT32_ExFAT() => - FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ - (FileSystemType.FAT32, 50 * MiB), - (FileSystemType.ExFAT, 0) - ]); - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_GPT_NTFS_FAT32() - { - if (!OperatingSystem.IsWindows()) - Assert.Ignore("This test is only supported on Windows."); - return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ - (FileSystemType.NTFS, 50 * MiB), - (FileSystemType.FAT32, 0) - ]); - } - - [Test, Category("DiskImage")] - public Task Test_GPT_NTFS_NTFS() - { - if (!OperatingSystem.IsWindows()) - Assert.Ignore("This test is only supported on Windows."); - return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ - (FileSystemType.NTFS, 50 * MiB), - (FileSystemType.NTFS, 0) - ]); - } - - [Test, Category("DiskImage")] - public Task Test_GPT_Ext4_Ext4() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("Ext4 is only supported on Linux."); - return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ - (FileSystemType.Ext4, 50 * MiB), - (FileSystemType.Ext4, 0) - ]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_GPT_Ext4_FAT32() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("This test is only supported on Linux."); - return FullRoundTrip((int)(100 * MiB), PartitionTableType.GPT, [ - (FileSystemType.Ext4, 50 * MiB), - (FileSystemType.FAT32, 0) - ]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_GPT_Ext4_XFS() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("This test is only supported on Linux."); - return FullRoundTrip((int)(360 * MiB), PartitionTableType.GPT, [ - (FileSystemType.Ext4, 50 * MiB), - (FileSystemType.XFS, 0) - ]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_GPT_XFS_FAT32() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("This test is only supported on Linux."); - return FullRoundTrip((int)(360 * MiB), PartitionTableType.GPT, [ - (FileSystemType.XFS, 310 * MiB), - (FileSystemType.FAT32, 0) - ]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_GPT_Btrfs_FAT32() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("This test is only supported on Linux."); - return FullRoundTrip((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() => - FullRoundTrip((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() - { - if (!OperatingSystem.IsMacOS()) - Assert.Ignore("HFSPlus is only supported on macOS."); - return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [ - (FileSystemType.HFSPlus, 50 * MiB), - (FileSystemType.HFSPlus, 0) - ]); - } - - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_MBR_FAT32_ExFAT() - { - return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [ - (FileSystemType.FAT32, 50 * MiB), - (FileSystemType.ExFAT, 0) - ]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_MBR_FAT32_HFSPlus() - { - if (!OperatingSystem.IsMacOS()) - Assert.Ignore("HFSPlus is only supported on macOS."); - return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [ - (FileSystemType.FAT32, 50 * MiB), - (FileSystemType.HFSPlus, 0) - ]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_MBR_NTFS_FAT32() - { - if (!OperatingSystem.IsWindows()) - Assert.Ignore("This test is only supported on Windows."); - return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [ - (FileSystemType.NTFS, 50 * MiB), - (FileSystemType.FAT32, 0) - ]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_MBR_Ext4_Ext4() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("Ext4 is only supported on Linux."); - return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [ - (FileSystemType.Ext4, 50 * MiB), - (FileSystemType.Ext4, 0) - ]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_MBR_FAT32_Ext4() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("This test is only supported on Linux."); - return FullRoundTrip((int)(100 * MiB), PartitionTableType.MBR, [ - (FileSystemType.FAT32, 50 * MiB), - (FileSystemType.Ext4, 0) - ]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_MBR_XFS_Ext4() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("This test is only supported on Linux."); - return FullRoundTrip((int)(360 * MiB), PartitionTableType.MBR, [ - (FileSystemType.XFS, 310 * MiB), - (FileSystemType.Ext4, 0) - ]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_MBR_Btrfs_Ext4() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("This test is only supported on Linux."); - return FullRoundTrip((int)(160 * MiB), PartitionTableType.MBR, [ - (FileSystemType.Btrfs, 110 * MiB), - (FileSystemType.Ext4, 0) - ]); - } - - [Test, Category("DiskImage"), Category("DiskImageLocal")] - public Task Test_MBR_XFS_Btrfs() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("This test is only supported on Linux."); - return FullRoundTrip((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() - { - return FullRoundTrip((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() - { - if (!OperatingSystem.IsMacOS()) - Assert.Ignore("APFS is only supported on macOS."); - return FullRoundTrip((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() - { - if (!OperatingSystem.IsWindows()) - Assert.Ignore("NTFS is only supported on Windows."); - return FullRoundTrip((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() - { - if (!OperatingSystem.IsMacOS()) - Assert.Ignore("HFSPlus is only supported on macOS."); - return FullRoundTrip((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() - { - if (!OperatingSystem.IsMacOS()) - Assert.Ignore("APFS and HFSPlus are only supported on macOS."); - return FullRoundTrip((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() - { - if (!OperatingSystem.IsMacOS()) - Assert.Ignore("APFS and HFSPlus are only supported on macOS."); - return FullRoundTrip((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() - { - if (!OperatingSystem.IsMacOS()) - Assert.Ignore("APFS and HFSPlus are only supported on macOS."); - return FullRoundTrip((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() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("Ext4 is only supported on Linux."); - return FullRoundTrip((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() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("This test is only supported on Linux."); - return FullRoundTrip((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() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("This test is only supported on Linux."); - return FullRoundTrip((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() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("This test is only supported on Linux."); - return FullRoundTrip((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() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("This test is only supported on Linux."); - return FullRoundTrip((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() - { - return FullRoundTrip((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() - { - if (!OperatingSystem.IsMacOS()) - Assert.Ignore("HFSPlus is only supported on macOS."); - return FullRoundTrip((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() - { - if (!OperatingSystem.IsMacOS()) - Assert.Ignore("HFSPlus is only supported on macOS."); - return FullRoundTrip((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() - { - if (!OperatingSystem.IsWindows()) - Assert.Ignore("This test is only supported on Windows."); - return FullRoundTrip((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() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("Ext4 is only supported on Linux."); - return FullRoundTrip((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() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("This test is only supported on Linux."); - return FullRoundTrip((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() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("This test is only supported on Linux."); - return FullRoundTrip((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() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("This test is only supported on Linux."); - return FullRoundTrip((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() - { - if (!OperatingSystem.IsLinux()) - Assert.Ignore("This test is only supported on Linux."); - return FullRoundTrip((int)(470 * MiB), PartitionTableType.MBR, [ - (FileSystemType.Btrfs, 110 * MiB), - (FileSystemType.Ext4, 50 * MiB), - (FileSystemType.XFS, 0) - ]); - } - - #endregion - - public async Task FullRoundTrip(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.GenerateTestData(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 = RunBackup(sourceDrivePath); - TestUtils.AssertResults(backupResults); - 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 = RunRestore(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() - { - 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 = RunBackup(sourceDrivePath); - TestUtils.AssertResults(backupResults); - - // List backup contents and verify geometry.json is present - using (var c = new Controller("file://" + TARGETFOLDER, TestOptions, null)) - { - var listResults = c.List("*"); - 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 - VerifyGeometryMetadata(TARGETFOLDER, TestOptions, partitions); - } - - [Test] - [Category("DiskImage")] - public async Task Test_SourceProvider_Enumeration() - { - TestContext.Progress.WriteLine("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)]); - TestContext.Progress.WriteLine($"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.Initialize(CancellationToken.None); - - // Enumerate entries - var diskEntries = new List(); - await foreach (var entry in provider.Enumerate(CancellationToken.None)) - { - diskEntries.Add(entry); - TestContext.Progress.WriteLine($"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); - - TestContext.Progress.WriteLine($"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() - { - TestContext.Progress.WriteLine("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)]); - TestContext.Progress.WriteLine($"Source disk image created at: {_sourceImagePath}"); - - // Generate some test data - await ToolTests.GenerateTestData(sourcePartitions.First(), 10, 5, 2, 1024); - _diskHelper.FlushDisk(sourceDrivePath); - _diskHelper.Unmount(sourceDrivePath); - TestContext.Progress.WriteLine($"Test data generated on source partition"); - - // Backup - var backupResults = RunBackup(sourceDrivePath); - TestUtils.AssertResults(backupResults); - TestContext.Progress.WriteLine($"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); - TestContext.Progress.WriteLine($"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 - TestContext.Progress.WriteLine("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 = c.Restore(["*"]); - - // 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"); - TestContext.Progress.WriteLine($"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 - TestContext.Progress.WriteLine("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 = c.Restore(["*"]); - Assert.That(!results.Errors.Any() && results.Warnings.Count() <= 1); + // 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"); + TestContext.Progress.WriteLine($"Restore failed as expected with errors: {string.Join(", ", results.Errors)}"); + } + catch (IOException) + { + // Assumed to fail + } + catch (UserInformationException) + { + // Assumed to fail } - TestContext.Progress.WriteLine($"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 + // Second attempt: Restore with auto-unmount option should succeed + TestContext.Progress.WriteLine("Attempting restore with auto-unmount enabled (should succeed)..."); - /// - /// 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 IBackupResults RunBackup(string physicalDrivePath, bool treatFilesystemAsUnknown = false) + options["diskimage-restore-auto-unmount"] = "true"; + using (var c = new Controller("file://" + TARGETFOLDER, options, null)) { - var options = new Dictionary(TestOptions); - options["enable-module"] = "diskimage"; - options["concurrency-fileprocessors"] = "1"; + var results = c.Restore(["*"]); - if (treatFilesystemAsUnknown) - { - options["diskimage-filesystem-unknown"] = "true"; - } + Assert.That(!results.Errors.Any() && results.Warnings.Count() <= 1); + } + TestContext.Progress.WriteLine($"Restore completed successfully with auto-unmount option"); - using var c = new Controller("file://" + TARGETFOLDER, options, null); - var sourceUrl = $"@/testdisk|diskimage://{physicalDrivePath}"; - return c.Backup(new[] { sourceUrl }); + // 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 IBackupResults RunBackup(string physicalDrivePath, bool treatFilesystemAsUnknown = false) + { + var options = new Dictionary(TestOptions); + options["enable-module"] = "diskimage"; + options["concurrency-fileprocessors"] = "1"; + + if (treatFilesystemAsUnknown) + { + options["diskimage-filesystem-unknown"] = "true"; } - /// - /// Runs a restore operation using the Controller. - /// - /// The physical drive path to restore to. - /// The restore results. - private IRestoreResults RunRestore(string restoreDrivePath) + using var c = new Controller("file://" + TARGETFOLDER, options, null); + var sourceUrl = $"@/testdisk|diskimage://{physicalDrivePath}"; + return c.Backup(new[] { sourceUrl }); + } + + /// + /// Runs a restore operation using the Controller. + /// + /// The physical drive path to restore to. + /// The restore results. + private IRestoreResults RunRestore(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); + var results = c.Restore(new[] { "*" }); + + return results; + } + + /// + /// 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 { - var options = new Dictionary(TestOptions); - options["restore-path"] = $"@diskimage://{restoreDrivePath}"; - options["overwrite"] = "true"; - options["restore-file-processors"] = "1"; + RecurseSubdirectories = true, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.System | FileAttributes.ReparsePoint + }; - using var c = new Controller("file://" + TARGETFOLDER, options, null); - var results = c.Restore(new[] { "*" }); + // 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(); - return results; + 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}"); } - /// - /// Recursively compares two directories for structural and content equality. - /// - /// The source directory path. - /// The restored directory path. - private void CompareDirectories(string sourcePath, string restorePath) + // Compare file contents byte-by-byte + foreach (var relativeSourceFile in sourceFiles) { - var options = new EnumerationOptions + var sourceFile = Path.Combine(sourcePath, relativeSourceFile); + var restoreFile = Path.Combine(restorePath, relativeSourceFile); + + if (File.Exists(restoreFile)) { - RecurseSubdirectories = true, - IgnoreInaccessible = true, - AttributesToSkip = FileAttributes.System | FileAttributes.ReparsePoint - }; + var sourceBytes = File.ReadAllBytes(sourceFile); + var restoreBytes = File.ReadAllBytes(restoreFile); - // 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}"); + Assert.That(restoreBytes.Length, Is.EqualTo(sourceBytes.Length), + $"File size mismatch for {sourceFile}"); + Assert.That(restoreBytes, Is.EqualTo(sourceBytes), + $"File content mismatch for {sourceFile}"); } + } - // Compare file contents byte-by-byte - foreach (var relativeSourceFile in sourceFiles) + 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 void VerifyGeometryMetadata(string target, Dictionary options, (FileSystemType, long)[] partitions) + { + TestContext.Progress.WriteLine("Verifying geometry metadata..."); + + using (var c = new Controller("file://" + target, options, null)) + { + var listResults = c.List("*"); + 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 sourceFile = Path.Combine(sourcePath, relativeSourceFile); - var restoreFile = Path.Combine(restorePath, relativeSourceFile); - - if (File.Exists(restoreFile)) + var restoreOptions = new Dictionary(options) { - var sourceBytes = File.ReadAllBytes(sourceFile); - var restoreBytes = File.ReadAllBytes(restoreFile); + ["restore-path"] = RESTOREFOLDER, + ["overwrite"] = "true" + }; - Assert.That(restoreBytes.Length, Is.EqualTo(sourceBytes.Length), - $"File size mismatch for {sourceFile}"); - Assert.That(restoreBytes, Is.EqualTo(sourceBytes), - $"File content mismatch for {sourceFile}"); + using (var restoreController = new Controller("file://" + target, restoreOptions, null)) + { + var restoreResults = restoreController.Restore(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"); + + TestContext.Progress.WriteLine($"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]; + TestContext.Progress.WriteLine($"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"); + } } } - - 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 void VerifyGeometryMetadata(string target, Dictionary options, (FileSystemType, long)[] partitions) - { - TestContext.Progress.WriteLine("Verifying geometry metadata..."); - - using (var c = new Controller("file://" + target, options, null)) + finally { - var listResults = c.List("*"); - 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 = restoreController.Restore(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"); - - TestContext.Progress.WriteLine($"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]; - TestContext.Progress.WriteLine($"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"); - } + File.Delete(geometryPath); } } - finally + catch { - try - { - if (File.Exists(geometryPath)) - { - File.Delete(geometryPath); - } - } - catch - { - // Ignore cleanup errors - } + // 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(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.GenerateTestData(sourcePartitions.First(), 10, 3, 1, 1024); - _diskHelper.FlushDisk(sourceDrivePath); - _diskHelper.Unmount(sourceDrivePath); - await TestContext.Progress.WriteLineAsync("Initial test data generated"); - - // First backup - var firstBackupResults = RunBackup(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); - } - - 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 = RunRestore(restoreDrivePath); - TestUtils.AssertResults(restoreResults); - 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 = RunBackup(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); - } - - 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 = RunRestore(restoreDrivePath); - TestUtils.AssertResults(incrementalRestoreResults); - 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(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.GenerateTestData(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 = RunBackup(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); - } - 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 = RunRestore(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(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.GenerateTestData(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 = RunBackup(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); - } - - 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 = RunRestore(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 } + + /// + /// 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(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.GenerateTestData(sourcePartitions.First(), 10, 3, 1, 1024); + _diskHelper.FlushDisk(sourceDrivePath); + _diskHelper.Unmount(sourceDrivePath); + await TestContext.Progress.WriteLineAsync("Initial test data generated"); + + // First backup + var firstBackupResults = RunBackup(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); + } + + 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 = RunRestore(restoreDrivePath); + TestUtils.AssertResults(restoreResults); + 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 = RunBackup(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); + } + + 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 = RunRestore(restoreDrivePath); + TestUtils.AssertResults(incrementalRestoreResults); + 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(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.GenerateTestData(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 = RunBackup(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); + } + 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 = RunRestore(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(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.GenerateTestData(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 = RunBackup(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); + } + + 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 = RunRestore(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/DiskImageUnitTests.cs b/Duplicati/UnitTest/DiskImage/DiskImageUnitTests.cs index df2804114..0d00e2b84 100644 --- a/Duplicati/UnitTest/DiskImage/DiskImageUnitTests.cs +++ b/Duplicati/UnitTest/DiskImage/DiskImageUnitTests.cs @@ -28,7 +28,6 @@ using System.Reflection; using System.Threading; using System.Threading.Tasks; using Duplicati.Library.Interface; -using Duplicati.UnitTest.DiskImage; using Duplicati.Proprietary.DiskImage; using Duplicati.Proprietary.DiskImage.Disk; using Duplicati.Proprietary.DiskImage.Filesystem; @@ -37,5509 +36,5509 @@ using Duplicati.Proprietary.DiskImage.Partition; using NUnit.Framework; using Assert = NUnit.Framework.Legacy.ClassicAssert; using StringAssert = NUnit.Framework.Legacy.StringAssert; +using Duplicati.UnitTest.DiskImage.Helpers; #nullable enable -namespace Duplicati.UnitTest.DiskImage +namespace Duplicati.UnitTest.DiskImage; + +/// +/// Unit tests for the DiskImage module's internal components. +/// These tests focus on individual component testing with a strong emphasis +/// on unaligned reads/writes and cross-platform compatibility. +/// +[TestFixture] +[Category("DiskImageUnit")] +[Platform("Win,MacOsX,Linux")] +public class DiskImageUnitTests : BasicSetupHelper { + // Class-level read-only disks (set up once for entire test class) + private static IDiskImageHelper? s_diskHelper; + + // GPT disk with 2 FAT32 partitions + private static string s_gptDiskPath = ""; + private static string s_gptDiskIdentifier = ""; + private static IRawDisk? s_gptRawDisk; + private static long s_gptPartition1Offset; + private static long s_gptPartition1Size; + private static long s_gptPartition2Offset; + private static long s_gptPartition2Size; + + // MBR disk with 2 FAT32 partitions + private static string s_mbrDiskPath = ""; + private static string s_mbrDiskIdentifier = ""; + private static IRawDisk? s_mbrRawDisk; + private static long s_mbrPartition1Offset; + private static long s_mbrPartition1Size; + private static long s_mbrPartition2Offset; + private static long s_mbrPartition2Size; + + // Writable disk for tests that need to write (re-initialized before each test) + private static string s_writableDiskPath = ""; + private static string s_writableDiskIdentifier = ""; + private static IRawDisk? s_writableRawDisk; + + // Per-test instance members + private IRawDisk _writableRawDisk = null!; + + private const long MiB = 1024 * 1024; + + #region Class-level Setup and Teardown + /// - /// Unit tests for the DiskImage module's internal components. - /// These tests focus on individual component testing with a strong emphasis - /// on unaligned reads/writes and cross-platform compatibility. + /// One-time setup for the entire test class. + /// Creates read-only GPT and MBR disks with 2 FAT32 partitions each. /// - [TestFixture] - [Category("DiskImageUnit")] - [Platform("Win,MacOsX,Linux")] - public class DiskImageUnitTests : BasicSetupHelper + [OneTimeSetUp] + public void OneTimeSetUp() { - // Class-level read-only disks (set up once for entire test class) - private static IDiskImageHelper? s_diskHelper; - - // GPT disk with 2 FAT32 partitions - private static string s_gptDiskPath = ""; - private static string s_gptDiskIdentifier = ""; - private static IRawDisk? s_gptRawDisk; - private static long s_gptPartition1Offset; - private static long s_gptPartition1Size; - private static long s_gptPartition2Offset; - private static long s_gptPartition2Size; - - // MBR disk with 2 FAT32 partitions - private static string s_mbrDiskPath = ""; - private static string s_mbrDiskIdentifier = ""; - private static IRawDisk? s_mbrRawDisk; - private static long s_mbrPartition1Offset; - private static long s_mbrPartition1Size; - private static long s_mbrPartition2Offset; - private static long s_mbrPartition2Size; - - // Writable disk for tests that need to write (re-initialized before each test) - private static string s_writableDiskPath = ""; - private static string s_writableDiskIdentifier = ""; - private static IRawDisk? s_writableRawDisk; - - // Per-test instance members - private IRawDisk _writableRawDisk = null!; - - private const long MiB = 1024 * 1024; - - #region Class-level Setup and Teardown - - /// - /// One-time setup for the entire test class. - /// Creates read-only GPT and MBR disks with 2 FAT32 partitions each. - /// - [OneTimeSetUp] - public void OneTimeSetUp() - { - base.BasicHelperSetUp(); - - // Create the appropriate disk image helper for the current platform - s_diskHelper = DiskImageHelperFactory.Create(); - - // Check for admin privileges - if (!s_diskHelper.HasRequiredPrivileges()) - { - Assert.Ignore("DiskImage tests require administrator privileges"); - } - - var extension = DiskImageTestHelpers.GetPlatformDiskImageExtension(); - - // Create GPT disk with 2 FAT32 partitions - s_gptDiskPath = Path.Combine(BASEFOLDER, $"duplicati_gpt_class_test.{extension}"); - s_gptDiskIdentifier = DiskImageTestHelpers.CreateDiskWithPartitions( - s_diskHelper, s_gptDiskPath, 100 * MiB, PartitionTableType.GPT, [(FileSystemType.FAT32, 50 * MiB), (FileSystemType.FAT32, 0)]); - - // Get partition info from GPT disk - var gptPartitions = s_diskHelper.GetPartitions(s_gptDiskIdentifier); - if (gptPartitions.Length >= 2) - { - s_gptPartition1Offset = gptPartitions[0].StartOffset; - s_gptPartition1Size = gptPartitions[0].Size; - s_gptPartition2Offset = gptPartitions[1].StartOffset; - s_gptPartition2Size = gptPartitions[1].Size; - } - - // Create and initialize raw disk for GPT (read-only) - s_gptRawDisk = DiskImageTestHelpers.CreateRawDiskForIdentifier(s_gptDiskIdentifier); - if (!s_gptRawDisk.InitializeAsync(true, CancellationToken.None).Result) - throw new InvalidOperationException("Failed to initialize GPT raw disk"); - - // Fill GPT partitions with well-known test data - DiskImageTestHelpers.FillPartitionWithTestDataAsync(s_gptRawDisk, s_gptPartition1Offset, s_gptPartition1Size, CancellationToken.None).Wait(); - DiskImageTestHelpers.FillPartitionWithTestDataAsync(s_gptRawDisk, s_gptPartition2Offset, s_gptPartition2Size, CancellationToken.None).Wait(); - s_diskHelper.FlushDisk(s_gptDiskIdentifier); - - // Create MBR disk with 2 FAT32 partitions - s_mbrDiskPath = Path.Combine(BASEFOLDER, $"duplicati_mbr_class_test.{extension}"); - s_mbrDiskIdentifier = DiskImageTestHelpers.CreateDiskWithPartitions( - s_diskHelper, s_mbrDiskPath, 100 * MiB, PartitionTableType.MBR, [(FileSystemType.FAT32, 50 * MiB), (FileSystemType.FAT32, 0)]); - - // Get partition info from MBR disk - var mbrPartitions = s_diskHelper.GetPartitions(s_mbrDiskIdentifier); - if (mbrPartitions.Length >= 2) - { - s_mbrPartition1Offset = mbrPartitions[0].StartOffset; - s_mbrPartition1Size = mbrPartitions[0].Size; - s_mbrPartition2Offset = mbrPartitions[1].StartOffset; - s_mbrPartition2Size = mbrPartitions[1].Size; - } - - // Create and initialize raw disk for MBR (read-only) - s_mbrRawDisk = DiskImageTestHelpers.CreateRawDiskForIdentifier(s_mbrDiskIdentifier); - if (!s_mbrRawDisk.InitializeAsync(true, CancellationToken.None).Result) - throw new InvalidOperationException("Failed to initialize MBR raw disk"); - - // Fill MBR partitions with well-known test data - DiskImageTestHelpers.FillPartitionWithTestDataAsync(s_mbrRawDisk, s_mbrPartition1Offset, s_mbrPartition1Size, CancellationToken.None).Wait(); - DiskImageTestHelpers.FillPartitionWithTestDataAsync(s_mbrRawDisk, s_mbrPartition2Offset, s_mbrPartition2Size, CancellationToken.None).Wait(); - s_diskHelper.FlushDisk(s_mbrDiskIdentifier); - - // Create writable disk path (will be created per-test) - s_writableDiskPath = Path.Combine(BASEFOLDER, $"duplicati_writable_class_test.{extension}"); - - // Create new writable disk (100 MiB, uninitialized) - s_writableDiskIdentifier = s_diskHelper.CreateDisk(s_writableDiskPath, 100 * MiB); - s_diskHelper.Unmount(s_writableDiskIdentifier); - - // Create raw disk interface (with write access) - s_writableRawDisk = DiskImageTestHelpers.CreateRawDiskForIdentifier(s_writableDiskIdentifier); - if (!s_writableRawDisk.InitializeAsync(true, CancellationToken.None).Result) - throw new InvalidOperationException("Failed to initialize writable raw disk"); - } - - /// - /// One-time teardown for the entire test class. - /// Cleans up all class-level disk resources. - /// - [OneTimeTearDown] - public void OneTimeTearDown() - { - // Dispose read-only raw disks - s_gptRawDisk?.Dispose(); - s_mbrRawDisk?.Dispose(); - s_writableRawDisk?.Dispose(); - - // Cleanup class-level disks - this properly detaches and deletes the disk images - // Using CleanupDisk ensures the disk is properly detached before the file is deleted - if (s_diskHelper != null) - { - s_diskHelper.CleanupDisk(s_gptDiskPath, s_gptDiskIdentifier); - s_diskHelper.CleanupDisk(s_mbrDiskPath, s_mbrDiskIdentifier); - s_diskHelper.CleanupDisk(s_writableDiskPath, s_writableDiskIdentifier); - } - - s_diskHelper?.Dispose(); - } - - #endregion - - #region Per-test Setup and Teardown - - /// - /// Sets up the test environment before each test that needs a writable disk. - /// - [SetUp] - public void SetUp() - { - if (s_writableRawDisk != null) - { - _writableRawDisk = s_writableRawDisk; - } - } - - /// - /// Cleans up after each test. - /// - [TearDown] - public void TearDown() - { - // No longer unmounting per-test to improve performance - } - - #endregion - - #region IRawDisk Sector-Aligned Tests - - [Test] - public async Task Test_RawDisk_ReadSector_ReturnsNonEmptyData() - { - var sectorSize = s_gptRawDisk!.SectorSize; - using var stream = await s_gptRawDisk.ReadSectorsAsync(0, 1, CancellationToken.None); - var buffer = new byte[sectorSize]; - var bytesRead = await stream.ReadAsync(buffer.AsMemory(), CancellationToken.None); - Assert.AreEqual(sectorSize, bytesRead, "Should have read 1 sector."); - bool hasData = buffer.Any(x => x != 0); - Assert.IsTrue(hasData, "Sector 0 should contain data."); - } - - [Test] - public async Task Test_RawDisk_ReadBytes_ReturnsData() - { - var sectorSize = s_gptRawDisk!.SectorSize; - using var stream = await s_gptRawDisk.ReadBytesAsync(sectorSize, sectorSize, CancellationToken.None); - Assert.AreEqual(sectorSize, stream.Length, "Should have read the correct amount of bytes."); - } - - [Test] - public async Task Test_RawDisk_ReadBytesAsync_CallerProvidedBuffer() - { - var sectorSize = s_gptRawDisk!.SectorSize; - var buffer = new byte[sectorSize]; - var bytesRead = await s_gptRawDisk.ReadBytesAsync(0, buffer.AsMemory(), CancellationToken.None); - Assert.AreEqual(sectorSize, bytesRead, "Should have read the correct amount of bytes."); - } - - [Test] - public async Task Test_RawDisk_WriteSectors_DataMatches() - { - var sectorSize = _writableRawDisk.SectorSize; - var writeBuffer = new byte[sectorSize]; - new Random().NextBytes(writeBuffer); - await _writableRawDisk.WriteSectorsAsync(1, writeBuffer, CancellationToken.None); - using var readStream = await _writableRawDisk.ReadSectorsAsync(1, 1, CancellationToken.None); - var readBuffer = new byte[sectorSize]; - var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - Assert.AreEqual(sectorSize, bytesRead, "Should have read the correct amount of bytes."); - Assert.AreEqual(writeBuffer, readBuffer, "Data should match."); - } - - [Test] - public async Task Test_RawDisk_WriteBytes_DataMatches() - { - var sectorSize = _writableRawDisk.SectorSize; - var writeBuffer = new byte[sectorSize]; - new Random().NextBytes(writeBuffer); - await _writableRawDisk.WriteBytesAsync(sectorSize, writeBuffer, CancellationToken.None); - using var readStream = await _writableRawDisk.ReadBytesAsync(sectorSize, sectorSize, CancellationToken.None); - var readBuffer = new byte[sectorSize]; - var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - Assert.AreEqual(sectorSize, bytesRead, "Should have read the correct amount of bytes."); - Assert.AreEqual(writeBuffer, readBuffer, "Data should match."); - } - - [Test] - public async Task Test_RawDisk_WriteBytes_Memory() - { - var sectorSize = _writableRawDisk.SectorSize; - var writeBuffer = new byte[sectorSize]; - new Random().NextBytes(writeBuffer); - await _writableRawDisk.WriteBytesAsync(sectorSize, writeBuffer.AsMemory(), CancellationToken.None); - using var readStream = await _writableRawDisk.ReadBytesAsync(sectorSize, sectorSize, CancellationToken.None); - var readBuffer = new byte[sectorSize]; - var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - Assert.AreEqual(sectorSize, bytesRead, "Should have read the correct amount of bytes."); - Assert.AreEqual(writeBuffer, readBuffer, "Data should match."); - } - - #endregion - - #region IRawDisk Unaligned Read Tests - - [Test] - public async Task Test_RawDisk_ReadUnalignedOffset_ReturnsCorrectData() - { - // Use writable disk for this test - var sectorSize = _writableRawDisk.SectorSize; - - // Write known pattern at sector 1 - var writeBuffer = new byte[sectorSize]; - for (int i = 0; i < sectorSize; i++) - writeBuffer[i] = (byte)(i & 0xFF); - - await _writableRawDisk.WriteSectorsAsync(1, writeBuffer, CancellationToken.None); - - // Read at offset = sector_size + 1 (unaligned) - var offset = sectorSize + 1; - var length = sectorSize - 2; - - using var stream = await _writableRawDisk.ReadBytesAsync(offset, length, CancellationToken.None); - var readBuffer = new byte[length]; - var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(length, bytesRead, "Should have read the requested length."); - for (int i = 0; i < length; i++) - Assert.AreEqual((byte)((i + 1) & 0xFF), readBuffer[i], $"Byte at position {i} should match."); - } - - [Test] - public async Task Test_RawDisk_ReadUnalignedLength_ReturnsCorrectData() - { - var sectorSize = _writableRawDisk.SectorSize; - - // Write known pattern at sector 0 - var writeBuffer = new byte[sectorSize]; - for (int i = 0; i < sectorSize; i++) - writeBuffer[i] = (byte)(i & 0xFF); - - await _writableRawDisk.WriteSectorsAsync(0, writeBuffer, CancellationToken.None); - - // Read with unaligned length (sectorSize - 1) - var length = sectorSize - 1; - using var stream = await _writableRawDisk.ReadBytesAsync(0, length, CancellationToken.None); - var readBuffer = new byte[length]; - var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(length, bytesRead, "Should have read the requested length."); - for (int i = 0; i < length; i++) - Assert.AreEqual((byte)(i & 0xFF), readBuffer[i], $"Byte at position {i} should match."); - } - - [Test] - public async Task Test_RawDisk_ReadShortLength_ReturnsCorrectData() - { - var sectorSize = _writableRawDisk.SectorSize; - - // Write known pattern at sector 0 - var writeBuffer = new byte[sectorSize]; - for (int i = 0; i < sectorSize; i++) - writeBuffer[i] = (byte)(i & 0xFF); - - await _writableRawDisk.WriteSectorsAsync(0, writeBuffer, CancellationToken.None); - - // Read short length - var length = sectorSize - 1; - using var stream = await _writableRawDisk.ReadBytesAsync(0, length, CancellationToken.None); - var readBuffer = new byte[length]; - var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(length, bytesRead, "Should have read the requested short length."); - for (int i = 0; i < length; i++) - Assert.AreEqual((byte)(i & 0xFF), readBuffer[i], $"Byte at position {i} should match."); - } - - [Test] - public async Task Test_RawDisk_ReadStraddlingSectors_ReturnsCorrectData() - { - var sectorSize = _writableRawDisk.SectorSize; - - // Write different patterns at sectors 0 and 1 - var sector0Data = new byte[sectorSize]; - var sector1Data = new byte[sectorSize]; - for (int i = 0; i < sectorSize; i++) - { - sector0Data[i] = (byte)(i | 0xF0); - sector1Data[i] = (byte)(i | 0x0F); - } - - await _writableRawDisk.WriteSectorsAsync(0, sector0Data, CancellationToken.None); - await _writableRawDisk.WriteSectorsAsync(1, sector1Data, CancellationToken.None); - - // Read at half-sector offset with full sector length - var offset = sectorSize / 2; - using var stream = await _writableRawDisk.ReadBytesAsync(offset, sectorSize, CancellationToken.None); - var readBuffer = new byte[sectorSize]; - var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector."); - - // First half should be from end of sector 0 - for (int i = 0; i < sectorSize / 2; i++) - { - var expected = (byte)((sectorSize / 2 + i) | 0xF0); - Assert.AreEqual(expected, readBuffer[i], $"Byte at position {i} should match sector 0 data."); - } - - // Second half should be from start of sector 1 - for (int i = sectorSize / 2; i < sectorSize; i++) - { - var expected = (byte)((i - sectorSize / 2) | 0x0F); - Assert.AreEqual(expected, readBuffer[i], $"Byte at position {i} should match sector 1 data."); - } - } - - [Test] - public async Task Test_RawDisk_ReadNearEndOfDisk_ReturnsCorrectData() - { - var sectorSize = _writableRawDisk.SectorSize; - var diskSize = _writableRawDisk.Size; - - // Calculate the last sector and write data there - var lastSector = (diskSize / sectorSize) - 1; - var writeBuffer = new byte[sectorSize]; - new Random().NextBytes(writeBuffer); - - await _writableRawDisk.WriteSectorsAsync(lastSector, writeBuffer, CancellationToken.None); - - // Read from near the end - var offset = diskSize - sectorSize; - using var stream = await _writableRawDisk.ReadBytesAsync(offset, sectorSize, CancellationToken.None); - var readBuffer = new byte[sectorSize]; - var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(sectorSize, bytesRead, "Should have read a full sector."); - Assert.AreEqual(writeBuffer, readBuffer, "Data should match what was written."); - } - - [Test] - public async Task Test_RawDisk_ReadUnalignedOffsetWithMemory_ReturnsCorrectData() - { - var sectorSize = _writableRawDisk.SectorSize; - - // Write known pattern at sector 1 - var writeBuffer = new byte[sectorSize]; - for (int i = 0; i < sectorSize; i++) - writeBuffer[i] = (byte)((i * 2) & 0xFF); - - await _writableRawDisk.WriteSectorsAsync(1, writeBuffer, CancellationToken.None); - - // Read at unaligned offset - var offset = sectorSize + 4; - var length = sectorSize - 8; - var readBuffer = new byte[length]; - var bytesRead = await _writableRawDisk.ReadBytesAsync(offset, readBuffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(length, bytesRead, "Should have read the requested length."); - for (int i = 0; i < length; i++) - Assert.AreEqual((byte)(((i + 4) * 2) & 0xFF), readBuffer[i], $"Byte at position {i} should match."); - } - - [Test] - public async Task Test_RawDisk_ReadUnalignedLengthWithMemory_ReturnsCorrectData() - { - var sectorSize = _writableRawDisk.SectorSize; - - // Write known pattern at sector 0 - var writeBuffer = new byte[sectorSize]; - for (int i = 0; i < sectorSize; i++) - writeBuffer[i] = (byte)((i + 100) & 0xFF); - - await _writableRawDisk.WriteSectorsAsync(0, writeBuffer, CancellationToken.None); - - // Read with unaligned length - var length = sectorSize - 5; - var readBuffer = new byte[length]; - var bytesRead = await _writableRawDisk.ReadBytesAsync(0, readBuffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(length, bytesRead, "Should have read the requested length."); - for (int i = 0; i < length; i++) - Assert.AreEqual((byte)((i + 100) & 0xFF), readBuffer[i], $"Byte at position {i} should match."); - } - - #endregion - - #region IRawDisk Unaligned Write Tests - - [Test] - public async Task Test_RawDisk_WriteUnalignedOffset_DataMatches() - { - var sectorSize = _writableRawDisk.SectorSize; - - // First, write known patterns to two consecutive sectors - var sector1Data = new byte[sectorSize]; - var sector2Data = new byte[sectorSize]; - for (int i = 0; i < sectorSize; i++) - { - sector1Data[i] = (byte)(i | 0xA0); - sector2Data[i] = (byte)(i | 0xB0); - } - - await _writableRawDisk.WriteSectorsAsync(1, sector1Data, CancellationToken.None); - await _writableRawDisk.WriteSectorsAsync(2, sector2Data, CancellationToken.None); - - // Now write at unaligned offset (sector_size + 5) - var unalignedOffset = sectorSize + 5; - var writeData = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 }; - var bytesWritten = await _writableRawDisk.WriteBytesAsync(unalignedOffset, writeData, CancellationToken.None); - - Assert.AreEqual(writeData.Length, bytesWritten, "Should have written all bytes."); - - // Read back both sectors and verify only the intended bytes were changed - using var readStream = await _writableRawDisk.ReadSectorsAsync(1, 2, CancellationToken.None); - var readBuffer = new byte[sectorSize * 2]; - var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(sectorSize * 2, bytesRead, "Should have read two sectors."); - - // Verify sector 1 data - for (int i = 0; i < 5; i++) - Assert.AreEqual((byte)(i | 0xA0), readBuffer[i], $"Byte at position {i} in sector 1 should be unchanged."); - for (int i = 0; i < writeData.Length; i++) - Assert.AreEqual(writeData[i], readBuffer[5 + i], $"Written byte at position {5 + i} should match."); - for (int i = 5 + writeData.Length; i < sectorSize; i++) - Assert.AreEqual((byte)(i | 0xA0), readBuffer[i], $"Byte at position {i} in sector 1 should be unchanged."); - - // Verify sector 2 data (should be completely unchanged) - for (int i = 0; i < sectorSize; i++) - Assert.AreEqual((byte)(i | 0xB0), readBuffer[sectorSize + i], $"Byte at position {i} in sector 2 should be unchanged."); - } - - [Test] - public async Task Test_RawDisk_WriteUnalignedLength_DataMatches() - { - var sectorSize = _writableRawDisk.SectorSize; - - // Write known pattern to sector 3 - var sectorData = new byte[sectorSize]; - for (int i = 0; i < sectorSize; i++) - sectorData[i] = (byte)(i | 0xC0); - - await _writableRawDisk.WriteSectorsAsync(3, sectorData, CancellationToken.None); - - // Write unaligned length (sectorSize - 10) - var writeLength = sectorSize - 10; - var writeData = new byte[writeLength]; - for (int i = 0; i < writeLength; i++) - writeData[i] = (byte)((i + 50) & 0xFF); - - var bytesWritten = await _writableRawDisk.WriteBytesAsync(3 * sectorSize, writeData, CancellationToken.None); - - Assert.AreEqual(writeData.Length, bytesWritten, "Should have written all bytes."); - - // Read back and verify - using var readStream = await _writableRawDisk.ReadSectorsAsync(3, 1, CancellationToken.None); - var readBuffer = new byte[sectorSize]; - var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector."); - - // First part should be our written data - for (int i = 0; i < writeLength; i++) - Assert.AreEqual((byte)((i + 50) & 0xFF), readBuffer[i], $"Byte at position {i} should match written data."); - - // Remaining bytes should be unchanged - for (int i = writeLength; i < sectorSize; i++) - Assert.AreEqual((byte)(i | 0xC0), readBuffer[i], $"Byte at position {i} should be unchanged."); - } - - [Test] - public async Task Test_RawDisk_WriteSingleByte_VerifySingleByteChanged() - { - var sectorSize = _writableRawDisk.SectorSize; - - // Write known pattern to sector 5 - var sectorData = new byte[sectorSize]; - for (int i = 0; i < sectorSize; i++) - sectorData[i] = (byte)(i & 0xFF); - - await _writableRawDisk.WriteSectorsAsync(5, sectorData, CancellationToken.None); - - // Write a single byte at offset 5 * sectorSize + 100 - var byteOffset = 5 * sectorSize + 100; - var singleByte = new byte[] { 0xAB }; - var bytesWritten = await _writableRawDisk.WriteBytesAsync(byteOffset, singleByte, CancellationToken.None); - - Assert.AreEqual(1, bytesWritten, "Should have written 1 byte."); - - // Read back the full sector - using var readStream = await _writableRawDisk.ReadSectorsAsync(5, 1, CancellationToken.None); - var readBuffer = new byte[sectorSize]; - var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector."); - - // Verify all bytes except position 100 are unchanged - for (int i = 0; i < sectorSize; i++) - if (i == 100) - Assert.AreEqual(0xAB, readBuffer[i], "Byte at position 100 should be the written value."); - else - Assert.AreEqual((byte)(i & 0xFF), readBuffer[i], $"Byte at position {i} should be unchanged."); - } - - [Test] - public async Task Test_RawDisk_WriteSpanningSectorBoundary_DataMatches() - { - var sectorSize = _writableRawDisk.SectorSize; - - // Write known patterns to sectors 6 and 7 - var sector6Data = new byte[sectorSize]; - var sector7Data = new byte[sectorSize]; - for (int i = 0; i < sectorSize; i++) - { - sector6Data[i] = (byte)(i | 0xD0); - sector7Data[i] = (byte)(i | 0xE0); - } - - await _writableRawDisk.WriteSectorsAsync(6, sector6Data, CancellationToken.None); - await _writableRawDisk.WriteSectorsAsync(7, sector7Data, CancellationToken.None); - - // Write spanning sector boundary: offset = sector_size - 4, length = 8 - var spanOffset = 6 * sectorSize + sectorSize - 4; - var spanData = new byte[] { 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88 }; - var bytesWritten = await _writableRawDisk.WriteBytesAsync(spanOffset, spanData, CancellationToken.None); - - Assert.AreEqual(spanData.Length, bytesWritten, "Should have written all bytes."); - - // Read back both sectors - using var readStream = await _writableRawDisk.ReadSectorsAsync(6, 2, CancellationToken.None); - var readBuffer = new byte[sectorSize * 2]; - var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(sectorSize * 2, bytesRead, "Should have read two sectors."); - - // Verify sector 6: last 4 bytes should be the first 4 of our written data - for (int i = 0; i < sectorSize - 4; i++) - Assert.AreEqual((byte)(i | 0xD0), readBuffer[i], $"Byte at position {i} in sector 6 should be unchanged."); - for (int i = 0; i < 4; i++) - Assert.AreEqual(spanData[i], readBuffer[sectorSize - 4 + i], $"Span byte at position {i} in sector 6 should match."); - - // Verify sector 7: first 4 bytes should be the last 4 of our written data - for (int i = 0; i < 4; i++) - Assert.AreEqual(spanData[4 + i], readBuffer[sectorSize + i], $"Span byte at position {4 + i} in sector 7 should match."); - for (int i = 4; i < sectorSize; i++) - Assert.AreEqual((byte)(i | 0xE0), readBuffer[sectorSize + i], $"Byte at position {i} in sector 7 should be unchanged."); - } - - [Test] - public async Task Test_RawDisk_WriteAtLastSector_NoOverflow() - { - var sectorSize = _writableRawDisk.SectorSize; - var diskSize = _writableRawDisk.Size; - - // Calculate the last sector - var lastSector = (diskSize / sectorSize) - 1; - - // Write a full sector at the last valid sector - var writeData = new byte[sectorSize]; - new Random(42).NextBytes(writeData); - - var bytesWritten = await _writableRawDisk.WriteSectorsAsync(lastSector, writeData, CancellationToken.None); - Assert.AreEqual(sectorSize, bytesWritten, "Should have written a full sector."); - - // Read it back and verify - using var readStream = await _writableRawDisk.ReadSectorsAsync(lastSector, 1, CancellationToken.None); - var readBuffer = new byte[sectorSize]; - var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector."); - Assert.AreEqual(writeData, readBuffer, "Data should match."); - } - - [Test] - public async Task Test_RawDisk_WriteUnalignedWithMemory_DataMatches() - { - var sectorSize = _writableRawDisk.SectorSize; - - // Write known pattern to sector 8 - var sectorData = new byte[sectorSize]; - for (int i = 0; i < sectorSize; i++) - sectorData[i] = (byte)(i | 0xF0); - - await _writableRawDisk.WriteSectorsAsync(8, sectorData, CancellationToken.None); - - // Write at unaligned offset using Memory - var unalignedOffset = 8 * sectorSize + 10; - var writeData = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD, 0xEE }; - var bytesWritten = await _writableRawDisk.WriteBytesAsync(unalignedOffset, writeData.AsMemory(), CancellationToken.None); - - Assert.AreEqual(writeData.Length, bytesWritten, "Should have written all bytes."); - - // Read back and verify - using var readStream = await _writableRawDisk.ReadSectorsAsync(8, 1, CancellationToken.None); - var readBuffer = new byte[sectorSize]; - var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector."); - - // Verify data before written region - for (int i = 0; i < 10; i++) - Assert.AreEqual((byte)(i | 0xF0), readBuffer[i], $"Byte at position {i} should be unchanged."); + base.BasicHelperSetUp(); - // Verify written data - for (int i = 0; i < writeData.Length; i++) - Assert.AreEqual(writeData[i], readBuffer[10 + i], $"Written byte at position {10 + i} should match."); + // Create the appropriate disk image helper for the current platform + s_diskHelper = DiskImageHelperFactory.Create(); - // Verify data after written region - for (int i = 10 + writeData.Length; i < sectorSize; i++) - Assert.AreEqual((byte)(i | 0xF0), readBuffer[i], $"Byte at position {i} should be unchanged."); - } - - #endregion - - #region PartitionTableFactory detection tests - - [Test] - public async Task Test_PartitionTableFactory_GPTBytes_DetectsGPT() - { - var sectorSize = 512; - var diskBytes = new byte[sectorSize * 4]; - - // MBR (sector 0) - Protective MBR with GPT signature - diskBytes[510] = 0x55; - diskBytes[511] = 0xAA; - diskBytes[450] = 0xEE; - - // GPT header (sector 1) - "EFI PART" signature - var gptSignature = "EFI PART"u8.ToArray(); - Buffer.BlockCopy(gptSignature, 0, diskBytes, sectorSize, 8); - - var partitionTable = await PartitionTableFactory.CreateAsync(diskBytes, sectorSize, CancellationToken.None); - - Assert.IsNotNull(partitionTable, "Partition table should be detected."); - Assert.AreEqual(PartitionTableType.GPT, partitionTable!.TableType, "Should detect GPT partition table."); - } - - [Test] - public async Task Test_PartitionTableFactory_MBRBytes_DetectsMBR() - { - var sectorSize = 512; - var diskBytes = new byte[sectorSize]; - - diskBytes[510] = 0x55; - diskBytes[511] = 0xAA; - diskBytes[450] = 0x83; - - var partitionTable = await PartitionTableFactory.CreateAsync(diskBytes, sectorSize, CancellationToken.None); - - Assert.IsNotNull(partitionTable, "Partition table should be detected."); - Assert.AreEqual(PartitionTableType.MBR, partitionTable!.TableType, "Should detect MBR partition table."); - } - - [Test] - public async Task Test_PartitionTableFactory_InvalidBytes_ReturnsUnknown() - { - var sectorSize = 512; - var diskBytes = new byte[sectorSize]; - - var partitionTable = await PartitionTableFactory.CreateAsync(diskBytes, sectorSize, CancellationToken.None); - - Assert.IsNotNull(partitionTable, "Partition table should be created even for unknown type."); - Assert.AreEqual(PartitionTableType.Unknown, partitionTable!.TableType, "Should detect Unknown partition table for invalid bytes."); - } - - [Test] - public async Task Test_PartitionTableFactory_ProtectiveMBRType_DetectsGPT() - { - var sectorSize = 512; - var diskBytes = new byte[sectorSize * 4]; - - diskBytes[510] = 0x55; - diskBytes[511] = 0xAA; - diskBytes[450] = 0xEE; - - var gptSignature = "EFI PART"u8.ToArray(); - Buffer.BlockCopy(gptSignature, 0, diskBytes, sectorSize, 8); - - var partitionTable = await PartitionTableFactory.CreateAsync(diskBytes, sectorSize, CancellationToken.None); - - Assert.IsNotNull(partitionTable, "Partition table should be detected."); - Assert.AreEqual(PartitionTableType.GPT, partitionTable!.TableType, "Should detect GPT when protective MBR type (0xEE) is present."); - } - - [Test] - public async Task Test_PartitionTableFactory_ProtectiveMBRWithoutGptHeader_FallsBackToMBR() - { - var sectorSize = 512; - var diskBytes = new byte[sectorSize * 2]; - - diskBytes[510] = 0x55; - diskBytes[511] = 0xAA; - diskBytes[450] = 0xEE; - - var partitionTable = await PartitionTableFactory.CreateAsync(diskBytes, sectorSize, CancellationToken.None); - - Assert.IsNotNull(partitionTable, "Partition table should be detected."); - Assert.AreEqual(PartitionTableType.MBR, partitionTable!.TableType, "Should fall back to MBR when GPT header is invalid."); - } - - [Test] - public async Task Test_PartitionTableFactory_GPTFromRealDisk_DetectsGPT() - { - var sectorSize = s_gptRawDisk!.SectorSize; - var sectorsToRead = 34; - using var stream = await s_gptRawDisk.ReadSectorsAsync(0, sectorsToRead, CancellationToken.None); - var diskBytes = new byte[sectorSize * sectorsToRead]; - await stream.ReadAtLeastAsync(diskBytes, diskBytes.Length, cancellationToken: CancellationToken.None); - - var partitionTable = await PartitionTableFactory.CreateAsync(diskBytes, sectorSize, CancellationToken.None); - - Assert.IsNotNull(partitionTable, "Partition table should be detected from real disk."); - Assert.AreEqual(PartitionTableType.GPT, partitionTable!.TableType, "Should detect GPT from real disk bytes."); - } - - #endregion - - #region GPT and MBR partition table parsing tests - - [Test] - public async Task Test_GPT_ParseFromRealDisk_ReturnsCorrectPartitions() - { - var sectorSize = s_gptRawDisk!.SectorSize; - - // Read enough sectors to include protective MBR, GPT header, and partition entries - var sectorsToRead = 34; - using var stream = await s_gptRawDisk.ReadSectorsAsync(0, sectorsToRead, CancellationToken.None); - var diskBytes = new byte[sectorSize * sectorsToRead]; - await stream.ReadAtLeastAsync(diskBytes, diskBytes.Length, cancellationToken: CancellationToken.None); - - // Parse the GPT from bytes - var gpt = new GPT(null); - var parsed = await gpt.ParseAsync(diskBytes, sectorSize, CancellationToken.None); - - Assert.IsTrue(parsed, "GPT should parse successfully from real disk bytes."); - - // Verify partition count - we created 2 partitions - var partitions = new List(); - await foreach (var partition in gpt.EnumeratePartitions(CancellationToken.None)) - { - partitions.Add(partition); - } - - Assert.AreEqual(2, partitions.Count, "Should have 2 partitions."); - - // Verify partition properties - var firstPartition = partitions[0]; - Assert.IsNotNull(firstPartition, "First partition should exist."); - Assert.AreEqual(1, firstPartition.PartitionNumber, "Partition number should be 1."); - Assert.Greater(firstPartition.StartOffset, 0, "StartOffset should be greater than 0."); - Assert.Greater(firstPartition.Size, 0, "Size should be greater than 0."); - - // Verify sector alignment - Assert.AreEqual(0, firstPartition.StartOffset % sectorSize, - "StartOffset should be sector-aligned."); - Assert.AreEqual(0, firstPartition.Size % sectorSize, - "Size should be sector-aligned."); - - gpt.Dispose(); - } - - [Test] - public async Task Test_MBR_ParseFromRealDisk_ReturnsCorrectPartitions() - { - var sectorSize = s_mbrRawDisk!.SectorSize; - - // Read the MBR sector (first 512 bytes) - using var stream = await s_mbrRawDisk.ReadSectorsAsync(0, 1, CancellationToken.None); - var mbrBytes = new byte[sectorSize]; - await stream.ReadAtLeastAsync(mbrBytes, mbrBytes.Length, cancellationToken: CancellationToken.None); - - // Parse the MBR from bytes - var mbr = new MBR(null); - var parsed = await mbr.ParseAsync(mbrBytes, sectorSize, CancellationToken.None); - - Assert.IsTrue(parsed, "MBR should parse successfully from real disk bytes."); - - // Verify partition entries - we created 2 partitions - Assert.AreEqual(2, mbr.NumPartitionEntries, "Should have 2 partition entries."); - - // Verify via EnumeratePartitions - var partitions = new List(); - await foreach (var partition in mbr.EnumeratePartitions(CancellationToken.None)) - { - partitions.Add(partition); - } - - Assert.AreEqual(2, partitions.Count, "Should enumerate 2 partitions."); - - // Verify partition properties - var firstPartition = partitions[0]; - Assert.IsNotNull(firstPartition, "First partition should exist."); - Assert.AreEqual(1, firstPartition.PartitionNumber, "Partition number should be 1."); - Assert.Greater(firstPartition.StartOffset, 0, "StartOffset should be greater than 0."); - Assert.Greater(firstPartition.Size, 0, "Size should be greater than 0."); - - // Verify sector alignment - Assert.AreEqual(0, firstPartition.StartOffset % sectorSize, - "StartOffset should be sector-aligned."); - Assert.AreEqual(0, firstPartition.Size % sectorSize, - "Size should be sector-aligned."); - - mbr.Dispose(); - } - - [Test] - public async Task Test_GPT_EnumeratePartitions_ReturnsCorrectCount() - { - var sectorSize = s_gptRawDisk!.SectorSize; - - using var stream = await s_gptRawDisk.ReadSectorsAsync(0, 34, CancellationToken.None); - var diskBytes = new byte[sectorSize * 34]; - await stream.ReadAtLeastAsync(diskBytes, diskBytes.Length, cancellationToken: CancellationToken.None); - - var gpt = new GPT(null); - await gpt.ParseAsync(diskBytes, sectorSize, CancellationToken.None); - - var count = 0; - await foreach (var partition in gpt.EnumeratePartitions(CancellationToken.None)) - { - count++; - Assert.IsNotNull(partition, "Partition should not be null."); - Assert.Greater(partition.PartitionNumber, 0, "Partition number should be positive."); - } - - Assert.AreEqual(2, count, "Should enumerate exactly 2 partitions."); - - gpt.Dispose(); - } - - [Test] - public async Task Test_MBR_EnumeratePartitions_ReturnsCorrectCount() - { - var sectorSize = s_mbrRawDisk!.SectorSize; - - using var stream = await s_mbrRawDisk.ReadSectorsAsync(0, 1, CancellationToken.None); - var mbrBytes = new byte[sectorSize]; - await stream.ReadAtLeastAsync(mbrBytes, mbrBytes.Length, cancellationToken: CancellationToken.None); - - var mbr = new MBR(null); - await mbr.ParseAsync(mbrBytes, sectorSize, CancellationToken.None); - - var count = 0; - await foreach (var partition in mbr.EnumeratePartitions(CancellationToken.None)) - { - count++; - Assert.IsNotNull(partition, "Partition should not be null."); - Assert.Greater(partition.PartitionNumber, 0, "Partition number should be positive."); - } - - Assert.AreEqual(2, count, "Should enumerate exactly 2 partitions."); - - mbr.Dispose(); - } - - [Test] - public async Task Test_GPT_GetPartitionAsync_ReturnsCorrectPartition() + // Check for admin privileges + if (!s_diskHelper.HasRequiredPrivileges()) { - var sectorSize = s_gptRawDisk!.SectorSize; - - using var stream = await s_gptRawDisk.ReadSectorsAsync(0, 34, CancellationToken.None); - var diskBytes = new byte[sectorSize * 34]; - await stream.ReadAtLeastAsync(diskBytes, diskBytes.Length, cancellationToken: CancellationToken.None); - - var gpt = new GPT(null); - await gpt.ParseAsync(diskBytes, sectorSize, CancellationToken.None); - - // Test GetPartitionAsync for partition 1 (should exist) - var partition1 = await gpt.GetPartitionAsync(1, CancellationToken.None); - Assert.IsNotNull(partition1, "Partition 1 should exist."); - Assert.AreEqual(1, partition1!.PartitionNumber, "Partition number should be 1."); - - // Test GetPartitionAsync for partition 2 (should exist) - var partition2 = await gpt.GetPartitionAsync(2, CancellationToken.None); - Assert.IsNotNull(partition2, "Partition 2 should exist."); - Assert.AreEqual(2, partition2!.PartitionNumber, "Partition number should be 2."); - - // Test GetPartitionAsync for partition 3 (should not exist) - var partition3 = await gpt.GetPartitionAsync(3, CancellationToken.None); - Assert.IsNull(partition3, "Partition 3 should not exist."); - - // Test GetPartitionAsync for invalid partition number 0 - var partition0 = await gpt.GetPartitionAsync(0, CancellationToken.None); - Assert.IsNull(partition0, "Partition 0 should not exist (invalid number)."); - - gpt.Dispose(); + Assert.Ignore("DiskImage tests require administrator privileges"); } - [Test] - public async Task Test_MBR_GetPartitionAsync_ReturnsCorrectPartition() - { - var sectorSize = s_mbrRawDisk!.SectorSize; - - using var stream = await s_mbrRawDisk.ReadSectorsAsync(0, 1, CancellationToken.None); - var mbrBytes = new byte[sectorSize]; - await stream.ReadAtLeastAsync(mbrBytes, mbrBytes.Length, cancellationToken: CancellationToken.None); - - var mbr = new MBR(null); - await mbr.ParseAsync(mbrBytes, sectorSize, CancellationToken.None); - - // Test GetPartitionAsync for partition 1 (should exist) - var partition1 = await mbr.GetPartitionAsync(1, CancellationToken.None); - Assert.IsNotNull(partition1, "Partition 1 should exist."); - Assert.AreEqual(1, partition1!.PartitionNumber, "Partition number should be 1."); + var extension = DiskImageTestHelpers.GetPlatformDiskImageExtension(); - // Test GetPartitionAsync for partition 2 (should exist) - var partition2 = await mbr.GetPartitionAsync(2, CancellationToken.None); - Assert.IsNotNull(partition2, "Partition 2 should exist."); - Assert.AreEqual(2, partition2!.PartitionNumber, "Partition number should be 2."); - - // Test GetPartitionAsync for partition 3 (should not exist) - var partition3 = await mbr.GetPartitionAsync(3, CancellationToken.None); - Assert.IsNull(partition3, "Partition 3 should not exist."); - - // Test GetPartitionAsync for invalid partition number 0 - var partition0 = await mbr.GetPartitionAsync(0, CancellationToken.None); - Assert.IsNull(partition0, "Partition 0 should not exist (invalid number)."); - - mbr.Dispose(); - } + // Create GPT disk with 2 FAT32 partitions + s_gptDiskPath = Path.Combine(BASEFOLDER, $"duplicati_gpt_class_test.{extension}"); + s_gptDiskIdentifier = DiskImageTestHelpers.CreateDiskWithPartitions( + s_diskHelper, s_gptDiskPath, 100 * MiB, PartitionTableType.GPT, [(FileSystemType.FAT32, 50 * MiB), (FileSystemType.FAT32, 0)]); - [Test] - public async Task Test_GPT_PartitionAlignment_IsSectorAligned() + // Get partition info from GPT disk + var gptPartitions = s_diskHelper.GetPartitions(s_gptDiskIdentifier); + if (gptPartitions.Length >= 2) { - var sectorSize = s_gptRawDisk!.SectorSize; - - using var stream = await s_gptRawDisk.ReadSectorsAsync(0, 34, CancellationToken.None); - var diskBytes = new byte[sectorSize * 34]; - await stream.ReadAtLeastAsync(diskBytes, diskBytes.Length, cancellationToken: CancellationToken.None); - - var gpt = new GPT(null); - await gpt.ParseAsync(diskBytes, sectorSize, CancellationToken.None); - - // Verify all partitions are sector-aligned - await foreach (var partition in gpt.EnumeratePartitions(CancellationToken.None)) - { - Assert.AreEqual(0, partition.StartOffset % sectorSize, - $"Partition {partition.PartitionNumber} StartOffset ({partition.StartOffset}) should be sector-aligned (sector size: {sectorSize})."); - Assert.AreEqual(0, partition.Size % sectorSize, - $"Partition {partition.PartitionNumber} Size ({partition.Size}) should be sector-aligned (sector size: {sectorSize})."); - } - - gpt.Dispose(); + s_gptPartition1Offset = gptPartitions[0].StartOffset; + s_gptPartition1Size = gptPartitions[0].Size; + s_gptPartition2Offset = gptPartitions[1].StartOffset; + s_gptPartition2Size = gptPartitions[1].Size; } - [Test] - public async Task Test_MBR_PartitionAlignment_IsSectorAligned() - { - var sectorSize = s_mbrRawDisk!.SectorSize; - - using var stream = await s_mbrRawDisk.ReadSectorsAsync(0, 1, CancellationToken.None); - var mbrBytes = new byte[sectorSize]; - await stream.ReadAtLeastAsync(mbrBytes, mbrBytes.Length, cancellationToken: CancellationToken.None); + // Create and initialize raw disk for GPT (read-only) + s_gptRawDisk = DiskImageTestHelpers.CreateRawDiskForIdentifier(s_gptDiskIdentifier); + if (!s_gptRawDisk.InitializeAsync(true, CancellationToken.None).Result) + throw new InvalidOperationException("Failed to initialize GPT raw disk"); - var mbr = new MBR(null); - await mbr.ParseAsync(mbrBytes, sectorSize, CancellationToken.None); + // Fill GPT partitions with well-known test data + DiskImageTestHelpers.FillPartitionWithTestDataAsync(s_gptRawDisk, s_gptPartition1Offset, s_gptPartition1Size, CancellationToken.None).Wait(); + DiskImageTestHelpers.FillPartitionWithTestDataAsync(s_gptRawDisk, s_gptPartition2Offset, s_gptPartition2Size, CancellationToken.None).Wait(); + s_diskHelper.FlushDisk(s_gptDiskIdentifier); - // Verify all partitions are sector-aligned - await foreach (var partition in mbr.EnumeratePartitions(CancellationToken.None)) - { - Assert.AreEqual(0, partition.StartOffset % sectorSize, - $"Partition {partition.PartitionNumber} StartOffset ({partition.StartOffset}) should be sector-aligned (sector size: {sectorSize})."); - Assert.AreEqual(0, partition.Size % sectorSize, - $"Partition {partition.PartitionNumber} Size ({partition.Size}) should be sector-aligned (sector size: {sectorSize})."); - } + // Create MBR disk with 2 FAT32 partitions + s_mbrDiskPath = Path.Combine(BASEFOLDER, $"duplicati_mbr_class_test.{extension}"); + s_mbrDiskIdentifier = DiskImageTestHelpers.CreateDiskWithPartitions( + s_diskHelper, s_mbrDiskPath, 100 * MiB, PartitionTableType.MBR, [(FileSystemType.FAT32, 50 * MiB), (FileSystemType.FAT32, 0)]); - mbr.Dispose(); - } + // Get partition info from MBR disk + var mbrPartitions = s_diskHelper.GetPartitions(s_mbrDiskIdentifier); + if (mbrPartitions.Length >= 2) + { + s_mbrPartition1Offset = mbrPartitions[0].StartOffset; + s_mbrPartition1Size = mbrPartitions[0].Size; + s_mbrPartition2Offset = mbrPartitions[1].StartOffset; + s_mbrPartition2Size = mbrPartitions[1].Size; + } + + // Create and initialize raw disk for MBR (read-only) + s_mbrRawDisk = DiskImageTestHelpers.CreateRawDiskForIdentifier(s_mbrDiskIdentifier); + if (!s_mbrRawDisk.InitializeAsync(true, CancellationToken.None).Result) + throw new InvalidOperationException("Failed to initialize MBR raw disk"); + + // Fill MBR partitions with well-known test data + DiskImageTestHelpers.FillPartitionWithTestDataAsync(s_mbrRawDisk, s_mbrPartition1Offset, s_mbrPartition1Size, CancellationToken.None).Wait(); + DiskImageTestHelpers.FillPartitionWithTestDataAsync(s_mbrRawDisk, s_mbrPartition2Offset, s_mbrPartition2Size, CancellationToken.None).Wait(); + s_diskHelper.FlushDisk(s_mbrDiskIdentifier); + + // Create writable disk path (will be created per-test) + s_writableDiskPath = Path.Combine(BASEFOLDER, $"duplicati_writable_class_test.{extension}"); + + // Create new writable disk (100 MiB, uninitialized) + s_writableDiskIdentifier = s_diskHelper.CreateDisk(s_writableDiskPath, 100 * MiB); + s_diskHelper.Unmount(s_writableDiskIdentifier); + + // Create raw disk interface (with write access) + s_writableRawDisk = DiskImageTestHelpers.CreateRawDiskForIdentifier(s_writableDiskIdentifier); + if (!s_writableRawDisk.InitializeAsync(true, CancellationToken.None).Result) + throw new InvalidOperationException("Failed to initialize writable raw disk"); + } + + /// + /// One-time teardown for the entire test class. + /// Cleans up all class-level disk resources. + /// + [OneTimeTearDown] + public void OneTimeTearDown() + { + // Dispose read-only raw disks + s_gptRawDisk?.Dispose(); + s_mbrRawDisk?.Dispose(); + s_writableRawDisk?.Dispose(); + + // Cleanup class-level disks - this properly detaches and deletes the disk images + // Using CleanupDisk ensures the disk is properly detached before the file is deleted + if (s_diskHelper != null) + { + s_diskHelper.CleanupDisk(s_gptDiskPath, s_gptDiskIdentifier); + s_diskHelper.CleanupDisk(s_mbrDiskPath, s_mbrDiskIdentifier); + s_diskHelper.CleanupDisk(s_writableDiskPath, s_writableDiskIdentifier); + } + + s_diskHelper?.Dispose(); + } + + #endregion + + #region Per-test Setup and Teardown + + /// + /// Sets up the test environment before each test that needs a writable disk. + /// + [SetUp] + public void SetUp() + { + if (s_writableRawDisk != null) + { + _writableRawDisk = s_writableRawDisk; + } + } + + /// + /// Cleans up after each test. + /// + [TearDown] + public void TearDown() + { + // No longer unmounting per-test to improve performance + } + + #endregion + + #region IRawDisk Sector-Aligned Tests + + [Test] + public async Task Test_RawDisk_ReadSector_ReturnsNonEmptyData() + { + var sectorSize = s_gptRawDisk!.SectorSize; + using var stream = await s_gptRawDisk.ReadSectorsAsync(0, 1, CancellationToken.None); + var buffer = new byte[sectorSize]; + var bytesRead = await stream.ReadAsync(buffer.AsMemory(), CancellationToken.None); + Assert.AreEqual(sectorSize, bytesRead, "Should have read 1 sector."); + bool hasData = buffer.Any(x => x != 0); + Assert.IsTrue(hasData, "Sector 0 should contain data."); + } + + [Test] + public async Task Test_RawDisk_ReadBytes_ReturnsData() + { + var sectorSize = s_gptRawDisk!.SectorSize; + using var stream = await s_gptRawDisk.ReadBytesAsync(sectorSize, sectorSize, CancellationToken.None); + Assert.AreEqual(sectorSize, stream.Length, "Should have read the correct amount of bytes."); + } + + [Test] + public async Task Test_RawDisk_ReadBytesAsync_CallerProvidedBuffer() + { + var sectorSize = s_gptRawDisk!.SectorSize; + var buffer = new byte[sectorSize]; + var bytesRead = await s_gptRawDisk.ReadBytesAsync(0, buffer.AsMemory(), CancellationToken.None); + Assert.AreEqual(sectorSize, bytesRead, "Should have read the correct amount of bytes."); + } + + [Test] + public async Task Test_RawDisk_WriteSectors_DataMatches() + { + var sectorSize = _writableRawDisk.SectorSize; + var writeBuffer = new byte[sectorSize]; + new Random().NextBytes(writeBuffer); + await _writableRawDisk.WriteSectorsAsync(1, writeBuffer, CancellationToken.None); + using var readStream = await _writableRawDisk.ReadSectorsAsync(1, 1, CancellationToken.None); + var readBuffer = new byte[sectorSize]; + var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + Assert.AreEqual(sectorSize, bytesRead, "Should have read the correct amount of bytes."); + Assert.AreEqual(writeBuffer, readBuffer, "Data should match."); + } + + [Test] + public async Task Test_RawDisk_WriteBytes_DataMatches() + { + var sectorSize = _writableRawDisk.SectorSize; + var writeBuffer = new byte[sectorSize]; + new Random().NextBytes(writeBuffer); + await _writableRawDisk.WriteBytesAsync(sectorSize, writeBuffer, CancellationToken.None); + using var readStream = await _writableRawDisk.ReadBytesAsync(sectorSize, sectorSize, CancellationToken.None); + var readBuffer = new byte[sectorSize]; + var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + Assert.AreEqual(sectorSize, bytesRead, "Should have read the correct amount of bytes."); + Assert.AreEqual(writeBuffer, readBuffer, "Data should match."); + } + + [Test] + public async Task Test_RawDisk_WriteBytes_Memory() + { + var sectorSize = _writableRawDisk.SectorSize; + var writeBuffer = new byte[sectorSize]; + new Random().NextBytes(writeBuffer); + await _writableRawDisk.WriteBytesAsync(sectorSize, writeBuffer.AsMemory(), CancellationToken.None); + using var readStream = await _writableRawDisk.ReadBytesAsync(sectorSize, sectorSize, CancellationToken.None); + var readBuffer = new byte[sectorSize]; + var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + Assert.AreEqual(sectorSize, bytesRead, "Should have read the correct amount of bytes."); + Assert.AreEqual(writeBuffer, readBuffer, "Data should match."); + } + + #endregion + + #region IRawDisk Unaligned Read Tests + + [Test] + public async Task Test_RawDisk_ReadUnalignedOffset_ReturnsCorrectData() + { + // Use writable disk for this test + var sectorSize = _writableRawDisk.SectorSize; + + // Write known pattern at sector 1 + var writeBuffer = new byte[sectorSize]; + for (int i = 0; i < sectorSize; i++) + writeBuffer[i] = (byte)(i & 0xFF); + + await _writableRawDisk.WriteSectorsAsync(1, writeBuffer, CancellationToken.None); + + // Read at offset = sector_size + 1 (unaligned) + var offset = sectorSize + 1; + var length = sectorSize - 2; + + using var stream = await _writableRawDisk.ReadBytesAsync(offset, length, CancellationToken.None); + var readBuffer = new byte[length]; + var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(length, bytesRead, "Should have read the requested length."); + for (int i = 0; i < length; i++) + Assert.AreEqual((byte)((i + 1) & 0xFF), readBuffer[i], $"Byte at position {i} should match."); + } + + [Test] + public async Task Test_RawDisk_ReadUnalignedLength_ReturnsCorrectData() + { + var sectorSize = _writableRawDisk.SectorSize; + + // Write known pattern at sector 0 + var writeBuffer = new byte[sectorSize]; + for (int i = 0; i < sectorSize; i++) + writeBuffer[i] = (byte)(i & 0xFF); + + await _writableRawDisk.WriteSectorsAsync(0, writeBuffer, CancellationToken.None); + + // Read with unaligned length (sectorSize - 1) + var length = sectorSize - 1; + using var stream = await _writableRawDisk.ReadBytesAsync(0, length, CancellationToken.None); + var readBuffer = new byte[length]; + var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(length, bytesRead, "Should have read the requested length."); + for (int i = 0; i < length; i++) + Assert.AreEqual((byte)(i & 0xFF), readBuffer[i], $"Byte at position {i} should match."); + } + + [Test] + public async Task Test_RawDisk_ReadShortLength_ReturnsCorrectData() + { + var sectorSize = _writableRawDisk.SectorSize; + + // Write known pattern at sector 0 + var writeBuffer = new byte[sectorSize]; + for (int i = 0; i < sectorSize; i++) + writeBuffer[i] = (byte)(i & 0xFF); + + await _writableRawDisk.WriteSectorsAsync(0, writeBuffer, CancellationToken.None); + + // Read short length + var length = sectorSize - 1; + using var stream = await _writableRawDisk.ReadBytesAsync(0, length, CancellationToken.None); + var readBuffer = new byte[length]; + var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(length, bytesRead, "Should have read the requested short length."); + for (int i = 0; i < length; i++) + Assert.AreEqual((byte)(i & 0xFF), readBuffer[i], $"Byte at position {i} should match."); + } + + [Test] + public async Task Test_RawDisk_ReadStraddlingSectors_ReturnsCorrectData() + { + var sectorSize = _writableRawDisk.SectorSize; + + // Write different patterns at sectors 0 and 1 + var sector0Data = new byte[sectorSize]; + var sector1Data = new byte[sectorSize]; + for (int i = 0; i < sectorSize; i++) + { + sector0Data[i] = (byte)(i | 0xF0); + sector1Data[i] = (byte)(i | 0x0F); + } + + await _writableRawDisk.WriteSectorsAsync(0, sector0Data, CancellationToken.None); + await _writableRawDisk.WriteSectorsAsync(1, sector1Data, CancellationToken.None); - #endregion + // Read at half-sector offset with full sector length + var offset = sectorSize / 2; + using var stream = await _writableRawDisk.ReadBytesAsync(offset, sectorSize, CancellationToken.None); + var readBuffer = new byte[sectorSize]; + var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - #region PartitionWriteStream tests + Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector."); - [Test] - public async Task Test_PartitionWriteStream_WriteAndDispose_DataFlushedToDisk() + // First half should be from end of sector 0 + for (int i = 0; i < sectorSize / 2; i++) { - var sectorSize = _writableRawDisk.SectorSize; - var partitionOffset = 10 * sectorSize; - var partitionSize = 4 * sectorSize; - - // Write data using PartitionWriteStream - var writeData = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 }; - using (var stream = new PartitionWriteStream(_writableRawDisk, partitionOffset, partitionSize)) - await stream.WriteAsync(writeData, CancellationToken.None); - - // Read back and verify data was flushed to disk - using var readStream = await _writableRawDisk.ReadBytesAsync(partitionOffset, writeData.Length, CancellationToken.None); - var readBuffer = new byte[writeData.Length]; - var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(writeData.Length, bytesRead, "Should have read the written data."); - Assert.AreEqual(writeData, readBuffer, "Data should match what was written."); + var expected = (byte)((sectorSize / 2 + i) | 0xF0); + Assert.AreEqual(expected, readBuffer[i], $"Byte at position {i} should match sector 0 data."); } - [Test] - public void Test_PartitionWriteStream_SeekBegin_ValidPosition() + // Second half should be from start of sector 1 + for (int i = sectorSize / 2; i < sectorSize; i++) { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 4 * sectorSize; - - using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize); - - // Test Seek with SeekOrigin.Begin - var newPosition = stream.Seek(100, SeekOrigin.Begin); - Assert.AreEqual(100, newPosition, "Position should be 100 after seeking from Begin."); - Assert.AreEqual(100, stream.Position, "Stream.Position should match returned position."); + var expected = (byte)((i - sectorSize / 2) | 0x0F); + Assert.AreEqual(expected, readBuffer[i], $"Byte at position {i} should match sector 1 data."); } + } - [Test] - public void Test_PartitionWriteStream_SeekCurrent_ValidPosition() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 4 * sectorSize; - - using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize); - - // Write some data first to move position - stream.Write([0x01, 0x02, 0x03], 0, 3); - Assert.AreEqual(3, stream.Position, "Position should be 3 after writing 3 bytes."); + [Test] + public async Task Test_RawDisk_ReadNearEndOfDisk_ReturnsCorrectData() + { + var sectorSize = _writableRawDisk.SectorSize; + var diskSize = _writableRawDisk.Size; - // Test Seek with SeekOrigin.Current - var newPosition = stream.Seek(50, SeekOrigin.Current); - Assert.AreEqual(53, newPosition, "Position should be 53 after seeking 50 from current position 3."); - Assert.AreEqual(53, stream.Position, "Stream.Position should match returned position."); - } + // Calculate the last sector and write data there + var lastSector = (diskSize / sectorSize) - 1; + var writeBuffer = new byte[sectorSize]; + new Random().NextBytes(writeBuffer); - [Test] - public void Test_PartitionWriteStream_SeekEnd_ValidPosition() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 4 * sectorSize; + await _writableRawDisk.WriteSectorsAsync(lastSector, writeBuffer, CancellationToken.None); - using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize); + // Read from near the end + var offset = diskSize - sectorSize; + using var stream = await _writableRawDisk.ReadBytesAsync(offset, sectorSize, CancellationToken.None); + var readBuffer = new byte[sectorSize]; + var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - // Write some data first to establish length - stream.Write([0x01, 0x02, 0x03, 0x04, 0x05], 0, 5); - Assert.AreEqual(5, stream.Length, "Length should be 5 after writing 5 bytes."); + Assert.AreEqual(sectorSize, bytesRead, "Should have read a full sector."); + Assert.AreEqual(writeBuffer, readBuffer, "Data should match what was written."); + } - // Test Seek with SeekOrigin.End - var newPosition = stream.Seek(-2, SeekOrigin.End); - Assert.AreEqual(3, newPosition, "Position should be 3 after seeking -2 from end of length 5."); - Assert.AreEqual(3, stream.Position, "Stream.Position should match returned position."); - } + [Test] + public async Task Test_RawDisk_ReadUnalignedOffsetWithMemory_ReturnsCorrectData() + { + var sectorSize = _writableRawDisk.SectorSize; - [Test] - public void Test_PartitionWriteStream_WriteBeyondSize_ThrowsIOException() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionOffset = 10 * sectorSize; - var partitionSize = 2 * sectorSize; + // Write known pattern at sector 1 + var writeBuffer = new byte[sectorSize]; + for (int i = 0; i < sectorSize; i++) + writeBuffer[i] = (byte)((i * 2) & 0xFF); - using var stream = new PartitionWriteStream(_writableRawDisk, partitionOffset, partitionSize); + await _writableRawDisk.WriteSectorsAsync(1, writeBuffer, CancellationToken.None); - // Try to write more than partition size - var largeBuffer = new byte[partitionSize + 100]; - new Random().NextBytes(largeBuffer); + // Read at unaligned offset + var offset = sectorSize + 4; + var length = sectorSize - 8; + var readBuffer = new byte[length]; + var bytesRead = await _writableRawDisk.ReadBytesAsync(offset, readBuffer.AsMemory(), CancellationToken.None); - var ex = Assert.Throws(() => - { - stream.Write(largeBuffer, 0, largeBuffer.Length); - }, "Writing beyond partition size should throw IOException."); + Assert.AreEqual(length, bytesRead, "Should have read the requested length."); + for (int i = 0; i < length; i++) + Assert.AreEqual((byte)(((i + 4) * 2) & 0xFF), readBuffer[i], $"Byte at position {i} should match."); + } - StringAssert.Contains("partition size", ex!.Message, "Exception message should mention partition size."); - } + [Test] + public async Task Test_RawDisk_ReadUnalignedLengthWithMemory_ReturnsCorrectData() + { + var sectorSize = _writableRawDisk.SectorSize; - [Test] - public void Test_PartitionWriteStream_SeekBeyondSize_ThrowsIOException() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 2 * sectorSize; + // Write known pattern at sector 0 + var writeBuffer = new byte[sectorSize]; + for (int i = 0; i < sectorSize; i++) + writeBuffer[i] = (byte)((i + 100) & 0xFF); - using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize); + await _writableRawDisk.WriteSectorsAsync(0, writeBuffer, CancellationToken.None); - // Try to seek beyond partition size - var ex = Assert.Throws(() => - { - stream.Seek(partitionSize + 100, SeekOrigin.Begin); - }, "Seeking beyond partition size should throw IOException."); + // Read with unaligned length + var length = sectorSize - 5; + var readBuffer = new byte[length]; + var bytesRead = await _writableRawDisk.ReadBytesAsync(0, readBuffer.AsMemory(), CancellationToken.None); - StringAssert.Contains("partition size", ex!.Message, "Exception message should mention partition size."); - } + Assert.AreEqual(length, bytesRead, "Should have read the requested length."); + for (int i = 0; i < length; i++) + Assert.AreEqual((byte)((i + 100) & 0xFF), readBuffer[i], $"Byte at position {i} should match."); + } - [Test] - public void Test_PartitionWriteStream_SeekNegativePosition_ThrowsIOException() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 2 * sectorSize; + #endregion - using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize); + #region IRawDisk Unaligned Write Tests - // Try to seek to negative position - Assert.Throws(() => - { - stream.Seek(-100, SeekOrigin.Begin); - }, "Seeking to negative position should throw IOException."); - } + [Test] + public async Task Test_RawDisk_WriteUnalignedOffset_DataMatches() + { + var sectorSize = _writableRawDisk.SectorSize; - [Test] - public async Task Test_PartitionWriteStream_WriteAtUnalignedPosition_DataMatches() + // First, write known patterns to two consecutive sectors + var sector1Data = new byte[sectorSize]; + var sector2Data = new byte[sectorSize]; + for (int i = 0; i < sectorSize; i++) { - var sectorSize = _writableRawDisk.SectorSize; - var partitionOffset = 15 * sectorSize; - var partitionSize = 4 * sectorSize; - - // Write data at various unaligned positions - using (var stream = new PartitionWriteStream(_writableRawDisk, partitionOffset, partitionSize)) - { - // Write at position 0 - stream.Write([0xAA, 0xBB], 0, 2); - - // Seek to unaligned position - stream.Seek(7, SeekOrigin.Begin); - stream.Write([0xCC, 0xDD, 0xEE], 0, 3); - - // Seek to another unaligned position - stream.Seek(20, SeekOrigin.Begin); - stream.Write([0x11, 0x22, 0x33, 0x44], 0, 4); - } - - // Read back and verify - using var readStream = await _writableRawDisk.ReadBytesAsync(partitionOffset, 25, CancellationToken.None); - var readBuffer = new byte[25]; - var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(25, bytesRead, "Should read exactly 25 bytes."); - - // Verify data at position 0 - Assert.AreEqual(0xAA, readBuffer[0], "Byte at position 0 should be 0xAA."); - Assert.AreEqual(0xBB, readBuffer[1], "Byte at position 1 should be 0xBB."); - - // Verify data at position 7 - Assert.AreEqual(0xCC, readBuffer[7], "Byte at position 7 should be 0xCC."); - Assert.AreEqual(0xDD, readBuffer[8], "Byte at position 8 should be 0xDD."); - Assert.AreEqual(0xEE, readBuffer[9], "Byte at position 9 should be 0xEE."); - - // Verify data at position 20 - Assert.AreEqual(0x11, readBuffer[20], "Byte at position 20 should be 0x11."); - Assert.AreEqual(0x22, readBuffer[21], "Byte at position 21 should be 0x22."); - Assert.AreEqual(0x33, readBuffer[22], "Byte at position 22 should be 0x33."); - Assert.AreEqual(0x44, readBuffer[23], "Byte at position 23 should be 0x44."); + sector1Data[i] = (byte)(i | 0xA0); + sector2Data[i] = (byte)(i | 0xB0); } - [Test] - public async Task Test_PartitionWriteStream_WriteMultipleTimes_DataMatches() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionOffset = 20 * sectorSize; - var partitionSize = 3 * sectorSize; - - // Write data in multiple operations - using (var stream = new PartitionWriteStream(_writableRawDisk, partitionOffset, partitionSize)) - { - // First write - stream.Write([0x01, 0x02, 0x03], 0, 3); + await _writableRawDisk.WriteSectorsAsync(1, sector1Data, CancellationToken.None); + await _writableRawDisk.WriteSectorsAsync(2, sector2Data, CancellationToken.None); - // Second write (should append) - stream.Write([0x04, 0x05], 0, 2); + // Now write at unaligned offset (sector_size + 5) + var unalignedOffset = sectorSize + 5; + var writeData = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 }; + var bytesWritten = await _writableRawDisk.WriteBytesAsync(unalignedOffset, writeData, CancellationToken.None); - // Third write - stream.Write([0x06, 0x07, 0x08, 0x09], 0, 4); - } + Assert.AreEqual(writeData.Length, bytesWritten, "Should have written all bytes."); - // Read back and verify all data was written - using var readStream = await _writableRawDisk.ReadBytesAsync(partitionOffset, 9, CancellationToken.None); - var readBuffer = new byte[9]; - var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + // Read back both sectors and verify only the intended bytes were changed + using var readStream = await _writableRawDisk.ReadSectorsAsync(1, 2, CancellationToken.None); + var readBuffer = new byte[sectorSize * 2]; + var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(sectorSize * 2, bytesRead, "Should have read two sectors."); - Assert.AreEqual(9, bytesRead, "Should read exactly 9 bytes."); + // Verify sector 1 data + for (int i = 0; i < 5; i++) + Assert.AreEqual((byte)(i | 0xA0), readBuffer[i], $"Byte at position {i} in sector 1 should be unchanged."); + for (int i = 0; i < writeData.Length; i++) + Assert.AreEqual(writeData[i], readBuffer[5 + i], $"Written byte at position {5 + i} should match."); + for (int i = 5 + writeData.Length; i < sectorSize; i++) + Assert.AreEqual((byte)(i | 0xA0), readBuffer[i], $"Byte at position {i} in sector 1 should be unchanged."); - var expected = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09 }; - Assert.AreEqual(expected, readBuffer, "All written data should match."); - } + // Verify sector 2 data (should be completely unchanged) + for (int i = 0; i < sectorSize; i++) + Assert.AreEqual((byte)(i | 0xB0), readBuffer[sectorSize + i], $"Byte at position {i} in sector 2 should be unchanged."); + } - [Test] - public void Test_PartitionWriteStream_SetLengthBeyondMaxSize_ThrowsIOException() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 2 * sectorSize; + [Test] + public async Task Test_RawDisk_WriteUnalignedLength_DataMatches() + { + var sectorSize = _writableRawDisk.SectorSize; - using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize); + // Write known pattern to sector 3 + var sectorData = new byte[sectorSize]; + for (int i = 0; i < sectorSize; i++) + sectorData[i] = (byte)(i | 0xC0); - // Try to set length beyond partition size - var ex = Assert.Throws(() => - { - stream.SetLength(partitionSize + 100); - }, "Setting length beyond partition size should throw IOException."); + await _writableRawDisk.WriteSectorsAsync(3, sectorData, CancellationToken.None); - StringAssert.Contains("partition size", ex!.Message, "Exception message should mention partition size."); - } + // Write unaligned length (sectorSize - 10) + var writeLength = sectorSize - 10; + var writeData = new byte[writeLength]; + for (int i = 0; i < writeLength; i++) + writeData[i] = (byte)((i + 50) & 0xFF); - [Test] - public async Task Test_PartitionWriteStream_SetLengthWithinMaxSize_SetsLength() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionOffset = 10 * sectorSize; - var partitionSize = 2 * sectorSize; - - using (var stream = new PartitionWriteStream(_writableRawDisk, partitionOffset, partitionSize)) - { - // Set length to a specific value within bounds - stream.SetLength(100); - Assert.AreEqual(100, stream.Length, "Length should be set to 100."); - - // Write some data at position 0 - stream.Write([0xAA, 0xBB, 0xCC], 0, 3); - // Length should be max(SetLength(100), written position) = 100 - Assert.AreEqual(100, stream.Length, "Length should remain 100 (SetLength value)."); - } - - // Verify the 3 bytes were written at position 0 and length 100 bytes are flushed - using var readStream = await _writableRawDisk.ReadBytesAsync(partitionOffset, 100, CancellationToken.None); - var readBuffer = new byte[100]; - var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(100, bytesRead, "Should read exactly 100 bytes."); - - Assert.AreEqual(0xAA, readBuffer[0], "Byte at position 0 should be 0xAA."); - Assert.AreEqual(0xBB, readBuffer[1], "Byte at position 1 should be 0xBB."); - Assert.AreEqual(0xCC, readBuffer[2], "Byte at position 2 should be 0xCC."); - } + var bytesWritten = await _writableRawDisk.WriteBytesAsync(3 * sectorSize, writeData, CancellationToken.None); - [Test] - public void Test_PartitionWriteStream_CanRead_ReturnsFalse() - { - var sectorSize = _writableRawDisk.SectorSize; - using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, sectorSize); + Assert.AreEqual(writeData.Length, bytesWritten, "Should have written all bytes."); - Assert.IsFalse(stream.CanRead, "CanRead should be false."); - } + // Read back and verify + using var readStream = await _writableRawDisk.ReadSectorsAsync(3, 1, CancellationToken.None); + var readBuffer = new byte[sectorSize]; + var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - [Test] - public void Test_PartitionWriteStream_CanWrite_ReturnsTrue() - { - var sectorSize = _writableRawDisk.SectorSize; - using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, sectorSize); + Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector."); - Assert.IsTrue(stream.CanWrite, "CanWrite should be true."); - } + // First part should be our written data + for (int i = 0; i < writeLength; i++) + Assert.AreEqual((byte)((i + 50) & 0xFF), readBuffer[i], $"Byte at position {i} should match written data."); - [Test] - public void Test_PartitionWriteStream_CanSeek_ReturnsTrue() - { - var sectorSize = _writableRawDisk.SectorSize; - using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, sectorSize); + // Remaining bytes should be unchanged + for (int i = writeLength; i < sectorSize; i++) + Assert.AreEqual((byte)(i | 0xC0), readBuffer[i], $"Byte at position {i} should be unchanged."); + } - Assert.IsTrue(stream.CanSeek, "CanSeek should be true."); - } + [Test] + public async Task Test_RawDisk_WriteSingleByte_VerifySingleByteChanged() + { + var sectorSize = _writableRawDisk.SectorSize; - [Test] - public void Test_PartitionWriteStream_Read_ThrowsNotSupported() - { - var sectorSize = _writableRawDisk.SectorSize; - using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, sectorSize); + // Write known pattern to sector 5 + var sectorData = new byte[sectorSize]; + for (int i = 0; i < sectorSize; i++) + sectorData[i] = (byte)(i & 0xFF); - Assert.Throws(() => - { - var _ = stream.Read(new byte[10], 0, 10); - }, "Read should throw NotSupportedException."); - } + await _writableRawDisk.WriteSectorsAsync(5, sectorData, CancellationToken.None); - [Test] - public async Task Test_PartitionWriteStream_WriteEmptyData_NoFlush() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionOffset = 10 * sectorSize; - var partitionSize = sectorSize; - - // Read initial data at this offset (may not be zero due to previous tests) - using var initialReadStream = await _writableRawDisk.ReadBytesAsync(partitionOffset, 10, CancellationToken.None); - var initialBuffer = new byte[10]; - var initialBytesRead = await initialReadStream.ReadAsync(initialBuffer.AsMemory(), CancellationToken.None); - Assert.AreEqual(10, initialBytesRead, "Should read 10 bytes."); - - // Create stream but write nothing - using (var stream = new PartitionWriteStream(_writableRawDisk, partitionOffset, partitionSize)) - // Don't write anything - Assert.AreEqual(0, stream.Length, "Length should be 0 when nothing is written."); - - // Read back and verify no data was flushed (should be unchanged) - using var readStream = await _writableRawDisk.ReadBytesAsync(partitionOffset, 10, CancellationToken.None); - var readBuffer = new byte[10]; - var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(10, bytesRead, "Should read 10 bytes."); - - // All bytes should be unchanged from before the stream was created - for (int i = 0; i < readBuffer.Length; i++) - { - Assert.AreEqual(initialBuffer[i], readBuffer[i], $"Byte at position {i} should be unchanged (no data written)."); - } - } + // Write a single byte at offset 5 * sectorSize + 100 + var byteOffset = 5 * sectorSize + 100; + var singleByte = new byte[] { 0xAB }; + var bytesWritten = await _writableRawDisk.WriteBytesAsync(byteOffset, singleByte, CancellationToken.None); - #endregion + Assert.AreEqual(1, bytesWritten, "Should have written 1 byte."); - #region PartitionTableSynthesizer tests + // Read back the full sector + using var readStream = await _writableRawDisk.ReadSectorsAsync(5, 1, CancellationToken.None); + var readBuffer = new byte[sectorSize]; + var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - [Test] - public void Test_PartitionTableSynthesizer_SynthesizeMBR_ContainsValidBootSignature() - { - // Create a GeometryMetadata with known partition info - var metadata = new GeometryMetadata - { - Disk = new DiskGeometry - { - SectorSize = 512, - Size = 100 * MiB, - Sectors = (int)(100 * MiB / 512) - }, - PartitionTable = new PartitionTableGeometry - { - Type = PartitionTableType.MBR, - SectorSize = 512 - }, - Partitions = - [ - new PartitionGeometry - { - Number = 1, - Type = PartitionType.Primary, - FilesystemType = FileSystemType.FAT32, - StartOffset = 512 * 2048, // Start at sector 2048 - Size = 20 * MiB, - TableType = PartitionTableType.MBR - }, - new PartitionGeometry - { - Number = 2, - Type = PartitionType.Primary, - FilesystemType = FileSystemType.FAT32, - StartOffset = 512 * 2048 + 20 * MiB, - Size = 20 * MiB, - TableType = PartitionTableType.MBR - } - ] - }; - - // Synthesize MBR - var mbrData = PartitionTableSynthesizer.SynthesizeMBR(metadata); - - Assert.IsNotNull(mbrData, "MBR data should not be null."); - Assert.GreaterOrEqual(mbrData.Length, 512, "MBR should be at least one sector."); - - // Verify boot signature at offset 510-511 (0x55 0xAA) - Assert.AreEqual(0x55, mbrData[510], "Boot signature first byte should be 0x55."); - Assert.AreEqual(0xAA, mbrData[511], "Boot signature second byte should be 0xAA."); - } + Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector."); - [Test] - public void Test_PartitionTableSynthesizer_SynthesizeGPT_ContainsValidSignature() - { - // Create a GeometryMetadata with known partition info - var metadata = new GeometryMetadata - { - Disk = new DiskGeometry - { - SectorSize = 512, - Size = 100 * MiB, - Sectors = (int)(100 * MiB / 512) - }, - PartitionTable = new PartitionTableGeometry - { - Type = PartitionTableType.GPT, - SectorSize = 512, - HasProtectiveMbr = true - }, - Partitions = - [ - new PartitionGeometry - { - Number = 1, - Type = PartitionType.Primary, - FilesystemType = FileSystemType.FAT32, - StartOffset = 512 * 2048, // Start at sector 2048 - Size = 20 * MiB, - TableType = PartitionTableType.GPT, - Name = "Partition 1" - }, - new PartitionGeometry - { - Number = 2, - Type = PartitionType.Primary, - FilesystemType = FileSystemType.FAT32, - StartOffset = 512 * 2048 + 20 * MiB, - Size = 20 * MiB, - TableType = PartitionTableType.GPT, - Name = "Partition 2" - } - ] - }; - - // Synthesize GPT - var gptData = PartitionTableSynthesizer.SynthesizeGPT(metadata); - - Assert.IsNotNull(gptData, "GPT data should not be null."); - Assert.Greater(gptData.Length, 512 * 2, "GPT data should include protective MBR and GPT header."); - - // Verify protective MBR boot signature at offset 510-511 - Assert.AreEqual(0x55, gptData[510], "Protective MBR boot signature first byte should be 0x55."); - Assert.AreEqual(0xAA, gptData[511], "Protective MBR boot signature second byte should be 0xAA."); - - // Verify GPT signature "EFI PART" at sector 1 (offset 512) - var expectedSignature = "EFI PART"u8.ToArray(); - for (int i = 0; i < 8; i++) - Assert.AreEqual(expectedSignature[i], gptData[512 + i], $"GPT signature byte {i} should match."); - } - - [Test] - public async Task Test_PartitionTableSynthesizer_MBRRoundTrip_PreservesPartitionData() - { - // Create a GeometryMetadata with known partition info - var sectorSize = 512; - var metadata = new GeometryMetadata - { - Disk = new DiskGeometry - { - SectorSize = sectorSize, - Size = 100 * MiB, - Sectors = (int)(100 * MiB / sectorSize) - }, - PartitionTable = new PartitionTableGeometry - { - Type = PartitionTableType.MBR, - SectorSize = sectorSize - }, - Partitions = - [ - new PartitionGeometry - { - Number = 1, - Type = PartitionType.Primary, - FilesystemType = FileSystemType.FAT32, - StartOffset = sectorSize * 2048, - Size = 20 * MiB, - TableType = PartitionTableType.MBR - }, - new PartitionGeometry - { - Number = 2, - Type = PartitionType.Primary, - FilesystemType = FileSystemType.FAT32, - StartOffset = sectorSize * 2048 + 20 * MiB, - Size = 20 * MiB, - TableType = PartitionTableType.MBR - } - ] - }; - - // Synthesize MBR - var mbrData = PartitionTableSynthesizer.SynthesizeMBR(metadata); - - // Parse with PartitionTableFactory - var partitionTable = await PartitionTableFactory.CreateAsync(mbrData, sectorSize, CancellationToken.None); - - Assert.IsNotNull(partitionTable, "Partition table should be detected."); - Assert.AreEqual(PartitionTableType.MBR, partitionTable!.TableType, "Should detect MBR partition table."); - - // Verify partition count - var partitions = new List(); - await foreach (var partition in partitionTable.EnumeratePartitions(CancellationToken.None)) - partitions.Add(partition); - - Assert.AreEqual(2, partitions.Count, "Should have 2 partitions."); - - // Verify partition properties match original metadata - for (int i = 0; i < partitions.Count; i++) - { - var originalPart = metadata.Partitions![i]; - var parsedPart = partitions[i]; - - Assert.AreEqual(originalPart.Number, parsedPart.PartitionNumber, $"Partition {i + 1} number should match."); - Assert.AreEqual(originalPart.StartOffset, parsedPart.StartOffset, $"Partition {i + 1} start offset should match."); - Assert.AreEqual(originalPart.Size, parsedPart.Size, $"Partition {i + 1} size should match."); - } - - partitionTable.Dispose(); - } - - [Test] - public async Task Test_PartitionTableSynthesizer_GPTRoundTrip_PreservesPartitionData() - { - // Create a GeometryMetadata with known partition info - var sectorSize = 512; - var metadata = new GeometryMetadata - { - Disk = new DiskGeometry - { - SectorSize = sectorSize, - Size = 100 * MiB, - Sectors = (int)(100 * MiB / sectorSize) - }, - PartitionTable = new PartitionTableGeometry - { - Type = PartitionTableType.GPT, - SectorSize = sectorSize, - HasProtectiveMbr = true - }, - Partitions = - [ - new PartitionGeometry - { - Number = 1, - Type = PartitionType.Primary, - FilesystemType = FileSystemType.FAT32, - StartOffset = sectorSize * 2048, - Size = 20 * MiB, - TableType = PartitionTableType.GPT, - Name = "Test Partition 1" - }, - new PartitionGeometry - { - Number = 2, - Type = PartitionType.Primary, - FilesystemType = FileSystemType.FAT32, - StartOffset = sectorSize * 2048 + 20 * MiB, - Size = 20 * MiB, - TableType = PartitionTableType.GPT, - Name = "Test Partition 2" - } - ] - }; - - // Synthesize GPT - var gptData = PartitionTableSynthesizer.SynthesizeGPT(metadata); - - // Parse with PartitionTableFactory - var partitionTable = await PartitionTableFactory.CreateAsync(gptData, sectorSize, CancellationToken.None); - - Assert.IsNotNull(partitionTable, "Partition table should be detected."); - Assert.AreEqual(PartitionTableType.GPT, partitionTable!.TableType, "Should detect GPT partition table."); - - // Verify partition count - var partitions = new List(); - await foreach (var partition in partitionTable.EnumeratePartitions(CancellationToken.None)) - partitions.Add(partition); - - Assert.AreEqual(2, partitions.Count, "Should have 2 partitions."); - - // Verify partition properties match original metadata - for (int i = 0; i < partitions.Count; i++) - { - var originalPart = metadata.Partitions![i]; - var parsedPart = partitions[i]; - - Assert.AreEqual(originalPart.Number, parsedPart.PartitionNumber, $"Partition {i + 1} number should match."); - Assert.AreEqual(originalPart.StartOffset, parsedPart.StartOffset, $"Partition {i + 1} start offset should match."); - Assert.AreEqual(originalPart.Size, parsedPart.Size, $"Partition {i + 1} size should match."); - } - - partitionTable.Dispose(); - } - - [Test] - public void Test_PartitionTableSynthesizer_SynthesizePartitionTable_MBR_ReturnsMBRData() - { - var metadata = new GeometryMetadata - { - Disk = new DiskGeometry { SectorSize = 512, Size = 50 * MiB }, - PartitionTable = new PartitionTableGeometry { Type = PartitionTableType.MBR }, - Partitions = - [ - new PartitionGeometry - { - Number = 1, - Type = PartitionType.Primary, - StartOffset = 512 * 2048, - Size = 20 * MiB, - TableType = PartitionTableType.MBR - } - ] - }; - - var result = PartitionTableSynthesizer.SynthesizePartitionTable(metadata); - - Assert.IsNotNull(result, "Should return MBR data."); - Assert.AreEqual(0x55, result![510], "Should have valid boot signature."); - Assert.AreEqual(0xAA, result[511], "Should have valid boot signature."); - } - - [Test] - public void Test_PartitionTableSynthesizer_SynthesizePartitionTable_GPT_ReturnsGPTData() - { - var metadata = new GeometryMetadata - { - Disk = new DiskGeometry { SectorSize = 512, Size = 50 * MiB, Sectors = (int)(50 * MiB / 512) }, - PartitionTable = new PartitionTableGeometry { Type = PartitionTableType.GPT }, - Partitions = - [ - new PartitionGeometry - { - Number = 1, - Type = PartitionType.Primary, - StartOffset = 512 * 2048, - Size = 20 * MiB, - TableType = PartitionTableType.GPT - } - ] - }; - - var result = PartitionTableSynthesizer.SynthesizePartitionTable(metadata); - - Assert.IsNotNull(result, "Should return GPT data."); - // Check for "EFI PART" signature at sector 1 - var expectedSignature = "EFI PART"u8.ToArray(); - for (int i = 0; i < 8; i++) - Assert.AreEqual(expectedSignature[i], result![512 + i], $"GPT signature byte {i} should match."); - } - - [Test] - public void Test_PartitionTableSynthesizer_SynthesizePartitionTable_NullPartitionTable_ReturnsNull() - { - var metadata = new GeometryMetadata - { - Disk = new DiskGeometry { SectorSize = 512 }, - PartitionTable = null - }; - - var result = PartitionTableSynthesizer.SynthesizePartitionTable(metadata); - - Assert.IsNull(result, "Should return null when PartitionTable is null."); - } - - [Test] - public void Test_PartitionTableSynthesizer_SynthesizePartitionTable_UnknownType_ReturnsNull() - { - var metadata = new GeometryMetadata - { - Disk = new DiskGeometry { SectorSize = 512 }, - PartitionTable = new PartitionTableGeometry { Type = PartitionTableType.Unknown } - }; - - var result = PartitionTableSynthesizer.SynthesizePartitionTable(metadata); - - Assert.IsNull(result, "Should return null for Unknown partition table type."); - } - - #endregion - - #region GeometryMetadata Serialization Tests - - /// - /// Tests that a fully populated GeometryMetadata can be serialized to JSON and - /// deserialized back, preserving all field values. - /// - [Test] - public void Test_GeometryMetadata_RoundTrip_PreservesAllFields() - { - // Create a GeometryMetadata with all fields populated - var original = new GeometryMetadata - { - Version = 1, - Disk = new DiskGeometry - { - DevicePath = "/dev/sda", - Size = 100 * MiB, - SectorSize = 512, - Sectors = (int)(100 * MiB / 512), - TableType = PartitionTableType.GPT - }, - PartitionTable = new PartitionTableGeometry - { - Type = PartitionTableType.GPT, - Size = 17408, // Typical GPT size - SectorSize = 512, - HasProtectiveMbr = true, - HeaderSize = 92 - }, - Partitions = - [ - new PartitionGeometry - { - Number = 1, - Type = PartitionType.Primary, - StartOffset = 512 * 2048, - Size = 40 * MiB, - Name = "Test Partition 1", - FilesystemType = FileSystemType.FAT32, - VolumeGuid = Guid.Parse("12345678-1234-1234-1234-123456789abc"), - TableType = PartitionTableType.GPT - }, - new PartitionGeometry - { - Number = 2, - Type = PartitionType.Primary, - StartOffset = 512 * 2048 + 40 * MiB, - Size = 40 * MiB, - Name = "Test Partition 2", - FilesystemType = FileSystemType.NTFS, - VolumeGuid = Guid.Parse("abcdef12-3456-7890-abcd-ef1234567890"), - TableType = PartitionTableType.GPT - } - ], - Filesystems = - [ - new FilesystemGeometry - { - PartitionNumber = 1, - Type = FileSystemType.FAT32, - PartitionStartOffset = 512 * 2048, - BlockSize = 4096, - Metadata = "{\"label\":\"FAT32_VOL\",\"serial\":\"1234-5678\"}" - }, - new FilesystemGeometry - { - PartitionNumber = 2, - Type = FileSystemType.NTFS, - PartitionStartOffset = 512 * 2048 + 40 * MiB, - BlockSize = 4096, - Metadata = "{\"label\":\"NTFS_VOL\",\"guid\":\"{12345678-1234-1234-1234-123456789ABC}\"}" - } - ] - }; - - // Serialize to JSON - var json = original.ToJson(); - - // Verify JSON is not null or empty - Assert.IsNotNull(json, "JSON output should not be null."); - Assert.IsNotEmpty(json, "JSON output should not be empty."); - - // Deserialize from JSON - var deserialized = GeometryMetadata.FromJson(json); - - // Verify deserialized object is not null - Assert.IsNotNull(deserialized, "Deserialized object should not be null."); - - // Verify Version - Assert.AreEqual(original.Version, deserialized!.Version, "Version should match."); - - // Verify Disk properties - Assert.IsNotNull(deserialized.Disk, "Disk should not be null after deserialization."); - Assert.AreEqual(original.Disk!.DevicePath, deserialized.Disk!.DevicePath, "Disk DevicePath should match."); - Assert.AreEqual(original.Disk.Size, deserialized.Disk.Size, "Disk Size should match."); - Assert.AreEqual(original.Disk.SectorSize, deserialized.Disk.SectorSize, "Disk SectorSize should match."); - Assert.AreEqual(original.Disk.Sectors, deserialized.Disk.Sectors, "Disk Sectors should match."); - Assert.AreEqual(original.Disk.TableType, deserialized.Disk.TableType, "Disk TableType should match."); - - // Verify PartitionTable properties - Assert.IsNotNull(deserialized.PartitionTable, "PartitionTable should not be null after deserialization."); - Assert.AreEqual(original.PartitionTable!.Type, deserialized.PartitionTable!.Type, "PartitionTable Type should match."); - Assert.AreEqual(original.PartitionTable.Size, deserialized.PartitionTable.Size, "PartitionTable Size should match."); - Assert.AreEqual(original.PartitionTable.SectorSize, deserialized.PartitionTable.SectorSize, "PartitionTable SectorSize should match."); - Assert.AreEqual(original.PartitionTable.HasProtectiveMbr, deserialized.PartitionTable.HasProtectiveMbr, "PartitionTable HasProtectiveMbr should match."); - Assert.AreEqual(original.PartitionTable.HeaderSize, deserialized.PartitionTable.HeaderSize, "PartitionTable HeaderSize should match."); - - // Verify Partitions - Assert.IsNotNull(deserialized.Partitions, "Partitions should not be null after deserialization."); - Assert.AreEqual(original.Partitions!.Count, deserialized.Partitions!.Count, "Partition count should match."); - - for (int i = 0; i < original.Partitions.Count; i++) - { - var originalPart = original.Partitions[i]; - var deserializedPart = deserialized.Partitions[i]; - - Assert.AreEqual(originalPart.Number, deserializedPart.Number, $"Partition {i + 1} Number should match."); - Assert.AreEqual(originalPart.Type, deserializedPart.Type, $"Partition {i + 1} Type should match."); - Assert.AreEqual(originalPart.StartOffset, deserializedPart.StartOffset, $"Partition {i + 1} StartOffset should match."); - Assert.AreEqual(originalPart.Size, deserializedPart.Size, $"Partition {i + 1} Size should match."); - Assert.AreEqual(originalPart.Name, deserializedPart.Name, $"Partition {i + 1} Name should match."); - Assert.AreEqual(originalPart.FilesystemType, deserializedPart.FilesystemType, $"Partition {i + 1} FilesystemType should match."); - Assert.AreEqual(originalPart.VolumeGuid, deserializedPart.VolumeGuid, $"Partition {i + 1} VolumeGuid should match."); - Assert.AreEqual(originalPart.TableType, deserializedPart.TableType, $"Partition {i + 1} TableType should match."); - } - - // Verify Filesystems - Assert.IsNotNull(deserialized.Filesystems, "Filesystems should not be null after deserialization."); - Assert.AreEqual(original.Filesystems!.Count, deserialized.Filesystems!.Count, "Filesystem count should match."); - - for (int i = 0; i < original.Filesystems.Count; i++) - { - var originalFs = original.Filesystems[i]; - var deserializedFs = deserialized.Filesystems[i]; - - Assert.AreEqual(originalFs.PartitionNumber, deserializedFs.PartitionNumber, $"Filesystem {i + 1} PartitionNumber should match."); - Assert.AreEqual(originalFs.Type, deserializedFs.Type, $"Filesystem {i + 1} Type should match."); - Assert.AreEqual(originalFs.PartitionStartOffset, deserializedFs.PartitionStartOffset, $"Filesystem {i + 1} PartitionStartOffset should match."); - Assert.AreEqual(originalFs.BlockSize, deserializedFs.BlockSize, $"Filesystem {i + 1} BlockSize should match."); - Assert.AreEqual(originalFs.Metadata, deserializedFs.Metadata, $"Filesystem {i + 1} Metadata should match."); - } - } - - /// - /// Tests that a GeometryMetadata with null optional fields can be serialized - /// and deserialized correctly. - /// - [Test] - public void Test_GeometryMetadata_RoundTrip_NullOptionalFields_PreservesStructure() - { - // Create a GeometryMetadata with minimal/null optional fields - var original = new GeometryMetadata - { - Version = 1, - Disk = new DiskGeometry - { - DevicePath = null, // Null optional field - Size = 50 * MiB, - SectorSize = 512, - Sectors = (int)(50 * MiB / 512), - TableType = PartitionTableType.MBR - }, - PartitionTable = new PartitionTableGeometry - { - Type = PartitionTableType.MBR, - Size = 512, - SectorSize = 512, - HasProtectiveMbr = false, - HeaderSize = 0, - MbrSize = 512 - }, - Partitions = - [ - new PartitionGeometry - { - Number = 1, - Type = PartitionType.Primary, - StartOffset = 512 * 2048, - Size = 40 * MiB, - Name = null, // Null optional field - FilesystemType = FileSystemType.Unknown, - VolumeGuid = null, // Null optional field - TableType = PartitionTableType.MBR - } - ], - Filesystems = null // Null optional collection - }; - - // Serialize to JSON - var json = original.ToJson(); - - // Verify JSON is not null or empty - Assert.IsNotNull(json, "JSON output should not be null."); - Assert.IsNotEmpty(json, "JSON output should not be empty."); - - // Deserialize from JSON - var deserialized = GeometryMetadata.FromJson(json); - - // Verify deserialized object is not null - Assert.IsNotNull(deserialized, "Deserialized object should not be null."); - - // Verify null fields remain null - Assert.IsNull(deserialized!.Disk!.DevicePath, "DevicePath should be null after deserialization."); - Assert.IsNull(deserialized.Filesystems, "Filesystems should be null after deserialization."); - - // Verify partition with null fields - Assert.IsNotNull(deserialized.Partitions, "Partitions should not be null."); - Assert.AreEqual(1, deserialized.Partitions!.Count, "Should have 1 partition."); - Assert.IsNull(deserialized.Partitions[0].Name, "Partition Name should be null."); - Assert.IsNull(deserialized.Partitions[0].VolumeGuid, "Partition VolumeGuid should be null."); - - // Verify other fields are preserved - Assert.AreEqual(original.Disk.Size, deserialized.Disk.Size, "Disk Size should match."); - Assert.AreEqual(original.Disk.SectorSize, deserialized.Disk.SectorSize, "Disk SectorSize should match."); - Assert.AreEqual(original.PartitionTable!.Type, deserialized.PartitionTable!.Type, "PartitionTable Type should match."); - } - - /// - /// Tests that an empty GeometryMetadata (with all null/empty fields) can be - /// serialized and deserialized correctly. - /// - [Test] - public void Test_GeometryMetadata_RoundTrip_EmptyMetadata_HandlesGracefully() - { - // Create a GeometryMetadata with mostly empty/null fields - var original = new GeometryMetadata - { - Version = 1, - Disk = null, - PartitionTable = null, - Partitions = null, - Filesystems = null - }; - - // Serialize to JSON - var json = original.ToJson(); - - // Verify JSON is not null or empty - Assert.IsNotNull(json, "JSON output should not be null."); - Assert.IsNotEmpty(json, "JSON output should not be empty."); - - // Deserialize from JSON - var deserialized = GeometryMetadata.FromJson(json); - - // Verify deserialized object is not null - Assert.IsNotNull(deserialized, "Deserialized object should not be null."); - - // Verify fields are null - Assert.IsNull(deserialized!.Disk, "Disk should be null."); - Assert.IsNull(deserialized.PartitionTable, "PartitionTable should be null."); - Assert.IsNull(deserialized.Partitions, "Partitions should be null."); - Assert.IsNull(deserialized.Filesystems, "Filesystems should be null."); - Assert.AreEqual(1, deserialized.Version, "Version should be preserved."); - } - - /// - /// Tests that a GeometryMetadata with multiple partitions and filesystems of - /// different types can be serialized and deserialized correctly. - /// - [Test] - public void Test_GeometryMetadata_RoundTrip_MultiplePartitionsAndFilesystems_PreservesAllData() - { - // Create a GeometryMetadata with multiple partitions and filesystems - var original = new GeometryMetadata - { - Version = 1, - Disk = new DiskGeometry - { - DevicePath = "/dev/nvme0n1", - Size = 500 * MiB, - SectorSize = 4096, // 4K sectors - Sectors = (int)(500 * MiB / 4096), - TableType = PartitionTableType.GPT - }, - PartitionTable = new PartitionTableGeometry - { - Type = PartitionTableType.GPT, - Size = 32768, - SectorSize = 4096, - HasProtectiveMbr = true, - HeaderSize = 92 - }, - Partitions = - [ - new PartitionGeometry - { - Number = 1, - Type = PartitionType.Primary, - StartOffset = 4096 * 6, // Start at sector 6 (after GPT headers) - Size = 100 * MiB, - Name = "EFI System Partition", - FilesystemType = FileSystemType.FAT32, - VolumeGuid = Guid.NewGuid(), - TableType = PartitionTableType.GPT - }, - new PartitionGeometry - { - Number = 2, - Type = PartitionType.Primary, - StartOffset = 4096 * 6 + 100 * MiB, - Size = 150 * MiB, - Name = "Windows OS", - FilesystemType = FileSystemType.NTFS, - VolumeGuid = Guid.NewGuid(), - TableType = PartitionTableType.GPT - }, - new PartitionGeometry - { - Number = 3, - Type = PartitionType.Primary, - StartOffset = 4096 * 6 + 250 * MiB, - Size = 200 * MiB, - Name = "Linux Root", - FilesystemType = FileSystemType.Ext4, - VolumeGuid = Guid.NewGuid(), - TableType = PartitionTableType.GPT - } - ], - Filesystems = - [ - new FilesystemGeometry - { - PartitionNumber = 1, - Type = FileSystemType.FAT32, - PartitionStartOffset = 4096 * 6, - BlockSize = 4096, - Metadata = "{\"label\":\"EFI\"}" - }, - new FilesystemGeometry - { - PartitionNumber = 2, - Type = FileSystemType.NTFS, - PartitionStartOffset = 4096 * 6 + 100 * MiB, - BlockSize = 4096, - Metadata = "{\"label\":\"Windows\"}" - }, - new FilesystemGeometry - { - PartitionNumber = 3, - Type = FileSystemType.Ext4, - PartitionStartOffset = 4096 * 6 + 250 * MiB, - BlockSize = 4096, - Metadata = "{\"label\":\"/\"}" - } - ] - }; - - // Serialize to JSON - var json = original.ToJson(); - - // Verify JSON is not null or empty - Assert.IsNotNull(json, "JSON output should not be null."); - Assert.IsNotEmpty(json, "JSON output should not be empty."); - - // Deserialize from JSON - var deserialized = GeometryMetadata.FromJson(json); - - // Verify deserialized object is not null - Assert.IsNotNull(deserialized, "Deserialized object should not be null."); - - // Verify counts - Assert.AreEqual(3, deserialized!.Partitions!.Count, "Should have 3 partitions."); - Assert.AreEqual(3, deserialized.Filesystems!.Count, "Should have 3 filesystems."); - - // Verify each partition's filesystem type - Assert.AreEqual(FileSystemType.FAT32, deserialized.Partitions[0].FilesystemType, "Partition 1 should be FAT32."); - Assert.AreEqual(FileSystemType.NTFS, deserialized.Partitions[1].FilesystemType, "Partition 2 should be NTFS."); - Assert.AreEqual(FileSystemType.Ext4, deserialized.Partitions[2].FilesystemType, "Partition 3 should be Ext4."); - - // Verify each filesystem's type - Assert.AreEqual(FileSystemType.FAT32, deserialized.Filesystems[0].Type, "Filesystem 1 should be FAT32."); - Assert.AreEqual(FileSystemType.NTFS, deserialized.Filesystems[1].Type, "Filesystem 2 should be NTFS."); - Assert.AreEqual(FileSystemType.Ext4, deserialized.Filesystems[2].Type, "Filesystem 3 should be Ext4."); - - // Verify 4K sector size is preserved - Assert.AreEqual(4096, deserialized.Disk!.SectorSize, "Disk SectorSize should be 4096."); - Assert.AreEqual(4096, deserialized.PartitionTable!.SectorSize, "PartitionTable SectorSize should be 4096."); - } - - /// - /// Tests that serializing an MBR-based GeometryMetadata produces valid JSON - /// that can be correctly deserialized. - /// - [Test] - public void Test_GeometryMetadata_RoundTrip_MBRType_PreservesMBRData() - { - var original = new GeometryMetadata - { - Version = 1, - Disk = new DiskGeometry - { - DevicePath = "/dev/sdb", - Size = 200 * MiB, - SectorSize = 512, - Sectors = (int)(200 * MiB / 512), - TableType = PartitionTableType.MBR - }, - PartitionTable = new PartitionTableGeometry - { - Type = PartitionTableType.MBR, - Size = 512, - SectorSize = 512, - HasProtectiveMbr = false, - MbrSize = 512 - }, - Partitions = - [ - new PartitionGeometry - { - Number = 1, - Type = PartitionType.Primary, - StartOffset = 512 * 2048, - Size = 100 * MiB, - Name = "Primary Partition", - FilesystemType = FileSystemType.FAT32, - TableType = PartitionTableType.MBR - }, - new PartitionGeometry - { - Number = 2, - Type = PartitionType.Primary, - StartOffset = 512 * 2048 + 100 * MiB, - Size = 90 * MiB, - Name = "Secondary Partition", - FilesystemType = FileSystemType.NTFS, - TableType = PartitionTableType.MBR - } - ], - Filesystems = - [ - new FilesystemGeometry - { - PartitionNumber = 1, - Type = FileSystemType.FAT32, - PartitionStartOffset = 512 * 2048, - BlockSize = 4096, - Metadata = null - } - ] - }; - - // Serialize to JSON - var json = original.ToJson(); - - // Deserialize from JSON - var deserialized = GeometryMetadata.FromJson(json); - - // Verify MBR-specific fields - Assert.IsNotNull(deserialized, "Deserialized object should not be null."); - Assert.AreEqual(PartitionTableType.MBR, deserialized!.Disk!.TableType, "Disk TableType should be MBR."); - Assert.AreEqual(PartitionTableType.MBR, deserialized.PartitionTable!.Type, "PartitionTable Type should be MBR."); - Assert.AreEqual(512, deserialized.PartitionTable.MbrSize, "PartitionTable MbrSize should be 512."); - Assert.IsFalse(deserialized.PartitionTable.HasProtectiveMbr, "PartitionTable HasProtectiveMbr should be false."); - - // Verify all partitions have MBR table type - foreach (var partition in deserialized.Partitions!) - { - Assert.AreEqual(PartitionTableType.MBR, partition.TableType, "All partitions should have MBR TableType."); - } - } - - /// - /// Tests that deserializing invalid JSON throws an appropriate exception. - /// - [Test] - public void Test_GeometryMetadata_FromJson_InvalidJson_ThrowsException() - { - // Test with invalid JSON - should throw JsonException - var invalidJson = "{invalid json"; - Assert.Throws(() => GeometryMetadata.FromJson(invalidJson)); - } - - /// - /// Tests that deserializing empty JSON object creates a minimal GeometryMetadata - /// with default property values set by the class. - /// - [Test] - public void Test_GeometryMetadata_FromJson_EmptyObject_CreatesDefaultInstance() - { - var emptyJson = "{}"; - var result = GeometryMetadata.FromJson(emptyJson); - - Assert.IsNotNull(result, "Should create an instance from empty JSON object."); - // Version has a default value of 1 in the class definition - Assert.AreEqual(1, result!.Version, "Version should have default value (1) from class definition."); - Assert.IsNull(result.Disk, "Disk should be null."); - Assert.IsNull(result.PartitionTable, "PartitionTable should be null."); - Assert.IsNull(result.Partitions, "Partitions should be null."); - Assert.IsNull(result.Filesystems, "Filesystems should be null."); - } - - #endregion - - #region UnknownFilesystem read/write tests - - /// - /// Tests that ListFilesAsync returns the expected block-based files for an UnknownFilesystem. - /// - [Test] - public async Task Test_UnknownFilesystem_ListFilesAsync_ReturnsBlockBasedFiles() - { - // Create a partition on the writable disk - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 10 * MiB; - var partitionOffset = sectorSize * 2048; // Start at sector 2048 - - // Create a simple partition entry - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - // Create UnknownFilesystem with 1MB block size - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - // List files - var files = new List(); - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - files.Add(file); - - // Should have 10 files (10MB partition / 1MB block size) - Assert.AreEqual(10, files.Count, "Should have 10 block files for a 10MB partition with 1MB blocks."); - - // Verify each file has correct properties - for (int i = 0; i < files.Count; i++) - { - var file = files[i]; - Assert.IsInstanceOf(file, $"File {i} should be UnknownFilesystemFile."); - Assert.IsFalse(file.IsDirectory, $"File {i} should not be a directory."); - - var unknownFile = (UnknownFilesystemFile)file; - Assert.AreEqual(i * 1024L * 1024L, unknownFile.Address, $"File {i} should have correct address."); - - // All files except possibly the last should have full block size - if (i < files.Count - 1) - Assert.AreEqual(1024 * 1024, unknownFile.Size, $"File {i} should have full block size."); - } - } - - /// - /// Tests that write and read round-trip preserves data correctly. - /// - [Test] - public async Task Test_UnknownFilesystem_WriteRead_RoundTrip_DataMatches() - { - // Create a partition on the writable disk - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - // Create UnknownFilesystem with 1MB block size - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - // Get first file - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - // Write data to the file - var writeData = new byte[1024 * 1024]; - new Random(42).NextBytes(writeData); - - using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) - { - await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); - } - - // Read data back - byte[] readData; - using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None)) - { - readData = new byte[writeData.Length]; - var bytesRead = await readStream.ReadAsync(readData.AsMemory(), CancellationToken.None); - Assert.AreEqual(writeData.Length, bytesRead, "Should have read all bytes."); - } - - // Verify data matches - Assert.AreEqual(writeData, readData, "Read data should match written data."); - } - - /// - /// Tests that BoundsCheck validates ranges correctly. - /// - [Test] - public void Test_UnknownFilesystem_BoundsCheck_ValidRange_DoesNotThrow() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - // Valid ranges should not throw - Assert.DoesNotThrow(() => fs.BoundsCheck(0, sectorSize), "BoundsCheck should not throw for valid range at start."); - Assert.DoesNotThrow(() => fs.BoundsCheck(sectorSize, sectorSize), "BoundsCheck should not throw for valid range."); - Assert.DoesNotThrow(() => fs.BoundsCheck(partitionSize - sectorSize, sectorSize), "BoundsCheck should not throw for valid range at end."); - } - - /// - /// Tests that BoundsCheck throws for negative start or size. - /// - [Test] - public void Test_UnknownFilesystem_BoundsCheck_NegativeValues_ThrowsArgumentOutOfRange() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - // Negative start should throw - Assert.Throws(() => fs.BoundsCheck(-1, sectorSize), "Negative start should throw."); - - // Negative size should throw - Assert.Throws(() => fs.BoundsCheck(0, -1), "Negative size should throw."); - } - - /// - /// Tests that BoundsCheck throws for ranges exceeding partition size. - /// - [Test] - public void Test_UnknownFilesystem_BoundsCheck_ExceedsPartitionSize_ThrowsArgumentOutOfRange() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - // Range exceeding partition size should throw - Assert.Throws(() => fs.BoundsCheck(partitionSize - sectorSize, sectorSize * 2), "Range exceeding partition should throw."); - Assert.Throws(() => fs.BoundsCheck(partitionSize, sectorSize), "Start at partition end should throw."); - } - - /// - /// Tests that BoundsCheck throws for unaligned start or size. - /// - [Test] - public void Test_UnknownFilesystem_BoundsCheck_UnalignedValues_ThrowsArgumentException() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - // Unaligned start should throw - Assert.Throws(() => fs.BoundsCheck(1, sectorSize), "Unaligned start should throw."); - - // Unaligned size should throw - Assert.Throws(() => fs.BoundsCheck(0, sectorSize + 1), "Unaligned size should throw."); - } - - /// - /// Tests that UnknownFilesystem constructor throws for unaligned block sizes. - /// - [Test] - public void Test_UnknownFilesystem_Constructor_UnalignedBlockSize_ThrowsArgumentException() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - // Block size not a multiple of sector size should throw - var unalignedBlockSize = sectorSize + 1; - var ex = Assert.Throws(() => new UnknownFilesystem(partition, blockSize: unalignedBlockSize), - "Unaligned block size should throw ArgumentException."); - Assert.That(ex!.ParamName, Is.EqualTo("blockSize"), "Exception should be for blockSize parameter."); - } - - /// - /// Tests that UnknownFilesystem constructor accepts valid block sizes. - /// - [Test] - public void Test_UnknownFilesystem_Constructor_ValidBlockSize_CreatesInstance() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - // Valid block sizes should create instance - Assert.DoesNotThrow(() => - { - using var fs1 = new UnknownFilesystem(partition, blockSize: sectorSize); - }, "Block size equal to sector size should work."); - - Assert.DoesNotThrow(() => - { - using var fs2 = new UnknownFilesystem(partition, blockSize: sectorSize * 4); - }, "Block size multiple of sector size should work."); - - Assert.DoesNotThrow(() => - { - using var fs3 = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - }, "1MB block size should work."); - } - - /// - /// Helper class to implement IPartition for testing. - /// - private class TestPartition : IPartition - { - public long StartOffset { get; init; } - public long Size { get; init; } - public IRawDisk? RawDisk { get; init; } - public int PartitionNumber => 1; - public string? Name => "TestPartition"; - public Guid? UniqueId => Guid.Empty; - public PartitionType Type => PartitionType.Primary; - public FileSystemType FilesystemType => FileSystemType.Unknown; - public Guid? VolumeGuid => Guid.Empty; - public IPartitionTable PartitionTable => new TestPartitionTable { RawDisk = RawDisk }; - - public Task OpenReadAsync(CancellationToken cancellationToken) - => Task.FromException(new NotImplementedException()); - - public Task OpenWriteAsync(CancellationToken cancellationToken) - => Task.FromException(new NotImplementedException()); - - public void Dispose() { } - } - - /// - /// Helper class to implement IPartitionTable for testing. - /// - private class TestPartitionTable : IPartitionTable - { - public IRawDisk? RawDisk { get; init; } - public PartitionTableType TableType => PartitionTableType.Unknown; - public long Size => 0; - - public IAsyncEnumerable EnumeratePartitions(CancellationToken cancellationToken) - => AsyncEnumerable.Empty(); - - public Task GetPartitionAsync(int partitionNumber, CancellationToken cancellationToken) - => Task.FromResult(null); - - public Task GetProtectiveMbrAsync(CancellationToken cancellationToken) - => Task.FromException(new NotImplementedException()); - - public Task GetPartitionTableDataAsync(CancellationToken cancellationToken) - => Task.FromException(new NotImplementedException()); - - public void Dispose() { } - } - - #endregion - - #region UnknownFilesystemStream Unaligned Tests - - /// - /// Tests that writing partial data (less than block size) is correctly flushed on dispose. - /// - [Test] - public async Task Test_UnknownFilesystemStream_WritePartialData_FlushesOnDispose() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - // Get first file - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - // Write partial data (less than block size) - var writeData = new byte[1024]; // 1KB, much less than 1MB block - new Random(42).NextBytes(writeData); - - using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) - await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); - - // Read back and verify - the written data should be preserved - byte[] readData; - using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None)) - { - readData = new byte[writeData.Length]; - var bytesRead = await readStream.ReadAsync(readData.AsMemory(), CancellationToken.None); - Assert.AreEqual(writeData.Length, bytesRead, "Should have read all written bytes."); - } - - // Verify data matches - Assert.AreEqual(writeData, readData, "Partial data should be correctly flushed and readable."); - } - - /// - /// Tests writing at position 0, seeking to middle, writing more data, and verifying both regions. - /// - [Test] - public async Task Test_UnknownFilesystemStream_WriteSeekWrite_BothRegionsPreserved() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - // Get first file - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - // Write at position 0, seek to middle, write more data - var firstWriteData = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 }; - var secondWriteData = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD }; - var middlePosition = 1000; - - using (var writeStream = await fs.OpenReadWriteStreamAsync(firstFile!, CancellationToken.None)) - { - // Write at position 0 - await writeStream.WriteAsync(firstWriteData.AsMemory(), CancellationToken.None); - - // Seek to middle position - writeStream.Seek(middlePosition, SeekOrigin.Begin); - - // Write more data - await writeStream.WriteAsync(secondWriteData.AsMemory(), CancellationToken.None); - } - - // Read back and verify both regions - byte[] readData; - using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None)) - { - readData = new byte[middlePosition + secondWriteData.Length]; - var bytesRead = await readStream.ReadAsync(readData.AsMemory(), CancellationToken.None); - Assert.AreEqual(middlePosition + secondWriteData.Length, bytesRead, "Should have read all data."); - } - - // Verify first region (position 0-4) - for (int i = 0; i < firstWriteData.Length; i++) - Assert.AreEqual(firstWriteData[i], readData[i], $"Byte at position {i} should match first write."); - - // Verify middle region (position 1000-1003) - for (int i = 0; i < secondWriteData.Length; i++) - Assert.AreEqual(secondWriteData[i], readData[middlePosition + i], $"Byte at position {middlePosition + i} should match second write."); - } - - /// - /// Tests reading after seeking to a non-zero position. - /// - [Test] - public async Task Test_UnknownFilesystemStream_ReadAfterSeek_ReturnsCorrectData() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - // Get first file - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - // Write test data - var writeData = new byte[4096]; // 4KB of test data - for (int i = 0; i < writeData.Length; i++) - writeData[i] = (byte)(i & 0xFF); - - using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) - await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); - - // Read after seeking to non-zero position - using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None)) - { - // Seek to position 100 - readStream.Seek(100, SeekOrigin.Begin); - - // Read remaining data - var readBuffer = new byte[writeData.Length - 100]; - var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - Assert.AreEqual(writeData.Length - 100, bytesRead, "Should have read remaining bytes after seek."); - - // Verify data matches expected values - for (int i = 0; i < bytesRead; i++) - { - Assert.AreEqual((byte)((i + 100) & 0xFF), readBuffer[i], $"Byte at position {100 + i} should match."); - } - } - } - - /// - /// Tests that writing data that doesn't fill the entire stream preserves the written data - /// while the remaining bytes retain their previous values. - /// - [Test] - public async Task Test_UnknownFilesystemStream_WritePartial_PreservesData() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - // Use a small block size for this test - var blockSize = 4096; - using var fs = new UnknownFilesystem(partition, blockSize: blockSize); - - // Get first file - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - // First, initialize the entire block with zeros by writing zeros - var zeroData = new byte[blockSize]; - using (var zeroStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) - await zeroStream.WriteAsync(zeroData.AsMemory(), CancellationToken.None); - - // Now write partial data at the beginning - var writeData = new byte[] { 0x11, 0x22, 0x33, 0x44, 0x55 }; - - using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) - await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); - - // Read the entire block and verify written data and remaining zeros - using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None); - var readBuffer = new byte[blockSize]; - var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - Assert.AreEqual(blockSize, bytesRead, "Should have read the full block."); - - // Verify written data - for (int i = 0; i < writeData.Length; i++) - Assert.AreEqual(writeData[i], readBuffer[i], $"Byte at position {i} should match written data."); - - // Verify remaining bytes are still zero - for (int i = writeData.Length; i < blockSize; i++) - Assert.AreEqual(0, readBuffer[i], $"Byte at position {i} should be zero."); - } - - /// - /// Tests the lazy-load behavior: first read triggers disk read, subsequent reads use buffer. - /// - [Test] - public async Task Test_UnknownFilesystemStream_LazyLoad_FirstReadTriggersDiskRead() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - // Get first file - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - // Write test data first - var writeData = new byte[2048]; - new Random(123).NextBytes(writeData); - - using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) - await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); - - // Now test lazy loading - first read should trigger disk read - using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None); - - // First small read (triggers lazy load) - var firstReadBuffer = new byte[512]; - var bytesRead1 = await readStream.ReadAsync(firstReadBuffer.AsMemory(), CancellationToken.None); - Assert.AreEqual(512, bytesRead1, "First read should return 512 bytes."); - - // Second read should use cached buffer (no disk read) - var secondReadBuffer = new byte[512]; - var bytesRead2 = await readStream.ReadAsync(secondReadBuffer.AsMemory(), CancellationToken.None); - Assert.AreEqual(512, bytesRead2, "Second read should return 512 bytes from buffer."); - - // Verify both reads returned correct data - for (int i = 0; i < 512; i++) - { - Assert.AreEqual(writeData[i], firstReadBuffer[i], $"First read: byte at position {i} should match."); - Assert.AreEqual(writeData[i + 512], secondReadBuffer[i], $"Second read: byte at position {i + 512} should match."); - } - - // Seek back and read again - should still use buffer - readStream.Seek(0, SeekOrigin.Begin); - var thirdReadBuffer = new byte[512]; - var bytesRead3 = await readStream.ReadAsync(thirdReadBuffer.AsMemory(), CancellationToken.None); - Assert.AreEqual(512, bytesRead3, "Third read after seek should return 512 bytes."); - - for (int i = 0; i < 512; i++) - Assert.AreEqual(writeData[i], thirdReadBuffer[i], $"Third read: byte at position {i} should match after seek."); - } - - /// - /// Tests seeking with SeekOrigin.Current works correctly. - /// - [Test] - public async Task Test_UnknownFilesystemStream_SeekCurrent_UpdatesPosition() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - // Get first file - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - // Write test data - var writeData = new byte[4096]; - for (int i = 0; i < writeData.Length; i++) - writeData[i] = (byte)(i & 0xFF); - - using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) - await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); - - // Test Seek with SeekOrigin.Current - using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None)) - { - // Read first 100 bytes - var buffer1 = new byte[100]; - var bytesRead1 = await readStream.ReadAsync(buffer1.AsMemory(), CancellationToken.None); - Assert.AreEqual(100, bytesRead1, "First read should return 100 bytes."); - Assert.AreEqual(100, readStream.Position, "Position should be 100 after first read."); - - // Seek forward 100 bytes from current - var newPos = readStream.Seek(100, SeekOrigin.Current); - Assert.AreEqual(200, newPos, "Position should be 200 after Seek(Current, 100)."); - Assert.AreEqual(200, readStream.Position, "Stream.Position should match returned position."); - - // Read next 100 bytes - var buffer2 = new byte[100]; - var bytesRead2 = await readStream.ReadAsync(buffer2.AsMemory(), CancellationToken.None); - Assert.AreEqual(100, bytesRead2, "Second read should return 100 bytes."); - - // Verify data at position 200 - for (int i = 0; i < 100; i++) - Assert.AreEqual((byte)((200 + i) & 0xFF), buffer2[i], $"Byte at position {200 + i} should match."); - } - } - - /// - /// Tests seeking with SeekOrigin.End works correctly. - /// - [Test] - public async Task Test_UnknownFilesystemStream_SeekEnd_UpdatesPosition() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - var blockSize = 4096; - using var fs = new UnknownFilesystem(partition, blockSize: blockSize); - - // Get first file - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - // Write test data - var writeData = new byte[blockSize]; - for (int i = 0; i < writeData.Length; i++) - writeData[i] = (byte)(i & 0xFF); - - using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) - await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); - - // Test Seek with SeekOrigin.End - using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None); - - // Seek to 100 bytes from end - var newPos = readStream.Seek(-100, SeekOrigin.End); - Assert.AreEqual(blockSize - 100, newPos, "Position should be blockSize - 100."); - Assert.AreEqual(blockSize - 100, readStream.Position, "Stream.Position should match."); - - // Read remaining bytes - var buffer = new byte[100]; - var bytesRead = await readStream.ReadAsync(buffer.AsMemory(), CancellationToken.None); - Assert.AreEqual(100, bytesRead, "Should read 100 bytes."); - - // Verify last 100 bytes - for (int i = 0; i < 100; i++) - Assert.AreEqual((byte)((blockSize - 100 + i) & 0xFF), buffer[i], $"Byte at position {blockSize - 100 + i} should match."); - } - - /// - /// Tests that reading past the end of the stream returns 0 bytes. - /// - [Test] - public async Task Test_UnknownFilesystemStream_ReadPastEnd_ReturnsZero() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - var blockSize = 4096; - using var fs = new UnknownFilesystem(partition, blockSize: blockSize); - - // Get first file - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - // Write small amount of test data - var writeData = new byte[100]; - new Random(42).NextBytes(writeData); - - using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) - await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); - - // Test reading past end - using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None); - - // Seek to near end - readStream.Seek(blockSize - 10, SeekOrigin.Begin); - - // Try to read 100 bytes when only 10 remain - var buffer = new byte[100]; - var bytesRead = await readStream.ReadAsync(buffer.AsMemory(), CancellationToken.None); - Assert.AreEqual(10, bytesRead, "Should only read remaining 10 bytes."); - - // Try to read again - should return 0 - bytesRead = await readStream.ReadAsync(buffer.AsMemory(), CancellationToken.None); - Assert.AreEqual(0, bytesRead, "Should return 0 when at end of stream."); - } - - /// - /// Tests that unaligned writes within the stream work correctly. - /// - [Test] - public async Task Test_UnknownFilesystemStream_UnalignedWrite_DataMatches() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - var blockSize = 8192; - using var fs = new UnknownFilesystem(partition, blockSize: blockSize); - - // Get first file - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - // First, zero-fill the block so gap bytes are deterministic - using (var zeroStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) - await zeroStream.WriteAsync(new byte[blockSize].AsMemory(), CancellationToken.None); - - // Write at unaligned positions using read-write stream (pre-loads zeroed data) - using (var writeStream = await fs.OpenReadWriteStreamAsync(firstFile!, CancellationToken.None)) - { - // Write 3 bytes at position 0 - await writeStream.WriteAsync(new byte[] { 0xAA, 0xBB, 0xCC }.AsMemory(), CancellationToken.None); - - // Seek to unaligned position 5 - writeStream.Seek(5, SeekOrigin.Begin); - await writeStream.WriteAsync(new byte[] { 0xDD, 0xEE }.AsMemory(), CancellationToken.None); - - // Seek to another unaligned position 10 - writeStream.Seek(10, SeekOrigin.Begin); - await writeStream.WriteAsync(new byte[] { 0x11, 0x22, 0x33, 0x44 }.AsMemory(), CancellationToken.None); - } - - // Read back and verify - using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None)) - { - var readBuffer = new byte[14]; - var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - Assert.AreEqual(14, bytesRead, "Should read 14 bytes."); - - // Verify written data - Assert.AreEqual(0xAA, readBuffer[0], "Byte 0 should be 0xAA"); - Assert.AreEqual(0xBB, readBuffer[1], "Byte 1 should be 0xBB"); - Assert.AreEqual(0xCC, readBuffer[2], "Byte 2 should be 0xCC"); - // Bytes 3-4 should be zero - Assert.AreEqual(0, readBuffer[3], "Byte 3 should be 0"); - Assert.AreEqual(0, readBuffer[4], "Byte 4 should be 0"); - Assert.AreEqual(0xDD, readBuffer[5], "Byte 5 should be 0xDD"); - Assert.AreEqual(0xEE, readBuffer[6], "Byte 6 should be 0xEE"); - // Bytes 7-9 should be zero - Assert.AreEqual(0, readBuffer[7], "Byte 7 should be 0"); - Assert.AreEqual(0, readBuffer[8], "Byte 8 should be 0"); - Assert.AreEqual(0, readBuffer[9], "Byte 9 should be 0"); - Assert.AreEqual(0x11, readBuffer[10], "Byte 10 should be 0x11"); - Assert.AreEqual(0x22, readBuffer[11], "Byte 11 should be 0x22"); - Assert.AreEqual(0x33, readBuffer[12], "Byte 12 should be 0x33"); - Assert.AreEqual(0x44, readBuffer[13], "Byte 13 should be 0x44"); - } - } - - #endregion - - #region 12. PooledBuffer and PooledMemoryStream Tests - - /// - /// Tests that PooledBuffer rents a buffer from the ArrayPool and returns it on dispose. - /// - [Test] - public void Test_PooledBuffer_RentAndReturn_BufferRecycled() - { - // Rent a buffer using PooledBuffer - using var pooledBuffer = new PooledBuffer(1024); - byte[] bufferArray = pooledBuffer.Array; - var bufferLength = pooledBuffer.Length; - - // Verify the buffer was rented - Assert.IsNotNull(bufferArray, "Buffer array should not be null."); - Assert.AreEqual(1024, bufferLength, "Buffer length should match requested size."); - Assert.GreaterOrEqual(bufferArray.Length, 1024, "Underlying array should be at least the requested size."); - - // Write some data to verify we have a valid buffer - bufferArray[0] = 0xAB; - bufferArray[1023] = 0xCD; - - // Verify Memory and Span properties work correctly - var memory = pooledBuffer.Memory; - var span = pooledBuffer.Span; - - Assert.AreEqual(1024, memory.Length, "Memory length should match requested size."); - Assert.AreEqual(1024, span.Length, "Span length should match requested size."); - Assert.AreEqual(0xAB, memory.Span[0], "Memory should reflect buffer changes."); - Assert.AreEqual(0xCD, span[1023], "Span should reflect buffer changes."); - } - - /// - /// Tests that PooledBuffer handles different sizes correctly. - /// - [Test] - public void Test_PooledBuffer_DifferentSizes_WorksCorrectly() - { - // Test small buffer - using (var small = new PooledBuffer(16)) - { - Assert.AreEqual(16, small.Length, "Small buffer length should be correct."); - Assert.GreaterOrEqual(small.Array.Length, 16, "Small buffer array should be at least requested size."); - } - - // Test large buffer (above LOH threshold of ~85KB) - using (var large = new PooledBuffer(128 * 1024)) - { - Assert.AreEqual(128 * 1024, large.Length, "Large buffer length should be correct."); - Assert.GreaterOrEqual(large.Array.Length, 128 * 1024, "Large buffer array should be at least requested size."); - } - - // Test zero-size buffer - using (var empty = new PooledBuffer(0)) - { - Assert.AreEqual(0, empty.Length, "Empty buffer length should be 0."); - Assert.IsNotNull(empty.Array, "Empty buffer array should still be rented."); - } - } - - /// - /// Tests that PooledMemoryStream reads correctly and returns buffer on dispose. - /// - [Test] - public void Test_PooledMemoryStream_Read_ReturnsCorrectData() - { - // Rent a buffer and populate it with test data - byte[] buffer; - using (var pooledBuffer = new PooledBuffer(1024)) - { - buffer = pooledBuffer.Array; - for (int i = 0; i < 1024; i++) - buffer[i] = (byte)(i & 0xFF); - } - - // Create a PooledMemoryStream with the buffer - using var stream = new PooledMemoryStream(buffer, 1024); - - // Verify stream properties - Assert.IsTrue(stream.CanRead, "Stream should be readable."); - Assert.IsTrue(stream.CanSeek, "Stream should be seekable."); - Assert.IsFalse(stream.CanWrite, "Stream should not be writable."); - Assert.AreEqual(1024, stream.Length, "Stream length should be correct."); - Assert.AreEqual(0, stream.Position, "Initial position should be 0."); - - // Read data from the stream - var readBuffer = new byte[256]; - var bytesRead = stream.Read(readBuffer, 0, 256); - - Assert.AreEqual(256, bytesRead, "Should have read 256 bytes."); - Assert.AreEqual(256, stream.Position, "Position should be updated."); - - // Verify the data - for (int i = 0; i < 256; i++) - Assert.AreEqual((byte)(i & 0xFF), readBuffer[i], $"Byte at position {i} should match."); - } - - /// - /// Tests that PooledMemoryStream seek operations work correctly. - /// - [Test] - public void Test_PooledMemoryStream_Seek_BeginCurrentEnd_WorksCorrectly() - { - // Rent a buffer and populate it - byte[] buffer; - using (var pooledBuffer = new PooledBuffer(512)) - { - buffer = pooledBuffer.Array; - for (int i = 0; i < 512; i++) - buffer[i] = (byte)(i & 0xFF); - } - - using (var stream = new PooledMemoryStream(buffer, 512)) - { - // Test SeekOrigin.Begin - var pos = stream.Seek(100, SeekOrigin.Begin); - Assert.AreEqual(100, pos, "Seek from Begin should return correct position."); - Assert.AreEqual(100, stream.Position, "Position should be 100."); - - // Read a byte to verify position - var readBuffer = new byte[1]; - var bytesRead = stream.Read(readBuffer, 0, 1); - Assert.AreEqual(1, bytesRead, "Should read 1 byte."); - Assert.AreEqual(100, readBuffer[0], "Should read byte at position 100."); - - // Test SeekOrigin.Current - pos = stream.Seek(50, SeekOrigin.Current); - Assert.AreEqual(151, pos, "Seek from Current should return correct position."); - Assert.AreEqual(151, stream.Position, "Position should be 151."); - - bytesRead = stream.Read(readBuffer, 0, 1); - Assert.AreEqual(1, bytesRead, "Should read 1 byte."); - Assert.AreEqual(151, readBuffer[0], "Should read byte at position 151."); - - // Test SeekOrigin.End - pos = stream.Seek(-10, SeekOrigin.End); - Assert.AreEqual(502, pos, "Seek from End should return correct position."); - Assert.AreEqual(502, stream.Position, "Position should be 502."); - - bytesRead = stream.Read(readBuffer, 0, 1); - Assert.AreEqual(1, bytesRead, "Should read 1 byte."); - // 502 & 0xFF = 246 (byte overflow) - Assert.AreEqual(246, readBuffer[0], "Should read byte at position 502 (502 & 0xFF = 246)."); - } - } - - /// - /// Tests that seeking beyond stream bounds throws ArgumentOutOfRangeException. - /// - [Test] - public void Test_PooledMemoryStream_SeekOutOfBounds_ThrowsArgumentOutOfRange() - { - byte[] buffer; - using (var pooledBuffer = new PooledBuffer(100)) - buffer = pooledBuffer.Array; - - using var stream = new PooledMemoryStream(buffer, 100); - - // Seek before beginning - Assert.Throws(() => stream.Seek(-1, SeekOrigin.Begin), "Seek before beginning should throw."); - - // Seek past end - Assert.Throws(() => stream.Seek(101, SeekOrigin.Begin), "Seek past end should throw."); - - // Seek before beginning using Current - stream.Seek(50, SeekOrigin.Begin); - Assert.Throws(() => stream.Seek(-51, SeekOrigin.Current), "Seek before beginning from current should throw."); - - // Seek past end using End - Assert.Throws(() => stream.Seek(1, SeekOrigin.End), "Seek past end from end should throw."); - } - - /// - /// Tests that reading past the end of PooledMemoryStream returns 0 bytes. - /// - [Test] - public void Test_PooledMemoryStream_ReadPastEnd_ReturnsZero() - { - byte[] buffer; - using (var pooledBuffer = new PooledBuffer(100)) - { - buffer = pooledBuffer.Array; - // Fill with test data - for (int i = 0; i < 100; i++) - buffer[i] = (byte)(i & 0xFF); - } - - using var stream = new PooledMemoryStream(buffer, 100); - - // Seek to near end - stream.Seek(95, SeekOrigin.Begin); - - // Read 10 bytes when only 5 remain - var readBuffer = new byte[10]; - var bytesRead = stream.Read(readBuffer, 0, 10); - - Assert.AreEqual(5, bytesRead, "Should only read remaining 5 bytes."); - Assert.AreEqual(100, stream.Position, "Position should be at end."); - - // Read again - should return 0 - bytesRead = stream.Read(readBuffer, 0, 10); - Assert.AreEqual(0, bytesRead, "Should return 0 when at end of stream."); - } - - /// - /// Tests that PooledMemoryStream returns buffer to pool on dispose. - /// - [Test] - public void Test_PooledMemoryStream_Dispose_ReturnsBufferToPool() - { - // This test verifies that the stream can be disposed without errors - // and that the buffer is returned to the pool (which we can't directly verify, - // but we can ensure disposal works correctly) - - byte[] buffer; - using (var pooledBuffer = new PooledBuffer(256)) - buffer = pooledBuffer.Array; - - var stream = new PooledMemoryStream(buffer, 256); - - // Use the stream - var readBuffer = new byte[64]; - var bytesRead = stream.Read(readBuffer, 0, 64); - Assert.AreEqual(64, bytesRead, "Should read 64 bytes."); - - // Dispose - stream.Dispose(); - - // After dispose, attempting to read should throw ObjectDisposedException - Assert.Throws(() => { var _ = stream.Read(readBuffer, 0, 64); }, "Reading after dispose should throw."); - - // Note: Seek doesn't throw ObjectDisposedException in this implementation - // because it doesn't access the buffer directly (only _position and _length) - - // CanRead, CanSeek should return false after dispose - Assert.IsFalse(stream.CanRead, "CanRead should be false after dispose."); - Assert.IsFalse(stream.CanSeek, "CanSeek should be false after dispose."); - } - - /// - /// Tests that PooledMemoryStream write and set length operations throw NotSupportedException. - /// - [Test] - public void Test_PooledMemoryStream_WriteAndSetLength_ThrowsNotSupported() - { - byte[] buffer; - using (var pooledBuffer = new PooledBuffer(100)) - buffer = pooledBuffer.Array; - - using var stream = new PooledMemoryStream(buffer, 100); - - // Write should throw - Assert.Throws(() => stream.Write(new byte[10], 0, 10), "Write should throw NotSupportedException."); - - // SetLength should throw - Assert.Throws(() => stream.SetLength(50), "SetLength should throw NotSupportedException."); - } - - /// - /// Tests that PooledMemoryStream handles empty buffers correctly. - /// - [Test] - public void Test_PooledMemoryStream_EmptyBuffer_WorksCorrectly() - { - byte[] buffer; - using (var pooledBuffer = new PooledBuffer(0)) - buffer = pooledBuffer.Array; - - using var stream = new PooledMemoryStream(buffer, 0); - - Assert.AreEqual(0, stream.Length, "Length should be 0."); - Assert.AreEqual(0, stream.Position, "Position should be 0."); - - // Read should return 0 - var readBuffer = new byte[10]; - var bytesRead = stream.Read(readBuffer, 0, 10); - Assert.AreEqual(0, bytesRead, "Read from empty stream should return 0."); - - // Seek to 0 should work - var pos = stream.Seek(0, SeekOrigin.Begin); - Assert.AreEqual(0, pos, "Seek to 0 should work."); - } - - /// - /// Tests that PooledBuffer can be used multiple times in succession. - /// - [Test] - public void Test_PooledBuffer_MultipleInstances_WorkIndependently() - { - // Create multiple PooledBuffers - using var buffer1 = new PooledBuffer(64); - using var buffer2 = new PooledBuffer(128); - using var buffer3 = new PooledBuffer(256); - - // Write different data to each - buffer1.Array[0] = 0x11; - buffer2.Array[0] = 0x22; - buffer3.Array[0] = 0x33; - - // Verify each buffer has its own data - Assert.AreEqual(0x11, buffer1.Array[0], "Buffer1 should have its own data."); - Assert.AreEqual(0x22, buffer2.Array[0], "Buffer2 should have its own data."); - Assert.AreEqual(0x33, buffer3.Array[0], "Buffer3 should have its own data."); - - // Verify lengths - Assert.AreEqual(64, buffer1.Length, "Buffer1 length should be correct."); - Assert.AreEqual(128, buffer2.Length, "Buffer2 length should be correct."); - Assert.AreEqual(256, buffer3.Length, "Buffer3 length should be correct."); - } - - #endregion - - #region RestoreProvider Path Parsing Tests - - /// - /// Creates a RestoreProvider with mock partitions and filesystems for testing path parsing. - /// Uses reflection to set up the internal state. - /// - private static RestoreProvider CreateRestoreProviderForPathParsingTests() - { - // Create a RestoreProvider using the default constructor (for metadata loading) - var provider = new RestoreProvider(); - - // Create mock partition tables - var gptTable = new MockPartitionTable(PartitionTableType.GPT); - var mbrTable = new MockPartitionTable(PartitionTableType.MBR); - - // Create mock partitions - var partitions = new List - { - new MockPartition(gptTable, 1, PartitionType.Primary, 1048576, 20971520, "EFI System", FileSystemType.FAT32), - new MockPartition(gptTable, 2, PartitionType.Primary, 22020096, 41943040, "Windows OS", FileSystemType.NTFS), - new MockPartition(mbrTable, 1, PartitionType.Primary, 1048576, 10485760, "MBR Partition 1", FileSystemType.FAT32), - new MockPartition(mbrTable, 2, PartitionType.Primary, 11534336, 20971520, "MBR Partition 2", FileSystemType.NTFS) - }; - - // Create mock filesystems - var filesystems = new List - { - new MockFilesystem(partitions[0], FileSystemType.FAT32), - new MockFilesystem(partitions[1], FileSystemType.NTFS), - new MockFilesystem(partitions[2], FileSystemType.FAT32), - new MockFilesystem(partitions[3], FileSystemType.NTFS) - }; - - // Use reflection to set the private fields - var partitionsField = typeof(RestoreProvider).GetField("_partitions", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var filesystemsField = typeof(RestoreProvider).GetField("_filesystems", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - partitionsField?.SetValue(provider, partitions); - filesystemsField?.SetValue(provider, filesystems); - - return provider; - } - - /// - /// Mock implementation of IPartitionTable for testing. - /// - private class MockPartitionTable(PartitionTableType tableType) : IPartitionTable - { - public IRawDisk? RawDisk => null; - public PartitionTableType TableType { get; } = tableType; - - public IAsyncEnumerable EnumeratePartitions(CancellationToken cancellationToken) - { - return AsyncEnumerable.Empty(); - } - - public Task GetPartitionAsync(int partitionNumber, CancellationToken cancellationToken) - { - return Task.FromResult(null); - } - - public Task GetProtectiveMbrAsync(CancellationToken cancellationToken) - { - return Task.FromResult(new MemoryStream()); - } - - public Task GetPartitionTableDataAsync(CancellationToken cancellationToken) - { - return Task.FromResult(new MemoryStream()); - } - - public void Dispose() { } - } - - /// - /// Mock implementation of IPartition for testing. - /// - private class MockPartition(IPartitionTable table, int number, PartitionType type, long startOffset, long size, string? name, FileSystemType fsType) : IPartition - { - public IPartitionTable PartitionTable { get; } = table; - public int PartitionNumber { get; } = number; - public PartitionType Type { get; } = type; - public long StartOffset { get; } = startOffset; - public long Size { get; } = size; - public string? Name { get; } = name; - public FileSystemType FilesystemType { get; } = fsType; - public Guid? VolumeGuid { get; } = Guid.NewGuid(); - - public Task OpenReadAsync(CancellationToken cancellationToken) - { - return Task.FromResult(new MemoryStream()); - } - - public Task OpenWriteAsync(CancellationToken cancellationToken) - { - return Task.FromResult(new MemoryStream()); - } - - public void Dispose() { } - } - - /// - /// Mock implementation of IFilesystem for testing. - /// - private class MockFilesystem(IPartition partition, FileSystemType type) : IFilesystem - { - public IPartition Partition { get; } = partition; - public FileSystemType Type { get; } = type; - - public Task GetFilesystemMetadataAsync(CancellationToken cancellationToken) - { - return Task.FromResult(null); - } - - public IAsyncEnumerable ListFilesAsync(CancellationToken cancellationToken) - { - return AsyncEnumerable.Empty(); - } - - public IAsyncEnumerable ListFilesAsync(IFile directory, CancellationToken cancellationToken) - { - return AsyncEnumerable.Empty(); - } - - public Task OpenReadStreamAsync(IFile file, CancellationToken cancellationToken) - { - return Task.FromResult(new MemoryStream()); - } - - public Task OpenWriteStreamAsync(IFile file, CancellationToken cancellationToken) - { - return Task.FromResult(new MemoryStream()); - } - - public Task OpenReadWriteStreamAsync(IFile file, CancellationToken cancellationToken) - { - return Task.FromResult(new MemoryStream()); - } - - public Task GetFileLengthAsync(IFile file, CancellationToken cancellationToken) - { - return Task.FromResult(0L); - } - - public Task OpenReadStreamAsync(string path, CancellationToken cancellationToken) - { - return Task.FromResult(new MemoryStream()); - } - - public Task OpenWriteStreamAsync(string path, CancellationToken cancellationToken) - { - return Task.FromResult(new MemoryStream()); - } - - public Task OpenReadWriteStreamAsync(string path, CancellationToken cancellationToken) - { - return Task.FromResult(new MemoryStream()); - } - - public Task GetFileLengthAsync(string path, CancellationToken cancellationToken) - { - return Task.FromResult(0L); - } - - public void Dispose() { } - } - - /// - /// Tests that ParsePartition correctly parses a valid GPT partition segment. - /// - [Test] - public void Test_RestoreProvider_ParsePartition_ValidGPTPartition_ReturnsCorrectPartition() - { - using var provider = CreateRestoreProviderForPathParsingTests(); - var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist."); - - // Test parsing "part_GPT_1" - var result = parsePartitionMethod!.Invoke(provider, ["part_GPT_1"]); - Assert.IsNotNull(result, "Should return a partition."); - - var partition = (IPartition)result!; - Assert.AreEqual(1, partition.PartitionNumber, "Partition number should be 1."); - Assert.AreEqual(PartitionTableType.GPT, partition.PartitionTable.TableType, "Partition table type should be GPT."); - Assert.AreEqual("EFI System", partition.Name, "Partition name should match."); - } - - /// - /// Tests that ParsePartition correctly parses a valid MBR partition segment. - /// - [Test] - public void Test_RestoreProvider_ParsePartition_ValidMBRPartition_ReturnsCorrectPartition() - { - using var provider = CreateRestoreProviderForPathParsingTests(); - var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist."); - - // Test parsing "part_MBR_2" - var result = parsePartitionMethod!.Invoke(provider, ["part_MBR_2"]); - Assert.IsNotNull(result, "Should return a partition."); - - var partition = (IPartition)result!; - Assert.AreEqual(2, partition.PartitionNumber, "Partition number should be 2."); - Assert.AreEqual(PartitionTableType.MBR, partition.PartitionTable.TableType, "Partition table type should be MBR."); - Assert.AreEqual("MBR Partition 2", partition.Name, "Partition name should match."); - } - - /// - /// Tests that ParsePartition throws InvalidOperationException for malformed partition segments. - /// - [Test] - public void Test_RestoreProvider_ParsePartition_MalformedSegment_ThrowsInvalidOperationException() - { - using var provider = CreateRestoreProviderForPathParsingTests(); - var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist."); - - // Test with too few parts - var ex = Assert.Throws(() => - parsePartitionMethod!.Invoke(provider, ["part_GPT"]), - "Should throw for malformed segment."); - Assert.IsInstanceOf(ex!.InnerException, "Inner exception should be InvalidOperationException."); - - // Test with invalid partition table type - ex = Assert.Throws(() => - parsePartitionMethod!.Invoke(provider, ["part_INVALID_1"]), - "Should throw for invalid table type."); - Assert.IsInstanceOf(ex!.InnerException, "Inner exception should be InvalidOperationException."); - - // Test with non-numeric partition number - ex = Assert.Throws(() => - parsePartitionMethod!.Invoke(provider, ["part_GPT_ABC"]), - "Should throw for non-numeric partition number."); - Assert.IsInstanceOf(ex!.InnerException, "Inner exception should be InvalidOperationException."); - } - - /// - /// Tests that ParsePartition throws InvalidOperationException for non-existent partitions. - /// - [Test] - public void Test_RestoreProvider_ParsePartition_NonExistentPartition_ThrowsInvalidOperationException() - { - using var provider = CreateRestoreProviderForPathParsingTests(); - var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist."); - - // Test with partition number that doesn't exist - var ex = Assert.Throws(() => - parsePartitionMethod!.Invoke(provider, ["part_GPT_99"]), - "Should throw for non-existent partition."); - Assert.IsInstanceOf(ex!.InnerException, "Inner exception should be InvalidOperationException."); - } - - /// - /// Tests that ParseFilesystem correctly parses a valid NTFS filesystem segment. - /// - [Test] - public void Test_RestoreProvider_ParseFilesystem_ValidNTFS_ReturnsCorrectFilesystem() - { - using var provider = CreateRestoreProviderForPathParsingTests(); - var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var parseFilesystemMethod = typeof(RestoreProvider).GetMethod("ParseFilesystem", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist."); - Assert.IsNotNull(parseFilesystemMethod, "ParseFilesystem method should exist."); - - // Get a partition first - var partitionResult = parsePartitionMethod!.Invoke(provider, ["part_GPT_2"]); - Assert.IsNotNull(partitionResult, "Should return a partition."); - var partition = (IPartition)partitionResult!; - - // Test parsing "fs_NTFS" - var result = parseFilesystemMethod!.Invoke(provider, [partition, "fs_NTFS"]); - Assert.IsNotNull(result, "Should return a filesystem."); - - var filesystem = (IFilesystem)result!; - Assert.AreEqual(FileSystemType.NTFS, filesystem.Type, "Filesystem type should be NTFS."); - Assert.AreEqual(partition.PartitionNumber, filesystem.Partition.PartitionNumber, "Partition number should match."); - } - - /// - /// Tests that ParseFilesystem correctly parses a valid FAT32 filesystem segment. - /// - [Test] - public void Test_RestoreProvider_ParseFilesystem_ValidFAT32_ReturnsCorrectFilesystem() - { - using var provider = CreateRestoreProviderForPathParsingTests(); - var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var parseFilesystemMethod = typeof(RestoreProvider).GetMethod("ParseFilesystem", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist."); - Assert.IsNotNull(parseFilesystemMethod, "ParseFilesystem method should exist."); - - // Get a partition first - var partitionResult = parsePartitionMethod!.Invoke(provider, ["part_GPT_1"]); - Assert.IsNotNull(partitionResult, "Should return a partition."); - var partition = (IPartition)partitionResult!; - - // Test parsing "fs_FAT32" - var result = parseFilesystemMethod!.Invoke(provider, [partition, "fs_FAT32"]); - Assert.IsNotNull(result, "Should return a filesystem."); - - var filesystem = (IFilesystem)result!; - Assert.AreEqual(FileSystemType.FAT32, filesystem.Type, "Filesystem type should be FAT32."); - } - - /// - /// Tests that ParseFilesystem throws InvalidOperationException for malformed filesystem segments. - /// - [Test] - public void Test_RestoreProvider_ParseFilesystem_MalformedSegment_ThrowsInvalidOperationException() - { - using var provider = CreateRestoreProviderForPathParsingTests(); - var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var parseFilesystemMethod = typeof(RestoreProvider).GetMethod("ParseFilesystem", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist."); - Assert.IsNotNull(parseFilesystemMethod, "ParseFilesystem method should exist."); - - // Get a partition first - var partitionResult = parsePartitionMethod!.Invoke(provider, ["part_GPT_1"]); - Assert.IsNotNull(partitionResult, "Should return a partition."); - var partition = (IPartition)partitionResult!; - - // Test with too few parts - var ex = Assert.Throws(() => - parseFilesystemMethod!.Invoke(provider, [partition, "fs"]), - "Should throw for malformed segment."); - Assert.IsInstanceOf(ex!.InnerException, "Inner exception should be InvalidOperationException."); - - // Test with invalid filesystem type - ex = Assert.Throws(() => - parseFilesystemMethod!.Invoke(provider, [partition, "fs_INVALID"]), - "Should throw for invalid filesystem type."); - Assert.IsInstanceOf(ex!.InnerException, "Inner exception should be InvalidOperationException."); - } - - /// - /// Tests that ParseFilesystem throws InvalidOperationException for non-existent filesystems. - /// - [Test] - public void Test_RestoreProvider_ParseFilesystem_NonExistentFilesystem_ThrowsInvalidOperationException() - { - using var provider = CreateRestoreProviderForPathParsingTests(); - var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var parseFilesystemMethod = typeof(RestoreProvider).GetMethod("ParseFilesystem", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist."); - Assert.IsNotNull(parseFilesystemMethod, "ParseFilesystem method should exist."); - - // Get a partition that doesn't have an Ext4 filesystem - var partitionResult = parsePartitionMethod!.Invoke(provider, ["part_GPT_1"]); - Assert.IsNotNull(partitionResult, "Should return a partition."); - var partition = (IPartition)partitionResult!; - - // Test with filesystem type that doesn't exist for this partition - var ex = Assert.Throws(() => - parseFilesystemMethod!.Invoke(provider, [partition, "fs_Ext4"]), - "Should throw for non-existent filesystem."); - Assert.IsInstanceOf(ex!.InnerException, "Inner exception should be InvalidOperationException."); - } - - /// - /// Tests that ParsePath correctly identifies a geometry file path. - /// - [Test] - public void Test_RestoreProvider_ParsePath_GeometryFile_ReturnsGeometryType() - { - using var provider = CreateRestoreProviderForPathParsingTests(); - var parsePathMethod = typeof(RestoreProvider).GetMethod("ParsePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - Assert.IsNotNull(parsePathMethod, "ParsePath method should exist."); - - // Test with geometry.json file - var result = parsePathMethod!.Invoke(provider, ["geometry.json"]); - Assert.IsNotNull(result, "Should return a tuple."); - - var tuple = ((string Type, IPartition? Partition, IFilesystem? Filesystem))result!; - Assert.AreEqual("geometry", tuple.Type, "Type should be 'geometry'."); - Assert.IsNull(tuple.Partition, "Partition should be null for geometry."); - Assert.IsNull(tuple.Filesystem, "Filesystem should be null for geometry."); - } - - /// - /// Tests that ParsePath correctly identifies a disk-level path. - /// - [Test] - public void Test_RestoreProvider_ParsePath_DiskPath_ReturnsDiskType() - { - using var provider = CreateRestoreProviderForPathParsingTests(); - var parsePathMethod = typeof(RestoreProvider).GetMethod("ParsePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - Assert.IsNotNull(parsePathMethod, "ParsePath method should exist."); - - // Test with root path - var result = parsePathMethod!.Invoke(provider, ["/"]); - Assert.IsNotNull(result, "Should return a tuple."); - - var tuple = ((string Type, IPartition? Partition, IFilesystem? Filesystem))result!; - Assert.AreEqual("disk", tuple.Type, "Type should be 'disk'."); - Assert.IsNull(tuple.Partition, "Partition should be null for disk."); - Assert.IsNull(tuple.Filesystem, "Filesystem should be null for disk."); - } - - /// - /// Tests that ParsePath correctly identifies a partition path. - /// - [Test] - public void Test_RestoreProvider_ParsePath_PartitionPath_ReturnsPartitionType() - { - using var provider = CreateRestoreProviderForPathParsingTests(); - var parsePathMethod = typeof(RestoreProvider).GetMethod("ParsePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - Assert.IsNotNull(parsePathMethod, "ParsePath method should exist."); - - // Test with partition path (no filesystem segment) - var result = parsePathMethod!.Invoke(provider, ["part_GPT_1"]); - Assert.IsNotNull(result, "Should return a tuple."); - - var tuple = ((string Type, IPartition? Partition, IFilesystem? Filesystem))result!; - Assert.AreEqual("partition", tuple.Type, "Type should be 'partition'."); - Assert.IsNotNull(tuple.Partition, "Partition should not be null."); - Assert.AreEqual(1, tuple.Partition!.PartitionNumber, "Partition number should be 1."); - Assert.IsNull(tuple.Filesystem, "Filesystem should be null for partition-only path."); - } - - /// - /// Tests that ParsePath correctly identifies a file path with partition and filesystem. - /// - [Test] - public void Test_RestoreProvider_ParsePath_FilePath_ReturnsFileType() - { - using var provider = CreateRestoreProviderForPathParsingTests(); - var parsePathMethod = typeof(RestoreProvider).GetMethod("ParsePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - Assert.IsNotNull(parsePathMethod, "ParsePath method should exist."); - - // Test with full file path - var result = parsePathMethod!.Invoke(provider, ["part_GPT_1/fs_FAT32/test/file.txt"]); - Assert.IsNotNull(result, "Should return a tuple."); - - var tuple = ((string Type, IPartition? Partition, IFilesystem? Filesystem))result!; - Assert.AreEqual("file", tuple.Type, "Type should be 'file'."); - Assert.IsNotNull(tuple.Partition, "Partition should not be null."); - Assert.IsNotNull(tuple.Filesystem, "Filesystem should not be null."); - Assert.AreEqual(1, tuple.Partition!.PartitionNumber, "Partition number should be 1."); - Assert.AreEqual(FileSystemType.FAT32, tuple.Filesystem!.Type, "Filesystem type should be FAT32."); - } - - /// - /// Tests that ParsePath handles MBR partition paths correctly. - /// - [Test] - public void Test_RestoreProvider_ParsePath_MBRPartitionPath_ReturnsCorrectPartition() - { - using var provider = CreateRestoreProviderForPathParsingTests(); - var parsePathMethod = typeof(RestoreProvider).GetMethod("ParsePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - Assert.IsNotNull(parsePathMethod, "ParsePath method should exist."); - - // Test with MBR partition path - var result = parsePathMethod!.Invoke(provider, ["part_MBR_2/fs_NTFS/data/file.dat"]); - Assert.IsNotNull(result, "Should return a tuple."); - - var tuple = ((string Type, IPartition? Partition, IFilesystem? Filesystem))result!; - Assert.AreEqual("file", tuple.Type, "Type should be 'file'."); - Assert.IsNotNull(tuple.Partition, "Partition should not be null."); - Assert.IsNotNull(tuple.Filesystem, "Filesystem should not be null."); - Assert.AreEqual(2, tuple.Partition!.PartitionNumber, "Partition number should be 2."); - Assert.AreEqual(PartitionTableType.MBR, tuple.Partition.PartitionTable.TableType, "Table type should be MBR."); - Assert.AreEqual(FileSystemType.NTFS, tuple.Filesystem!.Type, "Filesystem type should be NTFS."); - } - - /// - /// Tests that ParsePath handles paths with different separators correctly on Windows. - /// On Windows, both forward slash and backslash are valid path separators. - /// On Linux/macOS, backslash is a valid filename character, not a separator. - /// - [Test] - public void Test_RestoreProvider_ParsePath_DifferentSeparators_NormalizesCorrectly() - { - using var provider = CreateRestoreProviderForPathParsingTests(); - var parsePathMethod = typeof(RestoreProvider).GetMethod("ParsePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - Assert.IsNotNull(parsePathMethod, "ParsePath method should exist."); - - if (OperatingSystem.IsWindows()) - { - // On Windows, backslash is a path separator - var result = parsePathMethod!.Invoke(provider, ["part_GPT_1\\fs_FAT32\\test\\file.txt"]); - Assert.IsNotNull(result, "Should return a tuple."); - - var tuple = ((string Type, IPartition? Partition, IFilesystem? Filesystem))result!; - Assert.AreEqual("file", tuple.Type, "Type should be 'file'."); - Assert.IsNotNull(tuple.Partition, "Partition should not be null."); - Assert.IsNotNull(tuple.Filesystem, "Filesystem should not be null."); - } + // Verify all bytes except position 100 are unchanged + for (int i = 0; i < sectorSize; i++) + if (i == 100) + Assert.AreEqual(0xAB, readBuffer[i], "Byte at position 100 should be the written value."); else + Assert.AreEqual((byte)(i & 0xFF), readBuffer[i], $"Byte at position {i} should be unchanged."); + } + + [Test] + public async Task Test_RawDisk_WriteSpanningSectorBoundary_DataMatches() + { + var sectorSize = _writableRawDisk.SectorSize; + + // Write known patterns to sectors 6 and 7 + var sector6Data = new byte[sectorSize]; + var sector7Data = new byte[sectorSize]; + for (int i = 0; i < sectorSize; i++) + { + sector6Data[i] = (byte)(i | 0xD0); + sector7Data[i] = (byte)(i | 0xE0); + } + + await _writableRawDisk.WriteSectorsAsync(6, sector6Data, CancellationToken.None); + await _writableRawDisk.WriteSectorsAsync(7, sector7Data, CancellationToken.None); + + // Write spanning sector boundary: offset = sector_size - 4, length = 8 + var spanOffset = 6 * sectorSize + sectorSize - 4; + var spanData = new byte[] { 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88 }; + var bytesWritten = await _writableRawDisk.WriteBytesAsync(spanOffset, spanData, CancellationToken.None); + + Assert.AreEqual(spanData.Length, bytesWritten, "Should have written all bytes."); + + // Read back both sectors + using var readStream = await _writableRawDisk.ReadSectorsAsync(6, 2, CancellationToken.None); + var readBuffer = new byte[sectorSize * 2]; + var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(sectorSize * 2, bytesRead, "Should have read two sectors."); + + // Verify sector 6: last 4 bytes should be the first 4 of our written data + for (int i = 0; i < sectorSize - 4; i++) + Assert.AreEqual((byte)(i | 0xD0), readBuffer[i], $"Byte at position {i} in sector 6 should be unchanged."); + for (int i = 0; i < 4; i++) + Assert.AreEqual(spanData[i], readBuffer[sectorSize - 4 + i], $"Span byte at position {i} in sector 6 should match."); + + // Verify sector 7: first 4 bytes should be the last 4 of our written data + for (int i = 0; i < 4; i++) + Assert.AreEqual(spanData[4 + i], readBuffer[sectorSize + i], $"Span byte at position {4 + i} in sector 7 should match."); + for (int i = 4; i < sectorSize; i++) + Assert.AreEqual((byte)(i | 0xE0), readBuffer[sectorSize + i], $"Byte at position {i} in sector 7 should be unchanged."); + } + + [Test] + public async Task Test_RawDisk_WriteAtLastSector_NoOverflow() + { + var sectorSize = _writableRawDisk.SectorSize; + var diskSize = _writableRawDisk.Size; + + // Calculate the last sector + var lastSector = (diskSize / sectorSize) - 1; + + // Write a full sector at the last valid sector + var writeData = new byte[sectorSize]; + new Random(42).NextBytes(writeData); + + var bytesWritten = await _writableRawDisk.WriteSectorsAsync(lastSector, writeData, CancellationToken.None); + Assert.AreEqual(sectorSize, bytesWritten, "Should have written a full sector."); + + // Read it back and verify + using var readStream = await _writableRawDisk.ReadSectorsAsync(lastSector, 1, CancellationToken.None); + var readBuffer = new byte[sectorSize]; + var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector."); + Assert.AreEqual(writeData, readBuffer, "Data should match."); + } + + [Test] + public async Task Test_RawDisk_WriteUnalignedWithMemory_DataMatches() + { + var sectorSize = _writableRawDisk.SectorSize; + + // Write known pattern to sector 8 + var sectorData = new byte[sectorSize]; + for (int i = 0; i < sectorSize; i++) + sectorData[i] = (byte)(i | 0xF0); + + await _writableRawDisk.WriteSectorsAsync(8, sectorData, CancellationToken.None); + + // Write at unaligned offset using Memory + var unalignedOffset = 8 * sectorSize + 10; + var writeData = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD, 0xEE }; + var bytesWritten = await _writableRawDisk.WriteBytesAsync(unalignedOffset, writeData.AsMemory(), CancellationToken.None); + + Assert.AreEqual(writeData.Length, bytesWritten, "Should have written all bytes."); + + // Read back and verify + using var readStream = await _writableRawDisk.ReadSectorsAsync(8, 1, CancellationToken.None); + var readBuffer = new byte[sectorSize]; + var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector."); + + // Verify data before written region + for (int i = 0; i < 10; i++) + Assert.AreEqual((byte)(i | 0xF0), readBuffer[i], $"Byte at position {i} should be unchanged."); + + // Verify written data + for (int i = 0; i < writeData.Length; i++) + Assert.AreEqual(writeData[i], readBuffer[10 + i], $"Written byte at position {10 + i} should match."); + + // Verify data after written region + for (int i = 10 + writeData.Length; i < sectorSize; i++) + Assert.AreEqual((byte)(i | 0xF0), readBuffer[i], $"Byte at position {i} should be unchanged."); + } + + #endregion + + #region PartitionTableFactory detection tests + + [Test] + public async Task Test_PartitionTableFactory_GPTBytes_DetectsGPT() + { + var sectorSize = 512; + var diskBytes = new byte[sectorSize * 4]; + + // MBR (sector 0) - Protective MBR with GPT signature + diskBytes[510] = 0x55; + diskBytes[511] = 0xAA; + diskBytes[450] = 0xEE; + + // GPT header (sector 1) - "EFI PART" signature + var gptSignature = "EFI PART"u8.ToArray(); + Buffer.BlockCopy(gptSignature, 0, diskBytes, sectorSize, 8); + + var partitionTable = await PartitionTableFactory.CreateAsync(diskBytes, sectorSize, CancellationToken.None); + + Assert.IsNotNull(partitionTable, "Partition table should be detected."); + Assert.AreEqual(PartitionTableType.GPT, partitionTable!.TableType, "Should detect GPT partition table."); + } + + [Test] + public async Task Test_PartitionTableFactory_MBRBytes_DetectsMBR() + { + var sectorSize = 512; + var diskBytes = new byte[sectorSize]; + + diskBytes[510] = 0x55; + diskBytes[511] = 0xAA; + diskBytes[450] = 0x83; + + var partitionTable = await PartitionTableFactory.CreateAsync(diskBytes, sectorSize, CancellationToken.None); + + Assert.IsNotNull(partitionTable, "Partition table should be detected."); + Assert.AreEqual(PartitionTableType.MBR, partitionTable!.TableType, "Should detect MBR partition table."); + } + + [Test] + public async Task Test_PartitionTableFactory_InvalidBytes_ReturnsUnknown() + { + var sectorSize = 512; + var diskBytes = new byte[sectorSize]; + + var partitionTable = await PartitionTableFactory.CreateAsync(diskBytes, sectorSize, CancellationToken.None); + + Assert.IsNotNull(partitionTable, "Partition table should be created even for unknown type."); + Assert.AreEqual(PartitionTableType.Unknown, partitionTable!.TableType, "Should detect Unknown partition table for invalid bytes."); + } + + [Test] + public async Task Test_PartitionTableFactory_ProtectiveMBRType_DetectsGPT() + { + var sectorSize = 512; + var diskBytes = new byte[sectorSize * 4]; + + diskBytes[510] = 0x55; + diskBytes[511] = 0xAA; + diskBytes[450] = 0xEE; + + var gptSignature = "EFI PART"u8.ToArray(); + Buffer.BlockCopy(gptSignature, 0, diskBytes, sectorSize, 8); + + var partitionTable = await PartitionTableFactory.CreateAsync(diskBytes, sectorSize, CancellationToken.None); + + Assert.IsNotNull(partitionTable, "Partition table should be detected."); + Assert.AreEqual(PartitionTableType.GPT, partitionTable!.TableType, "Should detect GPT when protective MBR type (0xEE) is present."); + } + + [Test] + public async Task Test_PartitionTableFactory_ProtectiveMBRWithoutGptHeader_FallsBackToMBR() + { + var sectorSize = 512; + var diskBytes = new byte[sectorSize * 2]; + + diskBytes[510] = 0x55; + diskBytes[511] = 0xAA; + diskBytes[450] = 0xEE; + + var partitionTable = await PartitionTableFactory.CreateAsync(diskBytes, sectorSize, CancellationToken.None); + + Assert.IsNotNull(partitionTable, "Partition table should be detected."); + Assert.AreEqual(PartitionTableType.MBR, partitionTable!.TableType, "Should fall back to MBR when GPT header is invalid."); + } + + [Test] + public async Task Test_PartitionTableFactory_GPTFromRealDisk_DetectsGPT() + { + var sectorSize = s_gptRawDisk!.SectorSize; + var sectorsToRead = 34; + using var stream = await s_gptRawDisk.ReadSectorsAsync(0, sectorsToRead, CancellationToken.None); + var diskBytes = new byte[sectorSize * sectorsToRead]; + await stream.ReadAtLeastAsync(diskBytes, diskBytes.Length, cancellationToken: CancellationToken.None); + + var partitionTable = await PartitionTableFactory.CreateAsync(diskBytes, sectorSize, CancellationToken.None); + + Assert.IsNotNull(partitionTable, "Partition table should be detected from real disk."); + Assert.AreEqual(PartitionTableType.GPT, partitionTable!.TableType, "Should detect GPT from real disk bytes."); + } + + #endregion + + #region GPT and MBR partition table parsing tests + + [Test] + public async Task Test_GPT_ParseFromRealDisk_ReturnsCorrectPartitions() + { + var sectorSize = s_gptRawDisk!.SectorSize; + + // Read enough sectors to include protective MBR, GPT header, and partition entries + var sectorsToRead = 34; + using var stream = await s_gptRawDisk.ReadSectorsAsync(0, sectorsToRead, CancellationToken.None); + var diskBytes = new byte[sectorSize * sectorsToRead]; + await stream.ReadAtLeastAsync(diskBytes, diskBytes.Length, cancellationToken: CancellationToken.None); + + // Parse the GPT from bytes + var gpt = new GPT(null); + var parsed = await gpt.ParseAsync(diskBytes, sectorSize, CancellationToken.None); + + Assert.IsTrue(parsed, "GPT should parse successfully from real disk bytes."); + + // Verify partition count - we created 2 partitions + var partitions = new List(); + await foreach (var partition in gpt.EnumeratePartitions(CancellationToken.None)) + { + partitions.Add(partition); + } + + Assert.AreEqual(2, partitions.Count, "Should have 2 partitions."); + + // Verify partition properties + var firstPartition = partitions[0]; + Assert.IsNotNull(firstPartition, "First partition should exist."); + Assert.AreEqual(1, firstPartition.PartitionNumber, "Partition number should be 1."); + Assert.Greater(firstPartition.StartOffset, 0, "StartOffset should be greater than 0."); + Assert.Greater(firstPartition.Size, 0, "Size should be greater than 0."); + + // Verify sector alignment + Assert.AreEqual(0, firstPartition.StartOffset % sectorSize, + "StartOffset should be sector-aligned."); + Assert.AreEqual(0, firstPartition.Size % sectorSize, + "Size should be sector-aligned."); + + gpt.Dispose(); + } + + [Test] + public async Task Test_MBR_ParseFromRealDisk_ReturnsCorrectPartitions() + { + var sectorSize = s_mbrRawDisk!.SectorSize; + + // Read the MBR sector (first 512 bytes) + using var stream = await s_mbrRawDisk.ReadSectorsAsync(0, 1, CancellationToken.None); + var mbrBytes = new byte[sectorSize]; + await stream.ReadAtLeastAsync(mbrBytes, mbrBytes.Length, cancellationToken: CancellationToken.None); + + // Parse the MBR from bytes + var mbr = new MBR(null); + var parsed = await mbr.ParseAsync(mbrBytes, sectorSize, CancellationToken.None); + + Assert.IsTrue(parsed, "MBR should parse successfully from real disk bytes."); + + // Verify partition entries - we created 2 partitions + Assert.AreEqual(2, mbr.NumPartitionEntries, "Should have 2 partition entries."); + + // Verify via EnumeratePartitions + var partitions = new List(); + await foreach (var partition in mbr.EnumeratePartitions(CancellationToken.None)) + { + partitions.Add(partition); + } + + Assert.AreEqual(2, partitions.Count, "Should enumerate 2 partitions."); + + // Verify partition properties + var firstPartition = partitions[0]; + Assert.IsNotNull(firstPartition, "First partition should exist."); + Assert.AreEqual(1, firstPartition.PartitionNumber, "Partition number should be 1."); + Assert.Greater(firstPartition.StartOffset, 0, "StartOffset should be greater than 0."); + Assert.Greater(firstPartition.Size, 0, "Size should be greater than 0."); + + // Verify sector alignment + Assert.AreEqual(0, firstPartition.StartOffset % sectorSize, + "StartOffset should be sector-aligned."); + Assert.AreEqual(0, firstPartition.Size % sectorSize, + "Size should be sector-aligned."); + + mbr.Dispose(); + } + + [Test] + public async Task Test_GPT_EnumeratePartitions_ReturnsCorrectCount() + { + var sectorSize = s_gptRawDisk!.SectorSize; + + using var stream = await s_gptRawDisk.ReadSectorsAsync(0, 34, CancellationToken.None); + var diskBytes = new byte[sectorSize * 34]; + await stream.ReadAtLeastAsync(diskBytes, diskBytes.Length, cancellationToken: CancellationToken.None); + + var gpt = new GPT(null); + await gpt.ParseAsync(diskBytes, sectorSize, CancellationToken.None); + + var count = 0; + await foreach (var partition in gpt.EnumeratePartitions(CancellationToken.None)) + { + count++; + Assert.IsNotNull(partition, "Partition should not be null."); + Assert.Greater(partition.PartitionNumber, 0, "Partition number should be positive."); + } + + Assert.AreEqual(2, count, "Should enumerate exactly 2 partitions."); + + gpt.Dispose(); + } + + [Test] + public async Task Test_MBR_EnumeratePartitions_ReturnsCorrectCount() + { + var sectorSize = s_mbrRawDisk!.SectorSize; + + using var stream = await s_mbrRawDisk.ReadSectorsAsync(0, 1, CancellationToken.None); + var mbrBytes = new byte[sectorSize]; + await stream.ReadAtLeastAsync(mbrBytes, mbrBytes.Length, cancellationToken: CancellationToken.None); + + var mbr = new MBR(null); + await mbr.ParseAsync(mbrBytes, sectorSize, CancellationToken.None); + + var count = 0; + await foreach (var partition in mbr.EnumeratePartitions(CancellationToken.None)) + { + count++; + Assert.IsNotNull(partition, "Partition should not be null."); + Assert.Greater(partition.PartitionNumber, 0, "Partition number should be positive."); + } + + Assert.AreEqual(2, count, "Should enumerate exactly 2 partitions."); + + mbr.Dispose(); + } + + [Test] + public async Task Test_GPT_GetPartitionAsync_ReturnsCorrectPartition() + { + var sectorSize = s_gptRawDisk!.SectorSize; + + using var stream = await s_gptRawDisk.ReadSectorsAsync(0, 34, CancellationToken.None); + var diskBytes = new byte[sectorSize * 34]; + await stream.ReadAtLeastAsync(diskBytes, diskBytes.Length, cancellationToken: CancellationToken.None); + + var gpt = new GPT(null); + await gpt.ParseAsync(diskBytes, sectorSize, CancellationToken.None); + + // Test GetPartitionAsync for partition 1 (should exist) + var partition1 = await gpt.GetPartitionAsync(1, CancellationToken.None); + Assert.IsNotNull(partition1, "Partition 1 should exist."); + Assert.AreEqual(1, partition1!.PartitionNumber, "Partition number should be 1."); + + // Test GetPartitionAsync for partition 2 (should exist) + var partition2 = await gpt.GetPartitionAsync(2, CancellationToken.None); + Assert.IsNotNull(partition2, "Partition 2 should exist."); + Assert.AreEqual(2, partition2!.PartitionNumber, "Partition number should be 2."); + + // Test GetPartitionAsync for partition 3 (should not exist) + var partition3 = await gpt.GetPartitionAsync(3, CancellationToken.None); + Assert.IsNull(partition3, "Partition 3 should not exist."); + + // Test GetPartitionAsync for invalid partition number 0 + var partition0 = await gpt.GetPartitionAsync(0, CancellationToken.None); + Assert.IsNull(partition0, "Partition 0 should not exist (invalid number)."); + + gpt.Dispose(); + } + + [Test] + public async Task Test_MBR_GetPartitionAsync_ReturnsCorrectPartition() + { + var sectorSize = s_mbrRawDisk!.SectorSize; + + using var stream = await s_mbrRawDisk.ReadSectorsAsync(0, 1, CancellationToken.None); + var mbrBytes = new byte[sectorSize]; + await stream.ReadAtLeastAsync(mbrBytes, mbrBytes.Length, cancellationToken: CancellationToken.None); + + var mbr = new MBR(null); + await mbr.ParseAsync(mbrBytes, sectorSize, CancellationToken.None); + + // Test GetPartitionAsync for partition 1 (should exist) + var partition1 = await mbr.GetPartitionAsync(1, CancellationToken.None); + Assert.IsNotNull(partition1, "Partition 1 should exist."); + Assert.AreEqual(1, partition1!.PartitionNumber, "Partition number should be 1."); + + // Test GetPartitionAsync for partition 2 (should exist) + var partition2 = await mbr.GetPartitionAsync(2, CancellationToken.None); + Assert.IsNotNull(partition2, "Partition 2 should exist."); + Assert.AreEqual(2, partition2!.PartitionNumber, "Partition number should be 2."); + + // Test GetPartitionAsync for partition 3 (should not exist) + var partition3 = await mbr.GetPartitionAsync(3, CancellationToken.None); + Assert.IsNull(partition3, "Partition 3 should not exist."); + + // Test GetPartitionAsync for invalid partition number 0 + var partition0 = await mbr.GetPartitionAsync(0, CancellationToken.None); + Assert.IsNull(partition0, "Partition 0 should not exist (invalid number)."); + + mbr.Dispose(); + } + + [Test] + public async Task Test_GPT_PartitionAlignment_IsSectorAligned() + { + var sectorSize = s_gptRawDisk!.SectorSize; + + using var stream = await s_gptRawDisk.ReadSectorsAsync(0, 34, CancellationToken.None); + var diskBytes = new byte[sectorSize * 34]; + await stream.ReadAtLeastAsync(diskBytes, diskBytes.Length, cancellationToken: CancellationToken.None); + + var gpt = new GPT(null); + await gpt.ParseAsync(diskBytes, sectorSize, CancellationToken.None); + + // Verify all partitions are sector-aligned + await foreach (var partition in gpt.EnumeratePartitions(CancellationToken.None)) + { + Assert.AreEqual(0, partition.StartOffset % sectorSize, + $"Partition {partition.PartitionNumber} StartOffset ({partition.StartOffset}) should be sector-aligned (sector size: {sectorSize})."); + Assert.AreEqual(0, partition.Size % sectorSize, + $"Partition {partition.PartitionNumber} Size ({partition.Size}) should be sector-aligned (sector size: {sectorSize})."); + } + + gpt.Dispose(); + } + + [Test] + public async Task Test_MBR_PartitionAlignment_IsSectorAligned() + { + var sectorSize = s_mbrRawDisk!.SectorSize; + + using var stream = await s_mbrRawDisk.ReadSectorsAsync(0, 1, CancellationToken.None); + var mbrBytes = new byte[sectorSize]; + await stream.ReadAtLeastAsync(mbrBytes, mbrBytes.Length, cancellationToken: CancellationToken.None); + + var mbr = new MBR(null); + await mbr.ParseAsync(mbrBytes, sectorSize, CancellationToken.None); + + // Verify all partitions are sector-aligned + await foreach (var partition in mbr.EnumeratePartitions(CancellationToken.None)) + { + Assert.AreEqual(0, partition.StartOffset % sectorSize, + $"Partition {partition.PartitionNumber} StartOffset ({partition.StartOffset}) should be sector-aligned (sector size: {sectorSize})."); + Assert.AreEqual(0, partition.Size % sectorSize, + $"Partition {partition.PartitionNumber} Size ({partition.Size}) should be sector-aligned (sector size: {sectorSize})."); + } + + mbr.Dispose(); + } + + #endregion + + #region PartitionWriteStream tests + + [Test] + public async Task Test_PartitionWriteStream_WriteAndDispose_DataFlushedToDisk() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionOffset = 10 * sectorSize; + var partitionSize = 4 * sectorSize; + + // Write data using PartitionWriteStream + var writeData = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 }; + using (var stream = new PartitionWriteStream(_writableRawDisk, partitionOffset, partitionSize)) + await stream.WriteAsync(writeData, CancellationToken.None); + + // Read back and verify data was flushed to disk + using var readStream = await _writableRawDisk.ReadBytesAsync(partitionOffset, writeData.Length, CancellationToken.None); + var readBuffer = new byte[writeData.Length]; + var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(writeData.Length, bytesRead, "Should have read the written data."); + Assert.AreEqual(writeData, readBuffer, "Data should match what was written."); + } + + [Test] + public void Test_PartitionWriteStream_SeekBegin_ValidPosition() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 4 * sectorSize; + + using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize); + + // Test Seek with SeekOrigin.Begin + var newPosition = stream.Seek(100, SeekOrigin.Begin); + Assert.AreEqual(100, newPosition, "Position should be 100 after seeking from Begin."); + Assert.AreEqual(100, stream.Position, "Stream.Position should match returned position."); + } + + [Test] + public void Test_PartitionWriteStream_SeekCurrent_ValidPosition() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 4 * sectorSize; + + using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize); + + // Write some data first to move position + stream.Write([0x01, 0x02, 0x03], 0, 3); + Assert.AreEqual(3, stream.Position, "Position should be 3 after writing 3 bytes."); + + // Test Seek with SeekOrigin.Current + var newPosition = stream.Seek(50, SeekOrigin.Current); + Assert.AreEqual(53, newPosition, "Position should be 53 after seeking 50 from current position 3."); + Assert.AreEqual(53, stream.Position, "Stream.Position should match returned position."); + } + + [Test] + public void Test_PartitionWriteStream_SeekEnd_ValidPosition() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 4 * sectorSize; + + using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize); + + // Write some data first to establish length + stream.Write([0x01, 0x02, 0x03, 0x04, 0x05], 0, 5); + Assert.AreEqual(5, stream.Length, "Length should be 5 after writing 5 bytes."); + + // Test Seek with SeekOrigin.End + var newPosition = stream.Seek(-2, SeekOrigin.End); + Assert.AreEqual(3, newPosition, "Position should be 3 after seeking -2 from end of length 5."); + Assert.AreEqual(3, stream.Position, "Stream.Position should match returned position."); + } + + [Test] + public void Test_PartitionWriteStream_WriteBeyondSize_ThrowsIOException() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionOffset = 10 * sectorSize; + var partitionSize = 2 * sectorSize; + + using var stream = new PartitionWriteStream(_writableRawDisk, partitionOffset, partitionSize); + + // Try to write more than partition size + var largeBuffer = new byte[partitionSize + 100]; + new Random().NextBytes(largeBuffer); + + var ex = Assert.Throws(() => + { + stream.Write(largeBuffer, 0, largeBuffer.Length); + }, "Writing beyond partition size should throw IOException."); + + StringAssert.Contains("partition size", ex!.Message, "Exception message should mention partition size."); + } + + [Test] + public void Test_PartitionWriteStream_SeekBeyondSize_ThrowsIOException() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 2 * sectorSize; + + using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize); + + // Try to seek beyond partition size + var ex = Assert.Throws(() => + { + stream.Seek(partitionSize + 100, SeekOrigin.Begin); + }, "Seeking beyond partition size should throw IOException."); + + StringAssert.Contains("partition size", ex!.Message, "Exception message should mention partition size."); + } + + [Test] + public void Test_PartitionWriteStream_SeekNegativePosition_ThrowsIOException() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 2 * sectorSize; + + using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize); + + // Try to seek to negative position + Assert.Throws(() => + { + stream.Seek(-100, SeekOrigin.Begin); + }, "Seeking to negative position should throw IOException."); + } + + [Test] + public async Task Test_PartitionWriteStream_WriteAtUnalignedPosition_DataMatches() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionOffset = 15 * sectorSize; + var partitionSize = 4 * sectorSize; + + // Write data at various unaligned positions + using (var stream = new PartitionWriteStream(_writableRawDisk, partitionOffset, partitionSize)) + { + // Write at position 0 + stream.Write([0xAA, 0xBB], 0, 2); + + // Seek to unaligned position + stream.Seek(7, SeekOrigin.Begin); + stream.Write([0xCC, 0xDD, 0xEE], 0, 3); + + // Seek to another unaligned position + stream.Seek(20, SeekOrigin.Begin); + stream.Write([0x11, 0x22, 0x33, 0x44], 0, 4); + } + + // Read back and verify + using var readStream = await _writableRawDisk.ReadBytesAsync(partitionOffset, 25, CancellationToken.None); + var readBuffer = new byte[25]; + var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(25, bytesRead, "Should read exactly 25 bytes."); + + // Verify data at position 0 + Assert.AreEqual(0xAA, readBuffer[0], "Byte at position 0 should be 0xAA."); + Assert.AreEqual(0xBB, readBuffer[1], "Byte at position 1 should be 0xBB."); + + // Verify data at position 7 + Assert.AreEqual(0xCC, readBuffer[7], "Byte at position 7 should be 0xCC."); + Assert.AreEqual(0xDD, readBuffer[8], "Byte at position 8 should be 0xDD."); + Assert.AreEqual(0xEE, readBuffer[9], "Byte at position 9 should be 0xEE."); + + // Verify data at position 20 + Assert.AreEqual(0x11, readBuffer[20], "Byte at position 20 should be 0x11."); + Assert.AreEqual(0x22, readBuffer[21], "Byte at position 21 should be 0x22."); + Assert.AreEqual(0x33, readBuffer[22], "Byte at position 22 should be 0x33."); + Assert.AreEqual(0x44, readBuffer[23], "Byte at position 23 should be 0x44."); + } + + [Test] + public async Task Test_PartitionWriteStream_WriteMultipleTimes_DataMatches() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionOffset = 20 * sectorSize; + var partitionSize = 3 * sectorSize; + + // Write data in multiple operations + using (var stream = new PartitionWriteStream(_writableRawDisk, partitionOffset, partitionSize)) + { + // First write + stream.Write([0x01, 0x02, 0x03], 0, 3); + + // Second write (should append) + stream.Write([0x04, 0x05], 0, 2); + + // Third write + stream.Write([0x06, 0x07, 0x08, 0x09], 0, 4); + } + + // Read back and verify all data was written + using var readStream = await _writableRawDisk.ReadBytesAsync(partitionOffset, 9, CancellationToken.None); + var readBuffer = new byte[9]; + var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(9, bytesRead, "Should read exactly 9 bytes."); + + var expected = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09 }; + Assert.AreEqual(expected, readBuffer, "All written data should match."); + } + + [Test] + public void Test_PartitionWriteStream_SetLengthBeyondMaxSize_ThrowsIOException() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 2 * sectorSize; + + using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize); + + // Try to set length beyond partition size + var ex = Assert.Throws(() => + { + stream.SetLength(partitionSize + 100); + }, "Setting length beyond partition size should throw IOException."); + + StringAssert.Contains("partition size", ex!.Message, "Exception message should mention partition size."); + } + + [Test] + public async Task Test_PartitionWriteStream_SetLengthWithinMaxSize_SetsLength() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionOffset = 10 * sectorSize; + var partitionSize = 2 * sectorSize; + + using (var stream = new PartitionWriteStream(_writableRawDisk, partitionOffset, partitionSize)) + { + // Set length to a specific value within bounds + stream.SetLength(100); + Assert.AreEqual(100, stream.Length, "Length should be set to 100."); + + // Write some data at position 0 + stream.Write([0xAA, 0xBB, 0xCC], 0, 3); + // Length should be max(SetLength(100), written position) = 100 + Assert.AreEqual(100, stream.Length, "Length should remain 100 (SetLength value)."); + } + + // Verify the 3 bytes were written at position 0 and length 100 bytes are flushed + using var readStream = await _writableRawDisk.ReadBytesAsync(partitionOffset, 100, CancellationToken.None); + var readBuffer = new byte[100]; + var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(100, bytesRead, "Should read exactly 100 bytes."); + + Assert.AreEqual(0xAA, readBuffer[0], "Byte at position 0 should be 0xAA."); + Assert.AreEqual(0xBB, readBuffer[1], "Byte at position 1 should be 0xBB."); + Assert.AreEqual(0xCC, readBuffer[2], "Byte at position 2 should be 0xCC."); + } + + [Test] + public void Test_PartitionWriteStream_CanRead_ReturnsFalse() + { + var sectorSize = _writableRawDisk.SectorSize; + using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, sectorSize); + + Assert.IsFalse(stream.CanRead, "CanRead should be false."); + } + + [Test] + public void Test_PartitionWriteStream_CanWrite_ReturnsTrue() + { + var sectorSize = _writableRawDisk.SectorSize; + using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, sectorSize); + + Assert.IsTrue(stream.CanWrite, "CanWrite should be true."); + } + + [Test] + public void Test_PartitionWriteStream_CanSeek_ReturnsTrue() + { + var sectorSize = _writableRawDisk.SectorSize; + using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, sectorSize); + + Assert.IsTrue(stream.CanSeek, "CanSeek should be true."); + } + + [Test] + public void Test_PartitionWriteStream_Read_ThrowsNotSupported() + { + var sectorSize = _writableRawDisk.SectorSize; + using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, sectorSize); + + Assert.Throws(() => + { + var _ = stream.Read(new byte[10], 0, 10); + }, "Read should throw NotSupportedException."); + } + + [Test] + public async Task Test_PartitionWriteStream_WriteEmptyData_NoFlush() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionOffset = 10 * sectorSize; + var partitionSize = sectorSize; + + // Read initial data at this offset (may not be zero due to previous tests) + using var initialReadStream = await _writableRawDisk.ReadBytesAsync(partitionOffset, 10, CancellationToken.None); + var initialBuffer = new byte[10]; + var initialBytesRead = await initialReadStream.ReadAsync(initialBuffer.AsMemory(), CancellationToken.None); + Assert.AreEqual(10, initialBytesRead, "Should read 10 bytes."); + + // Create stream but write nothing + using (var stream = new PartitionWriteStream(_writableRawDisk, partitionOffset, partitionSize)) + // Don't write anything + Assert.AreEqual(0, stream.Length, "Length should be 0 when nothing is written."); + + // Read back and verify no data was flushed (should be unchanged) + using var readStream = await _writableRawDisk.ReadBytesAsync(partitionOffset, 10, CancellationToken.None); + var readBuffer = new byte[10]; + var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(10, bytesRead, "Should read 10 bytes."); + + // All bytes should be unchanged from before the stream was created + for (int i = 0; i < readBuffer.Length; i++) + { + Assert.AreEqual(initialBuffer[i], readBuffer[i], $"Byte at position {i} should be unchanged (no data written)."); + } + } + + #endregion + + #region PartitionTableSynthesizer tests + + [Test] + public void Test_PartitionTableSynthesizer_SynthesizeMBR_ContainsValidBootSignature() + { + // Create a GeometryMetadata with known partition info + var metadata = new GeometryMetadata + { + Disk = new DiskGeometry { - // On Linux/macOS, backslash is a filename character, not a separator - // The entire string becomes one segment, which fails partition parsing - var ex = Assert.Throws(() => - parsePathMethod!.Invoke(provider, ["part_GPT_1\\fs_FAT32\\test\\file.txt"]), - "Should throw for backslash path on non-Windows platforms."); - Assert.IsInstanceOf(ex!.InnerException, "Inner exception should be InvalidOperationException."); - } + SectorSize = 512, + Size = 100 * MiB, + Sectors = (int)(100 * MiB / 512) + }, + PartitionTable = new PartitionTableGeometry + { + Type = PartitionTableType.MBR, + SectorSize = 512 + }, + Partitions = + [ + new PartitionGeometry + { + Number = 1, + Type = PartitionType.Primary, + FilesystemType = FileSystemType.FAT32, + StartOffset = 512 * 2048, // Start at sector 2048 + Size = 20 * MiB, + TableType = PartitionTableType.MBR + }, + new PartitionGeometry + { + Number = 2, + Type = PartitionType.Primary, + FilesystemType = FileSystemType.FAT32, + StartOffset = 512 * 2048 + 20 * MiB, + Size = 20 * MiB, + TableType = PartitionTableType.MBR + } + ] + }; + + // Synthesize MBR + var mbrData = PartitionTableSynthesizer.SynthesizeMBR(metadata); + + Assert.IsNotNull(mbrData, "MBR data should not be null."); + Assert.GreaterOrEqual(mbrData.Length, 512, "MBR should be at least one sector."); + + // Verify boot signature at offset 510-511 (0x55 0xAA) + Assert.AreEqual(0x55, mbrData[510], "Boot signature first byte should be 0x55."); + Assert.AreEqual(0xAA, mbrData[511], "Boot signature second byte should be 0xAA."); + } + + [Test] + public void Test_PartitionTableSynthesizer_SynthesizeGPT_ContainsValidSignature() + { + // Create a GeometryMetadata with known partition info + var metadata = new GeometryMetadata + { + Disk = new DiskGeometry + { + SectorSize = 512, + Size = 100 * MiB, + Sectors = (int)(100 * MiB / 512) + }, + PartitionTable = new PartitionTableGeometry + { + Type = PartitionTableType.GPT, + SectorSize = 512, + HasProtectiveMbr = true + }, + Partitions = + [ + new PartitionGeometry + { + Number = 1, + Type = PartitionType.Primary, + FilesystemType = FileSystemType.FAT32, + StartOffset = 512 * 2048, // Start at sector 2048 + Size = 20 * MiB, + TableType = PartitionTableType.GPT, + Name = "Partition 1" + }, + new PartitionGeometry + { + Number = 2, + Type = PartitionType.Primary, + FilesystemType = FileSystemType.FAT32, + StartOffset = 512 * 2048 + 20 * MiB, + Size = 20 * MiB, + TableType = PartitionTableType.GPT, + Name = "Partition 2" + } + ] + }; + + // Synthesize GPT + var gptData = PartitionTableSynthesizer.SynthesizeGPT(metadata); + + Assert.IsNotNull(gptData, "GPT data should not be null."); + Assert.Greater(gptData.Length, 512 * 2, "GPT data should include protective MBR and GPT header."); + + // Verify protective MBR boot signature at offset 510-511 + Assert.AreEqual(0x55, gptData[510], "Protective MBR boot signature first byte should be 0x55."); + Assert.AreEqual(0xAA, gptData[511], "Protective MBR boot signature second byte should be 0xAA."); + + // Verify GPT signature "EFI PART" at sector 1 (offset 512) + var expectedSignature = "EFI PART"u8.ToArray(); + for (int i = 0; i < 8; i++) + Assert.AreEqual(expectedSignature[i], gptData[512 + i], $"GPT signature byte {i} should match."); + } + + [Test] + public async Task Test_PartitionTableSynthesizer_MBRRoundTrip_PreservesPartitionData() + { + // Create a GeometryMetadata with known partition info + var sectorSize = 512; + var metadata = new GeometryMetadata + { + Disk = new DiskGeometry + { + SectorSize = sectorSize, + Size = 100 * MiB, + Sectors = (int)(100 * MiB / sectorSize) + }, + PartitionTable = new PartitionTableGeometry + { + Type = PartitionTableType.MBR, + SectorSize = sectorSize + }, + Partitions = + [ + new PartitionGeometry + { + Number = 1, + Type = PartitionType.Primary, + FilesystemType = FileSystemType.FAT32, + StartOffset = sectorSize * 2048, + Size = 20 * MiB, + TableType = PartitionTableType.MBR + }, + new PartitionGeometry + { + Number = 2, + Type = PartitionType.Primary, + FilesystemType = FileSystemType.FAT32, + StartOffset = sectorSize * 2048 + 20 * MiB, + Size = 20 * MiB, + TableType = PartitionTableType.MBR + } + ] + }; + + // Synthesize MBR + var mbrData = PartitionTableSynthesizer.SynthesizeMBR(metadata); + + // Parse with PartitionTableFactory + var partitionTable = await PartitionTableFactory.CreateAsync(mbrData, sectorSize, CancellationToken.None); + + Assert.IsNotNull(partitionTable, "Partition table should be detected."); + Assert.AreEqual(PartitionTableType.MBR, partitionTable!.TableType, "Should detect MBR partition table."); + + // Verify partition count + var partitions = new List(); + await foreach (var partition in partitionTable.EnumeratePartitions(CancellationToken.None)) + partitions.Add(partition); + + Assert.AreEqual(2, partitions.Count, "Should have 2 partitions."); + + // Verify partition properties match original metadata + for (int i = 0; i < partitions.Count; i++) + { + var originalPart = metadata.Partitions![i]; + var parsedPart = partitions[i]; + + Assert.AreEqual(originalPart.Number, parsedPart.PartitionNumber, $"Partition {i + 1} number should match."); + Assert.AreEqual(originalPart.StartOffset, parsedPart.StartOffset, $"Partition {i + 1} start offset should match."); + Assert.AreEqual(originalPart.Size, parsedPart.Size, $"Partition {i + 1} size should match."); } - /// - /// Tests that ParsePath throws appropriate exceptions for invalid paths. - /// - [Test] - public void Test_RestoreProvider_ParsePath_InvalidPartitionSegment_ThrowsException() + partitionTable.Dispose(); + } + + [Test] + public async Task Test_PartitionTableSynthesizer_GPTRoundTrip_PreservesPartitionData() + { + // Create a GeometryMetadata with known partition info + var sectorSize = 512; + var metadata = new GeometryMetadata { - using var provider = CreateRestoreProviderForPathParsingTests(); - var parsePathMethod = typeof(RestoreProvider).GetMethod("ParsePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + Disk = new DiskGeometry + { + SectorSize = sectorSize, + Size = 100 * MiB, + Sectors = (int)(100 * MiB / sectorSize) + }, + PartitionTable = new PartitionTableGeometry + { + Type = PartitionTableType.GPT, + SectorSize = sectorSize, + HasProtectiveMbr = true + }, + Partitions = + [ + new PartitionGeometry + { + Number = 1, + Type = PartitionType.Primary, + FilesystemType = FileSystemType.FAT32, + StartOffset = sectorSize * 2048, + Size = 20 * MiB, + TableType = PartitionTableType.GPT, + Name = "Test Partition 1" + }, + new PartitionGeometry + { + Number = 2, + Type = PartitionType.Primary, + FilesystemType = FileSystemType.FAT32, + StartOffset = sectorSize * 2048 + 20 * MiB, + Size = 20 * MiB, + TableType = PartitionTableType.GPT, + Name = "Test Partition 2" + } + ] + }; - Assert.IsNotNull(parsePathMethod, "ParsePath method should exist."); + // Synthesize GPT + var gptData = PartitionTableSynthesizer.SynthesizeGPT(metadata); - // Test with invalid partition segment format - this should still parse as file type - // but will fail when trying to parse the partition - var ex = Assert.Throws(() => - parsePathMethod!.Invoke(provider, ["part_INVALID/fs_NTFS/file.txt"]), - "Should throw for invalid partition segment."); - Assert.IsInstanceOf(ex!.InnerException, "Inner exception should be InvalidOperationException."); + // Parse with PartitionTableFactory + var partitionTable = await PartitionTableFactory.CreateAsync(gptData, sectorSize, CancellationToken.None); + + Assert.IsNotNull(partitionTable, "Partition table should be detected."); + Assert.AreEqual(PartitionTableType.GPT, partitionTable!.TableType, "Should detect GPT partition table."); + + // Verify partition count + var partitions = new List(); + await foreach (var partition in partitionTable.EnumeratePartitions(CancellationToken.None)) + partitions.Add(partition); + + Assert.AreEqual(2, partitions.Count, "Should have 2 partitions."); + + // Verify partition properties match original metadata + for (int i = 0; i < partitions.Count; i++) + { + var originalPart = metadata.Partitions![i]; + var parsedPart = partitions[i]; + + Assert.AreEqual(originalPart.Number, parsedPart.PartitionNumber, $"Partition {i + 1} number should match."); + Assert.AreEqual(originalPart.StartOffset, parsedPart.StartOffset, $"Partition {i + 1} start offset should match."); + Assert.AreEqual(originalPart.Size, parsedPart.Size, $"Partition {i + 1} size should match."); } - #endregion + partitionTable.Dispose(); + } - #region Edge Case and Boundary Tests - - /// - /// Tests reading at offset 0 (first sector) returns correct data. - /// - [Test] - public async Task Test_RawDisk_ReadAtOffsetZero_ReturnsCorrectData() + [Test] + public void Test_PartitionTableSynthesizer_SynthesizePartitionTable_MBR_ReturnsMBRData() + { + var metadata = new GeometryMetadata { - var sectorSize = _writableRawDisk.SectorSize; + Disk = new DiskGeometry { SectorSize = 512, Size = 50 * MiB }, + PartitionTable = new PartitionTableGeometry { Type = PartitionTableType.MBR }, + Partitions = + [ + new PartitionGeometry + { + Number = 1, + Type = PartitionType.Primary, + StartOffset = 512 * 2048, + Size = 20 * MiB, + TableType = PartitionTableType.MBR + } + ] + }; - // Write known pattern to sector 0 (first sector) - var writeBuffer = new byte[sectorSize]; - for (int i = 0; i < sectorSize; i++) - writeBuffer[i] = (byte)(i & 0xFF); + var result = PartitionTableSynthesizer.SynthesizePartitionTable(metadata); - await _writableRawDisk.WriteSectorsAsync(0, writeBuffer, CancellationToken.None); + Assert.IsNotNull(result, "Should return MBR data."); + Assert.AreEqual(0x55, result![510], "Should have valid boot signature."); + Assert.AreEqual(0xAA, result[511], "Should have valid boot signature."); + } - // Read at offset 0 - using var stream = await _writableRawDisk.ReadBytesAsync(0, sectorSize, CancellationToken.None); - var readBuffer = new byte[sectorSize]; - var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + [Test] + public void Test_PartitionTableSynthesizer_SynthesizePartitionTable_GPT_ReturnsGPTData() + { + var metadata = new GeometryMetadata + { + Disk = new DiskGeometry { SectorSize = 512, Size = 50 * MiB, Sectors = (int)(50 * MiB / 512) }, + PartitionTable = new PartitionTableGeometry { Type = PartitionTableType.GPT }, + Partitions = + [ + new PartitionGeometry + { + Number = 1, + Type = PartitionType.Primary, + StartOffset = 512 * 2048, + Size = 20 * MiB, + TableType = PartitionTableType.GPT + } + ] + }; - Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector."); - Assert.AreEqual(writeBuffer, readBuffer, "Data at offset 0 should match what was written."); + var result = PartitionTableSynthesizer.SynthesizePartitionTable(metadata); + + Assert.IsNotNull(result, "Should return GPT data."); + // Check for "EFI PART" signature at sector 1 + var expectedSignature = "EFI PART"u8.ToArray(); + for (int i = 0; i < 8; i++) + Assert.AreEqual(expectedSignature[i], result![512 + i], $"GPT signature byte {i} should match."); + } + + [Test] + public void Test_PartitionTableSynthesizer_SynthesizePartitionTable_NullPartitionTable_ReturnsNull() + { + var metadata = new GeometryMetadata + { + Disk = new DiskGeometry { SectorSize = 512 }, + PartitionTable = null + }; + + var result = PartitionTableSynthesizer.SynthesizePartitionTable(metadata); + + Assert.IsNull(result, "Should return null when PartitionTable is null."); + } + + [Test] + public void Test_PartitionTableSynthesizer_SynthesizePartitionTable_UnknownType_ReturnsNull() + { + var metadata = new GeometryMetadata + { + Disk = new DiskGeometry { SectorSize = 512 }, + PartitionTable = new PartitionTableGeometry { Type = PartitionTableType.Unknown } + }; + + var result = PartitionTableSynthesizer.SynthesizePartitionTable(metadata); + + Assert.IsNull(result, "Should return null for Unknown partition table type."); + } + + #endregion + + #region GeometryMetadata Serialization Tests + + /// + /// Tests that a fully populated GeometryMetadata can be serialized to JSON and + /// deserialized back, preserving all field values. + /// + [Test] + public void Test_GeometryMetadata_RoundTrip_PreservesAllFields() + { + // Create a GeometryMetadata with all fields populated + var original = new GeometryMetadata + { + Version = 1, + Disk = new DiskGeometry + { + DevicePath = "/dev/sda", + Size = 100 * MiB, + SectorSize = 512, + Sectors = (int)(100 * MiB / 512), + TableType = PartitionTableType.GPT + }, + PartitionTable = new PartitionTableGeometry + { + Type = PartitionTableType.GPT, + Size = 17408, // Typical GPT size + SectorSize = 512, + HasProtectiveMbr = true, + HeaderSize = 92 + }, + Partitions = + [ + new PartitionGeometry + { + Number = 1, + Type = PartitionType.Primary, + StartOffset = 512 * 2048, + Size = 40 * MiB, + Name = "Test Partition 1", + FilesystemType = FileSystemType.FAT32, + VolumeGuid = Guid.Parse("12345678-1234-1234-1234-123456789abc"), + TableType = PartitionTableType.GPT + }, + new PartitionGeometry + { + Number = 2, + Type = PartitionType.Primary, + StartOffset = 512 * 2048 + 40 * MiB, + Size = 40 * MiB, + Name = "Test Partition 2", + FilesystemType = FileSystemType.NTFS, + VolumeGuid = Guid.Parse("abcdef12-3456-7890-abcd-ef1234567890"), + TableType = PartitionTableType.GPT + } + ], + Filesystems = + [ + new FilesystemGeometry + { + PartitionNumber = 1, + Type = FileSystemType.FAT32, + PartitionStartOffset = 512 * 2048, + BlockSize = 4096, + Metadata = "{\"label\":\"FAT32_VOL\",\"serial\":\"1234-5678\"}" + }, + new FilesystemGeometry + { + PartitionNumber = 2, + Type = FileSystemType.NTFS, + PartitionStartOffset = 512 * 2048 + 40 * MiB, + BlockSize = 4096, + Metadata = "{\"label\":\"NTFS_VOL\",\"guid\":\"{12345678-1234-1234-1234-123456789ABC}\"}" + } + ] + }; + + // Serialize to JSON + var json = original.ToJson(); + + // Verify JSON is not null or empty + Assert.IsNotNull(json, "JSON output should not be null."); + Assert.IsNotEmpty(json, "JSON output should not be empty."); + + // Deserialize from JSON + var deserialized = GeometryMetadata.FromJson(json); + + // Verify deserialized object is not null + Assert.IsNotNull(deserialized, "Deserialized object should not be null."); + + // Verify Version + Assert.AreEqual(original.Version, deserialized!.Version, "Version should match."); + + // Verify Disk properties + Assert.IsNotNull(deserialized.Disk, "Disk should not be null after deserialization."); + Assert.AreEqual(original.Disk!.DevicePath, deserialized.Disk!.DevicePath, "Disk DevicePath should match."); + Assert.AreEqual(original.Disk.Size, deserialized.Disk.Size, "Disk Size should match."); + Assert.AreEqual(original.Disk.SectorSize, deserialized.Disk.SectorSize, "Disk SectorSize should match."); + Assert.AreEqual(original.Disk.Sectors, deserialized.Disk.Sectors, "Disk Sectors should match."); + Assert.AreEqual(original.Disk.TableType, deserialized.Disk.TableType, "Disk TableType should match."); + + // Verify PartitionTable properties + Assert.IsNotNull(deserialized.PartitionTable, "PartitionTable should not be null after deserialization."); + Assert.AreEqual(original.PartitionTable!.Type, deserialized.PartitionTable!.Type, "PartitionTable Type should match."); + Assert.AreEqual(original.PartitionTable.Size, deserialized.PartitionTable.Size, "PartitionTable Size should match."); + Assert.AreEqual(original.PartitionTable.SectorSize, deserialized.PartitionTable.SectorSize, "PartitionTable SectorSize should match."); + Assert.AreEqual(original.PartitionTable.HasProtectiveMbr, deserialized.PartitionTable.HasProtectiveMbr, "PartitionTable HasProtectiveMbr should match."); + Assert.AreEqual(original.PartitionTable.HeaderSize, deserialized.PartitionTable.HeaderSize, "PartitionTable HeaderSize should match."); + + // Verify Partitions + Assert.IsNotNull(deserialized.Partitions, "Partitions should not be null after deserialization."); + Assert.AreEqual(original.Partitions!.Count, deserialized.Partitions!.Count, "Partition count should match."); + + for (int i = 0; i < original.Partitions.Count; i++) + { + var originalPart = original.Partitions[i]; + var deserializedPart = deserialized.Partitions[i]; + + Assert.AreEqual(originalPart.Number, deserializedPart.Number, $"Partition {i + 1} Number should match."); + Assert.AreEqual(originalPart.Type, deserializedPart.Type, $"Partition {i + 1} Type should match."); + Assert.AreEqual(originalPart.StartOffset, deserializedPart.StartOffset, $"Partition {i + 1} StartOffset should match."); + Assert.AreEqual(originalPart.Size, deserializedPart.Size, $"Partition {i + 1} Size should match."); + Assert.AreEqual(originalPart.Name, deserializedPart.Name, $"Partition {i + 1} Name should match."); + Assert.AreEqual(originalPart.FilesystemType, deserializedPart.FilesystemType, $"Partition {i + 1} FilesystemType should match."); + Assert.AreEqual(originalPart.VolumeGuid, deserializedPart.VolumeGuid, $"Partition {i + 1} VolumeGuid should match."); + Assert.AreEqual(originalPart.TableType, deserializedPart.TableType, $"Partition {i + 1} TableType should match."); } - /// - /// Tests writing at offset 0 (first sector) works correctly. - /// - [Test] - public async Task Test_RawDisk_WriteAtOffsetZero_DataMatches() + // Verify Filesystems + Assert.IsNotNull(deserialized.Filesystems, "Filesystems should not be null after deserialization."); + Assert.AreEqual(original.Filesystems!.Count, deserialized.Filesystems!.Count, "Filesystem count should match."); + + for (int i = 0; i < original.Filesystems.Count; i++) { - var sectorSize = _writableRawDisk.SectorSize; + var originalFs = original.Filesystems[i]; + var deserializedFs = deserialized.Filesystems[i]; - // Write at offset 0 - var writeBuffer = new byte[sectorSize]; - new Random(123).NextBytes(writeBuffer); + Assert.AreEqual(originalFs.PartitionNumber, deserializedFs.PartitionNumber, $"Filesystem {i + 1} PartitionNumber should match."); + Assert.AreEqual(originalFs.Type, deserializedFs.Type, $"Filesystem {i + 1} Type should match."); + Assert.AreEqual(originalFs.PartitionStartOffset, deserializedFs.PartitionStartOffset, $"Filesystem {i + 1} PartitionStartOffset should match."); + Assert.AreEqual(originalFs.BlockSize, deserializedFs.BlockSize, $"Filesystem {i + 1} BlockSize should match."); + Assert.AreEqual(originalFs.Metadata, deserializedFs.Metadata, $"Filesystem {i + 1} Metadata should match."); + } + } - var bytesWritten = await _writableRawDisk.WriteBytesAsync(0, writeBuffer, CancellationToken.None); - Assert.AreEqual(sectorSize, bytesWritten, "Should have written one sector."); + /// + /// Tests that a GeometryMetadata with null optional fields can be serialized + /// and deserialized correctly. + /// + [Test] + public void Test_GeometryMetadata_RoundTrip_NullOptionalFields_PreservesStructure() + { + // Create a GeometryMetadata with minimal/null optional fields + var original = new GeometryMetadata + { + Version = 1, + Disk = new DiskGeometry + { + DevicePath = null, // Null optional field + Size = 50 * MiB, + SectorSize = 512, + Sectors = (int)(50 * MiB / 512), + TableType = PartitionTableType.MBR + }, + PartitionTable = new PartitionTableGeometry + { + Type = PartitionTableType.MBR, + Size = 512, + SectorSize = 512, + HasProtectiveMbr = false, + HeaderSize = 0, + MbrSize = 512 + }, + Partitions = + [ + new PartitionGeometry + { + Number = 1, + Type = PartitionType.Primary, + StartOffset = 512 * 2048, + Size = 40 * MiB, + Name = null, // Null optional field + FilesystemType = FileSystemType.Unknown, + VolumeGuid = null, // Null optional field + TableType = PartitionTableType.MBR + } + ], + Filesystems = null // Null optional collection + }; - // Read back and verify - using var readStream = await _writableRawDisk.ReadSectorsAsync(0, 1, CancellationToken.None); - var readBuffer = new byte[sectorSize]; + // Serialize to JSON + var json = original.ToJson(); + + // Verify JSON is not null or empty + Assert.IsNotNull(json, "JSON output should not be null."); + Assert.IsNotEmpty(json, "JSON output should not be empty."); + + // Deserialize from JSON + var deserialized = GeometryMetadata.FromJson(json); + + // Verify deserialized object is not null + Assert.IsNotNull(deserialized, "Deserialized object should not be null."); + + // Verify null fields remain null + Assert.IsNull(deserialized!.Disk!.DevicePath, "DevicePath should be null after deserialization."); + Assert.IsNull(deserialized.Filesystems, "Filesystems should be null after deserialization."); + + // Verify partition with null fields + Assert.IsNotNull(deserialized.Partitions, "Partitions should not be null."); + Assert.AreEqual(1, deserialized.Partitions!.Count, "Should have 1 partition."); + Assert.IsNull(deserialized.Partitions[0].Name, "Partition Name should be null."); + Assert.IsNull(deserialized.Partitions[0].VolumeGuid, "Partition VolumeGuid should be null."); + + // Verify other fields are preserved + Assert.AreEqual(original.Disk.Size, deserialized.Disk.Size, "Disk Size should match."); + Assert.AreEqual(original.Disk.SectorSize, deserialized.Disk.SectorSize, "Disk SectorSize should match."); + Assert.AreEqual(original.PartitionTable!.Type, deserialized.PartitionTable!.Type, "PartitionTable Type should match."); + } + + /// + /// Tests that an empty GeometryMetadata (with all null/empty fields) can be + /// serialized and deserialized correctly. + /// + [Test] + public void Test_GeometryMetadata_RoundTrip_EmptyMetadata_HandlesGracefully() + { + // Create a GeometryMetadata with mostly empty/null fields + var original = new GeometryMetadata + { + Version = 1, + Disk = null, + PartitionTable = null, + Partitions = null, + Filesystems = null + }; + + // Serialize to JSON + var json = original.ToJson(); + + // Verify JSON is not null or empty + Assert.IsNotNull(json, "JSON output should not be null."); + Assert.IsNotEmpty(json, "JSON output should not be empty."); + + // Deserialize from JSON + var deserialized = GeometryMetadata.FromJson(json); + + // Verify deserialized object is not null + Assert.IsNotNull(deserialized, "Deserialized object should not be null."); + + // Verify fields are null + Assert.IsNull(deserialized!.Disk, "Disk should be null."); + Assert.IsNull(deserialized.PartitionTable, "PartitionTable should be null."); + Assert.IsNull(deserialized.Partitions, "Partitions should be null."); + Assert.IsNull(deserialized.Filesystems, "Filesystems should be null."); + Assert.AreEqual(1, deserialized.Version, "Version should be preserved."); + } + + /// + /// Tests that a GeometryMetadata with multiple partitions and filesystems of + /// different types can be serialized and deserialized correctly. + /// + [Test] + public void Test_GeometryMetadata_RoundTrip_MultiplePartitionsAndFilesystems_PreservesAllData() + { + // Create a GeometryMetadata with multiple partitions and filesystems + var original = new GeometryMetadata + { + Version = 1, + Disk = new DiskGeometry + { + DevicePath = "/dev/nvme0n1", + Size = 500 * MiB, + SectorSize = 4096, // 4K sectors + Sectors = (int)(500 * MiB / 4096), + TableType = PartitionTableType.GPT + }, + PartitionTable = new PartitionTableGeometry + { + Type = PartitionTableType.GPT, + Size = 32768, + SectorSize = 4096, + HasProtectiveMbr = true, + HeaderSize = 92 + }, + Partitions = + [ + new PartitionGeometry + { + Number = 1, + Type = PartitionType.Primary, + StartOffset = 4096 * 6, // Start at sector 6 (after GPT headers) + Size = 100 * MiB, + Name = "EFI System Partition", + FilesystemType = FileSystemType.FAT32, + VolumeGuid = Guid.NewGuid(), + TableType = PartitionTableType.GPT + }, + new PartitionGeometry + { + Number = 2, + Type = PartitionType.Primary, + StartOffset = 4096 * 6 + 100 * MiB, + Size = 150 * MiB, + Name = "Windows OS", + FilesystemType = FileSystemType.NTFS, + VolumeGuid = Guid.NewGuid(), + TableType = PartitionTableType.GPT + }, + new PartitionGeometry + { + Number = 3, + Type = PartitionType.Primary, + StartOffset = 4096 * 6 + 250 * MiB, + Size = 200 * MiB, + Name = "Linux Root", + FilesystemType = FileSystemType.Ext4, + VolumeGuid = Guid.NewGuid(), + TableType = PartitionTableType.GPT + } + ], + Filesystems = + [ + new FilesystemGeometry + { + PartitionNumber = 1, + Type = FileSystemType.FAT32, + PartitionStartOffset = 4096 * 6, + BlockSize = 4096, + Metadata = "{\"label\":\"EFI\"}" + }, + new FilesystemGeometry + { + PartitionNumber = 2, + Type = FileSystemType.NTFS, + PartitionStartOffset = 4096 * 6 + 100 * MiB, + BlockSize = 4096, + Metadata = "{\"label\":\"Windows\"}" + }, + new FilesystemGeometry + { + PartitionNumber = 3, + Type = FileSystemType.Ext4, + PartitionStartOffset = 4096 * 6 + 250 * MiB, + BlockSize = 4096, + Metadata = "{\"label\":\"/\"}" + } + ] + }; + + // Serialize to JSON + var json = original.ToJson(); + + // Verify JSON is not null or empty + Assert.IsNotNull(json, "JSON output should not be null."); + Assert.IsNotEmpty(json, "JSON output should not be empty."); + + // Deserialize from JSON + var deserialized = GeometryMetadata.FromJson(json); + + // Verify deserialized object is not null + Assert.IsNotNull(deserialized, "Deserialized object should not be null."); + + // Verify counts + Assert.AreEqual(3, deserialized!.Partitions!.Count, "Should have 3 partitions."); + Assert.AreEqual(3, deserialized.Filesystems!.Count, "Should have 3 filesystems."); + + // Verify each partition's filesystem type + Assert.AreEqual(FileSystemType.FAT32, deserialized.Partitions[0].FilesystemType, "Partition 1 should be FAT32."); + Assert.AreEqual(FileSystemType.NTFS, deserialized.Partitions[1].FilesystemType, "Partition 2 should be NTFS."); + Assert.AreEqual(FileSystemType.Ext4, deserialized.Partitions[2].FilesystemType, "Partition 3 should be Ext4."); + + // Verify each filesystem's type + Assert.AreEqual(FileSystemType.FAT32, deserialized.Filesystems[0].Type, "Filesystem 1 should be FAT32."); + Assert.AreEqual(FileSystemType.NTFS, deserialized.Filesystems[1].Type, "Filesystem 2 should be NTFS."); + Assert.AreEqual(FileSystemType.Ext4, deserialized.Filesystems[2].Type, "Filesystem 3 should be Ext4."); + + // Verify 4K sector size is preserved + Assert.AreEqual(4096, deserialized.Disk!.SectorSize, "Disk SectorSize should be 4096."); + Assert.AreEqual(4096, deserialized.PartitionTable!.SectorSize, "PartitionTable SectorSize should be 4096."); + } + + /// + /// Tests that serializing an MBR-based GeometryMetadata produces valid JSON + /// that can be correctly deserialized. + /// + [Test] + public void Test_GeometryMetadata_RoundTrip_MBRType_PreservesMBRData() + { + var original = new GeometryMetadata + { + Version = 1, + Disk = new DiskGeometry + { + DevicePath = "/dev/sdb", + Size = 200 * MiB, + SectorSize = 512, + Sectors = (int)(200 * MiB / 512), + TableType = PartitionTableType.MBR + }, + PartitionTable = new PartitionTableGeometry + { + Type = PartitionTableType.MBR, + Size = 512, + SectorSize = 512, + HasProtectiveMbr = false, + MbrSize = 512 + }, + Partitions = + [ + new PartitionGeometry + { + Number = 1, + Type = PartitionType.Primary, + StartOffset = 512 * 2048, + Size = 100 * MiB, + Name = "Primary Partition", + FilesystemType = FileSystemType.FAT32, + TableType = PartitionTableType.MBR + }, + new PartitionGeometry + { + Number = 2, + Type = PartitionType.Primary, + StartOffset = 512 * 2048 + 100 * MiB, + Size = 90 * MiB, + Name = "Secondary Partition", + FilesystemType = FileSystemType.NTFS, + TableType = PartitionTableType.MBR + } + ], + Filesystems = + [ + new FilesystemGeometry + { + PartitionNumber = 1, + Type = FileSystemType.FAT32, + PartitionStartOffset = 512 * 2048, + BlockSize = 4096, + Metadata = null + } + ] + }; + + // Serialize to JSON + var json = original.ToJson(); + + // Deserialize from JSON + var deserialized = GeometryMetadata.FromJson(json); + + // Verify MBR-specific fields + Assert.IsNotNull(deserialized, "Deserialized object should not be null."); + Assert.AreEqual(PartitionTableType.MBR, deserialized!.Disk!.TableType, "Disk TableType should be MBR."); + Assert.AreEqual(PartitionTableType.MBR, deserialized.PartitionTable!.Type, "PartitionTable Type should be MBR."); + Assert.AreEqual(512, deserialized.PartitionTable.MbrSize, "PartitionTable MbrSize should be 512."); + Assert.IsFalse(deserialized.PartitionTable.HasProtectiveMbr, "PartitionTable HasProtectiveMbr should be false."); + + // Verify all partitions have MBR table type + foreach (var partition in deserialized.Partitions!) + { + Assert.AreEqual(PartitionTableType.MBR, partition.TableType, "All partitions should have MBR TableType."); + } + } + + /// + /// Tests that deserializing invalid JSON throws an appropriate exception. + /// + [Test] + public void Test_GeometryMetadata_FromJson_InvalidJson_ThrowsException() + { + // Test with invalid JSON - should throw JsonException + var invalidJson = "{invalid json"; + Assert.Throws(() => GeometryMetadata.FromJson(invalidJson)); + } + + /// + /// Tests that deserializing empty JSON object creates a minimal GeometryMetadata + /// with default property values set by the class. + /// + [Test] + public void Test_GeometryMetadata_FromJson_EmptyObject_CreatesDefaultInstance() + { + var emptyJson = "{}"; + var result = GeometryMetadata.FromJson(emptyJson); + + Assert.IsNotNull(result, "Should create an instance from empty JSON object."); + // Version has a default value of 1 in the class definition + Assert.AreEqual(1, result!.Version, "Version should have default value (1) from class definition."); + Assert.IsNull(result.Disk, "Disk should be null."); + Assert.IsNull(result.PartitionTable, "PartitionTable should be null."); + Assert.IsNull(result.Partitions, "Partitions should be null."); + Assert.IsNull(result.Filesystems, "Filesystems should be null."); + } + + #endregion + + #region UnknownFilesystem read/write tests + + /// + /// Tests that ListFilesAsync returns the expected block-based files for an UnknownFilesystem. + /// + [Test] + public async Task Test_UnknownFilesystem_ListFilesAsync_ReturnsBlockBasedFiles() + { + // Create a partition on the writable disk + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 10 * MiB; + var partitionOffset = sectorSize * 2048; // Start at sector 2048 + + // Create a simple partition entry + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + // Create UnknownFilesystem with 1MB block size + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + // List files + var files = new List(); + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + files.Add(file); + + // Should have 10 files (10MB partition / 1MB block size) + Assert.AreEqual(10, files.Count, "Should have 10 block files for a 10MB partition with 1MB blocks."); + + // Verify each file has correct properties + for (int i = 0; i < files.Count; i++) + { + var file = files[i]; + Assert.IsInstanceOf(file, $"File {i} should be UnknownFilesystemFile."); + Assert.IsFalse(file.IsDirectory, $"File {i} should not be a directory."); + + var unknownFile = (UnknownFilesystemFile)file; + Assert.AreEqual(i * 1024L * 1024L, unknownFile.Address, $"File {i} should have correct address."); + + // All files except possibly the last should have full block size + if (i < files.Count - 1) + Assert.AreEqual(1024 * 1024, unknownFile.Size, $"File {i} should have full block size."); + } + } + + /// + /// Tests that write and read round-trip preserves data correctly. + /// + [Test] + public async Task Test_UnknownFilesystem_WriteRead_RoundTrip_DataMatches() + { + // Create a partition on the writable disk + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + // Create UnknownFilesystem with 1MB block size + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + // Get first file + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + // Write data to the file + var writeData = new byte[1024 * 1024]; + new Random(42).NextBytes(writeData); + + using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) + { + await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); + } + + // Read data back + byte[] readData; + using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None)) + { + readData = new byte[writeData.Length]; + var bytesRead = await readStream.ReadAsync(readData.AsMemory(), CancellationToken.None); + Assert.AreEqual(writeData.Length, bytesRead, "Should have read all bytes."); + } + + // Verify data matches + Assert.AreEqual(writeData, readData, "Read data should match written data."); + } + + /// + /// Tests that BoundsCheck validates ranges correctly. + /// + [Test] + public void Test_UnknownFilesystem_BoundsCheck_ValidRange_DoesNotThrow() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + // Valid ranges should not throw + Assert.DoesNotThrow(() => fs.BoundsCheck(0, sectorSize), "BoundsCheck should not throw for valid range at start."); + Assert.DoesNotThrow(() => fs.BoundsCheck(sectorSize, sectorSize), "BoundsCheck should not throw for valid range."); + Assert.DoesNotThrow(() => fs.BoundsCheck(partitionSize - sectorSize, sectorSize), "BoundsCheck should not throw for valid range at end."); + } + + /// + /// Tests that BoundsCheck throws for negative start or size. + /// + [Test] + public void Test_UnknownFilesystem_BoundsCheck_NegativeValues_ThrowsArgumentOutOfRange() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + // Negative start should throw + Assert.Throws(() => fs.BoundsCheck(-1, sectorSize), "Negative start should throw."); + + // Negative size should throw + Assert.Throws(() => fs.BoundsCheck(0, -1), "Negative size should throw."); + } + + /// + /// Tests that BoundsCheck throws for ranges exceeding partition size. + /// + [Test] + public void Test_UnknownFilesystem_BoundsCheck_ExceedsPartitionSize_ThrowsArgumentOutOfRange() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + // Range exceeding partition size should throw + Assert.Throws(() => fs.BoundsCheck(partitionSize - sectorSize, sectorSize * 2), "Range exceeding partition should throw."); + Assert.Throws(() => fs.BoundsCheck(partitionSize, sectorSize), "Start at partition end should throw."); + } + + /// + /// Tests that BoundsCheck throws for unaligned start or size. + /// + [Test] + public void Test_UnknownFilesystem_BoundsCheck_UnalignedValues_ThrowsArgumentException() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + // Unaligned start should throw + Assert.Throws(() => fs.BoundsCheck(1, sectorSize), "Unaligned start should throw."); + + // Unaligned size should throw + Assert.Throws(() => fs.BoundsCheck(0, sectorSize + 1), "Unaligned size should throw."); + } + + /// + /// Tests that UnknownFilesystem constructor throws for unaligned block sizes. + /// + [Test] + public void Test_UnknownFilesystem_Constructor_UnalignedBlockSize_ThrowsArgumentException() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + // Block size not a multiple of sector size should throw + var unalignedBlockSize = sectorSize + 1; + var ex = Assert.Throws(() => new UnknownFilesystem(partition, blockSize: unalignedBlockSize), + "Unaligned block size should throw ArgumentException."); + Assert.That(ex!.ParamName, Is.EqualTo("blockSize"), "Exception should be for blockSize parameter."); + } + + /// + /// Tests that UnknownFilesystem constructor accepts valid block sizes. + /// + [Test] + public void Test_UnknownFilesystem_Constructor_ValidBlockSize_CreatesInstance() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + // Valid block sizes should create instance + Assert.DoesNotThrow(() => + { + using var fs1 = new UnknownFilesystem(partition, blockSize: sectorSize); + }, "Block size equal to sector size should work."); + + Assert.DoesNotThrow(() => + { + using var fs2 = new UnknownFilesystem(partition, blockSize: sectorSize * 4); + }, "Block size multiple of sector size should work."); + + Assert.DoesNotThrow(() => + { + using var fs3 = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + }, "1MB block size should work."); + } + + /// + /// Helper class to implement IPartition for testing. + /// + private class TestPartition : IPartition + { + public long StartOffset { get; init; } + public long Size { get; init; } + public IRawDisk? RawDisk { get; init; } + public int PartitionNumber => 1; + public string? Name => "TestPartition"; + public Guid? UniqueId => Guid.Empty; + public PartitionType Type => PartitionType.Primary; + public FileSystemType FilesystemType => FileSystemType.Unknown; + public Guid? VolumeGuid => Guid.Empty; + public IPartitionTable PartitionTable => new TestPartitionTable { RawDisk = RawDisk }; + + public Task OpenReadAsync(CancellationToken cancellationToken) + => Task.FromException(new NotImplementedException()); + + public Task OpenWriteAsync(CancellationToken cancellationToken) + => Task.FromException(new NotImplementedException()); + + public void Dispose() { } + } + + /// + /// Helper class to implement IPartitionTable for testing. + /// + private class TestPartitionTable : IPartitionTable + { + public IRawDisk? RawDisk { get; init; } + public PartitionTableType TableType => PartitionTableType.Unknown; + public long Size => 0; + + public IAsyncEnumerable EnumeratePartitions(CancellationToken cancellationToken) + => AsyncEnumerable.Empty(); + + public Task GetPartitionAsync(int partitionNumber, CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task GetProtectiveMbrAsync(CancellationToken cancellationToken) + => Task.FromException(new NotImplementedException()); + + public Task GetPartitionTableDataAsync(CancellationToken cancellationToken) + => Task.FromException(new NotImplementedException()); + + public void Dispose() { } + } + + #endregion + + #region UnknownFilesystemStream Unaligned Tests + + /// + /// Tests that writing partial data (less than block size) is correctly flushed on dispose. + /// + [Test] + public async Task Test_UnknownFilesystemStream_WritePartialData_FlushesOnDispose() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + // Get first file + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + // Write partial data (less than block size) + var writeData = new byte[1024]; // 1KB, much less than 1MB block + new Random(42).NextBytes(writeData); + + using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) + await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); + + // Read back and verify - the written data should be preserved + byte[] readData; + using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None)) + { + readData = new byte[writeData.Length]; + var bytesRead = await readStream.ReadAsync(readData.AsMemory(), CancellationToken.None); + Assert.AreEqual(writeData.Length, bytesRead, "Should have read all written bytes."); + } + + // Verify data matches + Assert.AreEqual(writeData, readData, "Partial data should be correctly flushed and readable."); + } + + /// + /// Tests writing at position 0, seeking to middle, writing more data, and verifying both regions. + /// + [Test] + public async Task Test_UnknownFilesystemStream_WriteSeekWrite_BothRegionsPreserved() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + // Get first file + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + // Write at position 0, seek to middle, write more data + var firstWriteData = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 }; + var secondWriteData = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD }; + var middlePosition = 1000; + + using (var writeStream = await fs.OpenReadWriteStreamAsync(firstFile!, CancellationToken.None)) + { + // Write at position 0 + await writeStream.WriteAsync(firstWriteData.AsMemory(), CancellationToken.None); + + // Seek to middle position + writeStream.Seek(middlePosition, SeekOrigin.Begin); + + // Write more data + await writeStream.WriteAsync(secondWriteData.AsMemory(), CancellationToken.None); + } + + // Read back and verify both regions + byte[] readData; + using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None)) + { + readData = new byte[middlePosition + secondWriteData.Length]; + var bytesRead = await readStream.ReadAsync(readData.AsMemory(), CancellationToken.None); + Assert.AreEqual(middlePosition + secondWriteData.Length, bytesRead, "Should have read all data."); + } + + // Verify first region (position 0-4) + for (int i = 0; i < firstWriteData.Length; i++) + Assert.AreEqual(firstWriteData[i], readData[i], $"Byte at position {i} should match first write."); + + // Verify middle region (position 1000-1003) + for (int i = 0; i < secondWriteData.Length; i++) + Assert.AreEqual(secondWriteData[i], readData[middlePosition + i], $"Byte at position {middlePosition + i} should match second write."); + } + + /// + /// Tests reading after seeking to a non-zero position. + /// + [Test] + public async Task Test_UnknownFilesystemStream_ReadAfterSeek_ReturnsCorrectData() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + // Get first file + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + // Write test data + var writeData = new byte[4096]; // 4KB of test data + for (int i = 0; i < writeData.Length; i++) + writeData[i] = (byte)(i & 0xFF); + + using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) + await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); + + // Read after seeking to non-zero position + using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None)) + { + // Seek to position 100 + readStream.Seek(100, SeekOrigin.Begin); + + // Read remaining data + var readBuffer = new byte[writeData.Length - 100]; var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + Assert.AreEqual(writeData.Length - 100, bytesRead, "Should have read remaining bytes after seek."); - Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector."); - Assert.AreEqual(writeBuffer, readBuffer, "Data at offset 0 should match."); + // Verify data matches expected values + for (int i = 0; i < bytesRead; i++) + { + Assert.AreEqual((byte)((i + 100) & 0xFF), readBuffer[i], $"Byte at position {100 + i} should match."); + } + } + } + + /// + /// Tests that writing data that doesn't fill the entire stream preserves the written data + /// while the remaining bytes retain their previous values. + /// + [Test] + public async Task Test_UnknownFilesystemStream_WritePartial_PreservesData() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + // Use a small block size for this test + var blockSize = 4096; + using var fs = new UnknownFilesystem(partition, blockSize: blockSize); + + // Get first file + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + // First, initialize the entire block with zeros by writing zeros + var zeroData = new byte[blockSize]; + using (var zeroStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) + await zeroStream.WriteAsync(zeroData.AsMemory(), CancellationToken.None); + + // Now write partial data at the beginning + var writeData = new byte[] { 0x11, 0x22, 0x33, 0x44, 0x55 }; + + using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) + await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); + + // Read the entire block and verify written data and remaining zeros + using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None); + var readBuffer = new byte[blockSize]; + var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + Assert.AreEqual(blockSize, bytesRead, "Should have read the full block."); + + // Verify written data + for (int i = 0; i < writeData.Length; i++) + Assert.AreEqual(writeData[i], readBuffer[i], $"Byte at position {i} should match written data."); + + // Verify remaining bytes are still zero + for (int i = writeData.Length; i < blockSize; i++) + Assert.AreEqual(0, readBuffer[i], $"Byte at position {i} should be zero."); + } + + /// + /// Tests the lazy-load behavior: first read triggers disk read, subsequent reads use buffer. + /// + [Test] + public async Task Test_UnknownFilesystemStream_LazyLoad_FirstReadTriggersDiskRead() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + // Get first file + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + // Write test data first + var writeData = new byte[2048]; + new Random(123).NextBytes(writeData); + + using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) + await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); + + // Now test lazy loading - first read should trigger disk read + using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None); + + // First small read (triggers lazy load) + var firstReadBuffer = new byte[512]; + var bytesRead1 = await readStream.ReadAsync(firstReadBuffer.AsMemory(), CancellationToken.None); + Assert.AreEqual(512, bytesRead1, "First read should return 512 bytes."); + + // Second read should use cached buffer (no disk read) + var secondReadBuffer = new byte[512]; + var bytesRead2 = await readStream.ReadAsync(secondReadBuffer.AsMemory(), CancellationToken.None); + Assert.AreEqual(512, bytesRead2, "Second read should return 512 bytes from buffer."); + + // Verify both reads returned correct data + for (int i = 0; i < 512; i++) + { + Assert.AreEqual(writeData[i], firstReadBuffer[i], $"First read: byte at position {i} should match."); + Assert.AreEqual(writeData[i + 512], secondReadBuffer[i], $"Second read: byte at position {i + 512} should match."); } - /// - /// Tests reading at the last sector of the disk returns correct data. - /// - [Test] - public async Task Test_RawDisk_ReadAtLastSector_ReturnsCorrectData() + // Seek back and read again - should still use buffer + readStream.Seek(0, SeekOrigin.Begin); + var thirdReadBuffer = new byte[512]; + var bytesRead3 = await readStream.ReadAsync(thirdReadBuffer.AsMemory(), CancellationToken.None); + Assert.AreEqual(512, bytesRead3, "Third read after seek should return 512 bytes."); + + for (int i = 0; i < 512; i++) + Assert.AreEqual(writeData[i], thirdReadBuffer[i], $"Third read: byte at position {i} should match after seek."); + } + + /// + /// Tests seeking with SeekOrigin.Current works correctly. + /// + [Test] + public async Task Test_UnknownFilesystemStream_SeekCurrent_UpdatesPosition() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition { - var sectorSize = _writableRawDisk.SectorSize; - var diskSize = _writableRawDisk.Size; - var lastSector = (diskSize / sectorSize) - 1; + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; - // Write known pattern to last sector - var writeBuffer = new byte[sectorSize]; - for (int i = 0; i < sectorSize; i++) - writeBuffer[i] = (byte)((i + 100) & 0xFF); + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - await _writableRawDisk.WriteSectorsAsync(lastSector, writeBuffer, CancellationToken.None); + // Get first file + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); - // Read at last sector offset - var lastSectorOffset = lastSector * sectorSize; - using var stream = await _writableRawDisk.ReadBytesAsync(lastSectorOffset, sectorSize, CancellationToken.None); - var readBuffer = new byte[sectorSize]; - var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + // Write test data + var writeData = new byte[4096]; + for (int i = 0; i < writeData.Length; i++) + writeData[i] = (byte)(i & 0xFF); - Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector."); - Assert.AreEqual(writeBuffer, readBuffer, "Data at last sector should match."); + using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) + await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); + + // Test Seek with SeekOrigin.Current + using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None)) + { + // Read first 100 bytes + var buffer1 = new byte[100]; + var bytesRead1 = await readStream.ReadAsync(buffer1.AsMemory(), CancellationToken.None); + Assert.AreEqual(100, bytesRead1, "First read should return 100 bytes."); + Assert.AreEqual(100, readStream.Position, "Position should be 100 after first read."); + + // Seek forward 100 bytes from current + var newPos = readStream.Seek(100, SeekOrigin.Current); + Assert.AreEqual(200, newPos, "Position should be 200 after Seek(Current, 100)."); + Assert.AreEqual(200, readStream.Position, "Stream.Position should match returned position."); + + // Read next 100 bytes + var buffer2 = new byte[100]; + var bytesRead2 = await readStream.ReadAsync(buffer2.AsMemory(), CancellationToken.None); + Assert.AreEqual(100, bytesRead2, "Second read should return 100 bytes."); + + // Verify data at position 200 + for (int i = 0; i < 100; i++) + Assert.AreEqual((byte)((200 + i) & 0xFF), buffer2[i], $"Byte at position {200 + i} should match."); + } + } + + /// + /// Tests seeking with SeekOrigin.End works correctly. + /// + [Test] + public async Task Test_UnknownFilesystemStream_SeekEnd_UpdatesPosition() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + var blockSize = 4096; + using var fs = new UnknownFilesystem(partition, blockSize: blockSize); + + // Get first file + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + // Write test data + var writeData = new byte[blockSize]; + for (int i = 0; i < writeData.Length; i++) + writeData[i] = (byte)(i & 0xFF); + + using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) + await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); + + // Test Seek with SeekOrigin.End + using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None); + + // Seek to 100 bytes from end + var newPos = readStream.Seek(-100, SeekOrigin.End); + Assert.AreEqual(blockSize - 100, newPos, "Position should be blockSize - 100."); + Assert.AreEqual(blockSize - 100, readStream.Position, "Stream.Position should match."); + + // Read remaining bytes + var buffer = new byte[100]; + var bytesRead = await readStream.ReadAsync(buffer.AsMemory(), CancellationToken.None); + Assert.AreEqual(100, bytesRead, "Should read 100 bytes."); + + // Verify last 100 bytes + for (int i = 0; i < 100; i++) + Assert.AreEqual((byte)((blockSize - 100 + i) & 0xFF), buffer[i], $"Byte at position {blockSize - 100 + i} should match."); + } + + /// + /// Tests that reading past the end of the stream returns 0 bytes. + /// + [Test] + public async Task Test_UnknownFilesystemStream_ReadPastEnd_ReturnsZero() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + var blockSize = 4096; + using var fs = new UnknownFilesystem(partition, blockSize: blockSize); + + // Get first file + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + // Write small amount of test data + var writeData = new byte[100]; + new Random(42).NextBytes(writeData); + + using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) + await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); + + // Test reading past end + using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None); + + // Seek to near end + readStream.Seek(blockSize - 10, SeekOrigin.Begin); + + // Try to read 100 bytes when only 10 remain + var buffer = new byte[100]; + var bytesRead = await readStream.ReadAsync(buffer.AsMemory(), CancellationToken.None); + Assert.AreEqual(10, bytesRead, "Should only read remaining 10 bytes."); + + // Try to read again - should return 0 + bytesRead = await readStream.ReadAsync(buffer.AsMemory(), CancellationToken.None); + Assert.AreEqual(0, bytesRead, "Should return 0 when at end of stream."); + } + + /// + /// Tests that unaligned writes within the stream work correctly. + /// + [Test] + public async Task Test_UnknownFilesystemStream_UnalignedWrite_DataMatches() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + var blockSize = 8192; + using var fs = new UnknownFilesystem(partition, blockSize: blockSize); + + // Get first file + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + // First, zero-fill the block so gap bytes are deterministic + using (var zeroStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) + await zeroStream.WriteAsync(new byte[blockSize].AsMemory(), CancellationToken.None); + + // Write at unaligned positions using read-write stream (pre-loads zeroed data) + using (var writeStream = await fs.OpenReadWriteStreamAsync(firstFile!, CancellationToken.None)) + { + // Write 3 bytes at position 0 + await writeStream.WriteAsync(new byte[] { 0xAA, 0xBB, 0xCC }.AsMemory(), CancellationToken.None); + + // Seek to unaligned position 5 + writeStream.Seek(5, SeekOrigin.Begin); + await writeStream.WriteAsync(new byte[] { 0xDD, 0xEE }.AsMemory(), CancellationToken.None); + + // Seek to another unaligned position 10 + writeStream.Seek(10, SeekOrigin.Begin); + await writeStream.WriteAsync(new byte[] { 0x11, 0x22, 0x33, 0x44 }.AsMemory(), CancellationToken.None); } - /// - /// Tests writing at the last sector of the disk works correctly. - /// - [Test] - public async Task Test_RawDisk_WriteAtLastSector_DataMatches() + // Read back and verify + using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None)) { - var sectorSize = _writableRawDisk.SectorSize; - var diskSize = _writableRawDisk.Size; - var lastSector = (diskSize / sectorSize) - 1; - - // Write at last sector - var writeBuffer = new byte[sectorSize]; - new Random(456).NextBytes(writeBuffer); - - var bytesWritten = await _writableRawDisk.WriteSectorsAsync(lastSector, writeBuffer, CancellationToken.None); - Assert.AreEqual(sectorSize, bytesWritten, "Should have written one sector at last position."); - - // Read back and verify - using var readStream = await _writableRawDisk.ReadSectorsAsync(lastSector, 1, CancellationToken.None); - var readBuffer = new byte[sectorSize]; + var readBuffer = new byte[14]; var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + Assert.AreEqual(14, bytesRead, "Should read 14 bytes."); - Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector."); - Assert.AreEqual(writeBuffer, readBuffer, "Data at last sector should match."); + // Verify written data + Assert.AreEqual(0xAA, readBuffer[0], "Byte 0 should be 0xAA"); + Assert.AreEqual(0xBB, readBuffer[1], "Byte 1 should be 0xBB"); + Assert.AreEqual(0xCC, readBuffer[2], "Byte 2 should be 0xCC"); + // Bytes 3-4 should be zero + Assert.AreEqual(0, readBuffer[3], "Byte 3 should be 0"); + Assert.AreEqual(0, readBuffer[4], "Byte 4 should be 0"); + Assert.AreEqual(0xDD, readBuffer[5], "Byte 5 should be 0xDD"); + Assert.AreEqual(0xEE, readBuffer[6], "Byte 6 should be 0xEE"); + // Bytes 7-9 should be zero + Assert.AreEqual(0, readBuffer[7], "Byte 7 should be 0"); + Assert.AreEqual(0, readBuffer[8], "Byte 8 should be 0"); + Assert.AreEqual(0, readBuffer[9], "Byte 9 should be 0"); + Assert.AreEqual(0x11, readBuffer[10], "Byte 10 should be 0x11"); + Assert.AreEqual(0x22, readBuffer[11], "Byte 11 should be 0x22"); + Assert.AreEqual(0x33, readBuffer[12], "Byte 12 should be 0x33"); + Assert.AreEqual(0x44, readBuffer[13], "Byte 13 should be 0x44"); + } + } + + #endregion + + #region 12. PooledBuffer and PooledMemoryStream Tests + + /// + /// Tests that PooledBuffer rents a buffer from the ArrayPool and returns it on dispose. + /// + [Test] + public void Test_PooledBuffer_RentAndReturn_BufferRecycled() + { + // Rent a buffer using PooledBuffer + using var pooledBuffer = new PooledBuffer(1024); + byte[] bufferArray = pooledBuffer.Array; + var bufferLength = pooledBuffer.Length; + + // Verify the buffer was rented + Assert.IsNotNull(bufferArray, "Buffer array should not be null."); + Assert.AreEqual(1024, bufferLength, "Buffer length should match requested size."); + Assert.GreaterOrEqual(bufferArray.Length, 1024, "Underlying array should be at least the requested size."); + + // Write some data to verify we have a valid buffer + bufferArray[0] = 0xAB; + bufferArray[1023] = 0xCD; + + // Verify Memory and Span properties work correctly + var memory = pooledBuffer.Memory; + var span = pooledBuffer.Span; + + Assert.AreEqual(1024, memory.Length, "Memory length should match requested size."); + Assert.AreEqual(1024, span.Length, "Span length should match requested size."); + Assert.AreEqual(0xAB, memory.Span[0], "Memory should reflect buffer changes."); + Assert.AreEqual(0xCD, span[1023], "Span should reflect buffer changes."); + } + + /// + /// Tests that PooledBuffer handles different sizes correctly. + /// + [Test] + public void Test_PooledBuffer_DifferentSizes_WorksCorrectly() + { + // Test small buffer + using (var small = new PooledBuffer(16)) + { + Assert.AreEqual(16, small.Length, "Small buffer length should be correct."); + Assert.GreaterOrEqual(small.Array.Length, 16, "Small buffer array should be at least requested size."); } - /// - /// Tests reading exactly one sector returns correct data. - /// - [Test] - public async Task Test_RawDisk_ReadExactlyOneSector_ReturnsCorrectData() + // Test large buffer (above LOH threshold of ~85KB) + using (var large = new PooledBuffer(128 * 1024)) { - var sectorSize = _writableRawDisk.SectorSize; - - // Write known pattern to sector 5 - var writeBuffer = new byte[sectorSize]; - for (int i = 0; i < sectorSize; i++) - writeBuffer[i] = (byte)((i * 3) & 0xFF); - - await _writableRawDisk.WriteSectorsAsync(5, writeBuffer, CancellationToken.None); - - // Read exactly one sector using ReadBytesAsync - var offset = 5 * sectorSize; - using var stream = await _writableRawDisk.ReadBytesAsync(offset, sectorSize, CancellationToken.None); - var readBuffer = new byte[sectorSize]; - var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(sectorSize, bytesRead, "Should have read exactly one sector."); - Assert.AreEqual(sectorSize, stream.Length, "Stream length should be exactly one sector."); - Assert.AreEqual(writeBuffer, readBuffer, "Data should match exactly."); + Assert.AreEqual(128 * 1024, large.Length, "Large buffer length should be correct."); + Assert.GreaterOrEqual(large.Array.Length, 128 * 1024, "Large buffer array should be at least requested size."); } - /// - /// Tests writing exactly one sector works correctly. - /// - [Test] - public async Task Test_RawDisk_WriteExactlyOneSector_DataMatches() + // Test zero-size buffer + using (var empty = new PooledBuffer(0)) { - var sectorSize = _writableRawDisk.SectorSize; + Assert.AreEqual(0, empty.Length, "Empty buffer length should be 0."); + Assert.IsNotNull(empty.Array, "Empty buffer array should still be rented."); + } + } - // Write exactly one sector using WriteBytesAsync - var writeBuffer = new byte[sectorSize]; - new Random(789).NextBytes(writeBuffer); - - var offset = 10 * sectorSize; - var bytesWritten = await _writableRawDisk.WriteBytesAsync(offset, writeBuffer, CancellationToken.None); - - Assert.AreEqual(sectorSize, bytesWritten, "Should have written exactly one sector."); - - // Read back and verify - using var readStream = await _writableRawDisk.ReadSectorsAsync(10, 1, CancellationToken.None); - var readBuffer = new byte[sectorSize]; - var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector."); - Assert.AreEqual(writeBuffer, readBuffer, "Data should match exactly."); + /// + /// Tests that PooledMemoryStream reads correctly and returns buffer on dispose. + /// + [Test] + public void Test_PooledMemoryStream_Read_ReturnsCorrectData() + { + // Rent a buffer and populate it with test data + byte[] buffer; + using (var pooledBuffer = new PooledBuffer(1024)) + { + buffer = pooledBuffer.Array; + for (int i = 0; i < 1024; i++) + buffer[i] = (byte)(i & 0xFF); } - /// - /// Tests reading the full disk size works correctly. - /// - [Test] - public async Task Test_RawDisk_ReadFullDiskSize_ReturnsCorrectData() + // Create a PooledMemoryStream with the buffer + using var stream = new PooledMemoryStream(buffer, 1024); + + // Verify stream properties + Assert.IsTrue(stream.CanRead, "Stream should be readable."); + Assert.IsTrue(stream.CanSeek, "Stream should be seekable."); + Assert.IsFalse(stream.CanWrite, "Stream should not be writable."); + Assert.AreEqual(1024, stream.Length, "Stream length should be correct."); + Assert.AreEqual(0, stream.Position, "Initial position should be 0."); + + // Read data from the stream + var readBuffer = new byte[256]; + var bytesRead = stream.Read(readBuffer, 0, 256); + + Assert.AreEqual(256, bytesRead, "Should have read 256 bytes."); + Assert.AreEqual(256, stream.Position, "Position should be updated."); + + // Verify the data + for (int i = 0; i < 256; i++) + Assert.AreEqual((byte)(i & 0xFF), readBuffer[i], $"Byte at position {i} should match."); + } + + /// + /// Tests that PooledMemoryStream seek operations work correctly. + /// + [Test] + public void Test_PooledMemoryStream_Seek_BeginCurrentEnd_WorksCorrectly() + { + // Rent a buffer and populate it + byte[] buffer; + using (var pooledBuffer = new PooledBuffer(512)) { - // Use a smaller test disk for this test to avoid memory issues - var sectorSize = _writableRawDisk.SectorSize; - var diskSize = _writableRawDisk.Size; - - // Read full disk in chunks to verify it works - const int chunkSectors = 10; - var chunkSize = chunkSectors * sectorSize; - var totalSectors = diskSize / sectorSize; - var sectorsRead = 0L; - - for (long sector = 0; sector < totalSectors; sector += chunkSectors) - { - var sectorsToRead = (int)Math.Min(chunkSectors, totalSectors - sector); - using var stream = await _writableRawDisk.ReadSectorsAsync(sector, sectorsToRead, CancellationToken.None); - - var buffer = new byte[sectorsToRead * sectorSize]; - var bytesRead = await stream.ReadAsync(buffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(sectorsToRead * sectorSize, bytesRead, $"Should have read {sectorsToRead} sectors at offset {sector}."); - sectorsRead += sectorsToRead; - } - - Assert.AreEqual(totalSectors, sectorsRead, "Should have read all sectors."); + buffer = pooledBuffer.Array; + for (int i = 0; i < 512; i++) + buffer[i] = (byte)(i & 0xFF); } - /// - /// Tests writing the full disk size works correctly. - /// - [Test] - public async Task Test_RawDisk_WriteFullDiskSize_DataMatches() + using (var stream = new PooledMemoryStream(buffer, 512)) { - var sectorSize = _writableRawDisk.SectorSize; - var diskSize = _writableRawDisk.Size; - - // Write a unique pattern to each sector and verify - var totalSectors = diskSize / sectorSize; - const int testSectors = 20; // Test a subset to keep test fast - - for (long sector = 0; sector < Math.Min(testSectors, totalSectors); sector++) - { - // Create unique pattern for this sector - var writeBuffer = new byte[sectorSize]; - var patternByte = (byte)(sector & 0xFF); - for (int i = 0; i < sectorSize; i++) - writeBuffer[i] = (byte)((patternByte + i) & 0xFF); - - await _writableRawDisk.WriteSectorsAsync(sector, writeBuffer, CancellationToken.None); - - // Read back and verify - using var readStream = await _writableRawDisk.ReadSectorsAsync(sector, 1, CancellationToken.None); - var readBuffer = new byte[sectorSize]; - var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(sectorSize, bytesRead, $"Should read sector {sector}."); - Assert.AreEqual(writeBuffer, readBuffer, $"Data at sector {sector} should match."); - } - } - - /// - /// Tests writing empty data (0 bytes) returns 0 without error. - /// - [Test] - public async Task Test_RawDisk_WriteEmptyData_ReturnsZero() - { - var sectorSize = _writableRawDisk.SectorSize; - - // Write empty data - var emptyData = Array.Empty(); - var bytesWritten = await _writableRawDisk.WriteBytesAsync(sectorSize, emptyData, CancellationToken.None); - - Assert.AreEqual(0, bytesWritten, "Writing empty data should return 0 bytes written."); - } - - /// - /// Tests reading empty data (0 bytes) returns 0 without error. - /// - [Test] - public async Task Test_RawDisk_ReadEmptyData_ReturnsZero() - { - var sectorSize = _writableRawDisk.SectorSize; - - // Read 0 bytes - using var stream = await _writableRawDisk.ReadBytesAsync(sectorSize, 0, CancellationToken.None); - var buffer = Array.Empty(); - var bytesRead = await stream.ReadAsync(buffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(0, bytesRead, "Reading 0 bytes should return 0."); - Assert.AreEqual(0, stream.Length, "Stream length should be 0 for empty read."); - } - - /// - /// Tests that a disk with minimum possible size still supports basic operations. - /// Creates a small test disk and verifies read/write operations work. - /// - [Test] - public async Task Test_RawDisk_MinimumSizeDisk_OperationsWork() - { - // The minimum practical disk size is at least a few sectors - // We'll use the writable disk which is already created at 100 MiB - // but verify operations work at the boundaries - var sectorSize = _writableRawDisk.SectorSize; - var diskSize = _writableRawDisk.Size; - - // Minimum operations: read/write first sector - var firstSectorData = new byte[sectorSize]; - new Random(111).NextBytes(firstSectorData); - await _writableRawDisk.WriteSectorsAsync(0, firstSectorData, CancellationToken.None); - - using var firstReadStream = await _writableRawDisk.ReadSectorsAsync(0, 1, CancellationToken.None); - var firstReadBuffer = new byte[sectorSize]; - var firstBytesRead = await firstReadStream.ReadAsync(firstReadBuffer.AsMemory(), CancellationToken.None); - Assert.AreEqual(sectorSize, firstBytesRead, "Should read exactly one sector."); - Assert.AreEqual(firstSectorData, firstReadBuffer, "First sector should be readable/writable."); - - // Minimum operations: read/write last sector - var lastSector = (diskSize / sectorSize) - 1; - var lastSectorData = new byte[sectorSize]; - new Random(222).NextBytes(lastSectorData); - await _writableRawDisk.WriteSectorsAsync(lastSector, lastSectorData, CancellationToken.None); - - using var lastReadStream = await _writableRawDisk.ReadSectorsAsync(lastSector, 1, CancellationToken.None); - var lastReadBuffer = new byte[sectorSize]; - var lastBytesRead = await lastReadStream.ReadAsync(lastReadBuffer.AsMemory(), CancellationToken.None); - Assert.AreEqual(sectorSize, lastBytesRead, "Should read exactly one sector."); - Assert.AreEqual(lastSectorData, lastReadBuffer, "Last sector should be readable/writable."); - - // Verify disk size is reported correctly - Assert.Greater(diskSize, 0, "Disk size should be greater than 0."); - Assert.AreEqual(0, diskSize % sectorSize, "Disk size should be sector-aligned."); - } - - /// - /// Tests boundary condition: reading at exactly the disk size boundary should fail gracefully. - /// - [Test] - public void Test_RawDisk_ReadAtDiskSizeBoundary_ThrowsInvalidOperationException() - { - var sectorSize = _writableRawDisk.SectorSize; - var diskSize = _writableRawDisk.Size; - - // Attempting to read at or beyond disk size should throw InvalidOperationException - var ex = Assert.ThrowsAsync(async () => - { - using var stream = await _writableRawDisk.ReadBytesAsync(diskSize, sectorSize, CancellationToken.None); - var buffer = new byte[sectorSize]; - var _ = await stream.ReadAsync(buffer.AsMemory(), CancellationToken.None); - }, "Reading at disk boundary should throw InvalidOperationException."); - - StringAssert.Contains("beyond disk size", ex!.Message, "Exception message should mention beyond disk size."); - } - - /// - /// Tests boundary condition: writing at exactly the disk size boundary should fail gracefully. - /// - [Test] - public void Test_RawDisk_WriteAtDiskSizeBoundary_ThrowsInvalidOperationException() - { - var sectorSize = _writableRawDisk.SectorSize; - var diskSize = _writableRawDisk.Size; - - // Attempting to write at or beyond disk size should throw InvalidOperationException - var ex = Assert.ThrowsAsync(async () => - { - var writeData = new byte[sectorSize]; - await _writableRawDisk.WriteBytesAsync(diskSize, writeData, CancellationToken.None); - }, "Writing at disk boundary should throw InvalidOperationException."); - - StringAssert.Contains("beyond disk size", ex!.Message, "Exception message should mention beyond disk size."); - } - - /// - /// Tests reading/writing a single byte at various boundary positions. - /// - [Test] - public async Task Test_RawDisk_SingleByteAtBoundaries_DataMatches() - { - var sectorSize = _writableRawDisk.SectorSize; - - // Test positions: first byte of disk, last byte of first sector, - // first byte of second sector, and various other positions - var testPositions = new[] - { - 0L, // First byte of disk - sectorSize - 1, // Last byte of first sector - sectorSize, // First byte of second sector - sectorSize + 1, // Second byte of second sector - (2 * sectorSize) - 1, // Last byte of second sector - 2 * sectorSize // First byte of third sector - }; - - for (int i = 0; i < testPositions.Length; i++) - { - var position = testPositions[i]; - var testByte = (byte)(0xA0 + i); - - // Write single byte - var writeData = new[] { testByte }; - var bytesWritten = await _writableRawDisk.WriteBytesAsync(position, writeData, CancellationToken.None); - Assert.AreEqual(1, bytesWritten, $"Should write 1 byte at position {position}."); - - // Read back the byte - using var stream = await _writableRawDisk.ReadBytesAsync(position, 1, CancellationToken.None); - var readBuffer = new byte[1]; - var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - - Assert.AreEqual(1, bytesRead, $"Should read 1 byte at position {position}."); - Assert.AreEqual(testByte, readBuffer[0], $"Byte at position {position} should match."); - } - } - - #endregion - - #region Coverage increasing tests - - [Test] - public async Task Test_GPT_GetProtectiveMbrAsync_ReturnsValidMbr() - { - var sectorSize = s_gptRawDisk!.SectorSize; - - // Read enough sectors to parse GPT - var sectorsToRead = 34; - using var stream = await s_gptRawDisk.ReadSectorsAsync(0, sectorsToRead, CancellationToken.None); - var diskBytes = new byte[sectorSize * sectorsToRead]; - await stream.ReadAtLeastAsync(diskBytes, diskBytes.Length, cancellationToken: CancellationToken.None); - - // Parse GPT - var gpt = new GPT(s_gptRawDisk); - var parsed = await gpt.ParseAsync(diskBytes, sectorSize, CancellationToken.None); - Assert.IsTrue(parsed, "GPT should parse successfully."); - - // Get protective MBR - using var mbrStream = await gpt.GetProtectiveMbrAsync(CancellationToken.None); - Assert.IsNotNull(mbrStream, "Protective MBR stream should not be null."); - Assert.Greater(mbrStream.Length, 0, "Protective MBR stream should have data."); - - // Read MBR bytes and verify boot signature - var mbrBytes = new byte[mbrStream.Length]; - var bytesRead = await mbrStream.ReadAsync(mbrBytes.AsMemory(), CancellationToken.None); - Assert.Greater(bytesRead, 0, "Should have read MBR bytes."); - - // Verify MBR boot signature (0x55AA at offset 510-511) - if (bytesRead >= 512) - { - Assert.AreEqual(0x55, mbrBytes[510], "MBR boot signature first byte should be 0x55."); - Assert.AreEqual(0xAA, mbrBytes[511], "MBR boot signature second byte should be 0xAA."); - } - - gpt.Dispose(); - } - - [Test] - public async Task Test_GPT_GetPartitionTableDataAsync_ReturnsValidData() - { - var sectorSize = s_gptRawDisk!.SectorSize; - - // Read enough sectors to parse GPT - var sectorsToRead = 34; - using var stream = await s_gptRawDisk.ReadSectorsAsync(0, sectorsToRead, CancellationToken.None); - var diskBytes = new byte[sectorSize * sectorsToRead]; - await stream.ReadAtLeastAsync(diskBytes, diskBytes.Length, cancellationToken: CancellationToken.None); - - // Parse GPT with raw disk reference - var gpt = new GPT(s_gptRawDisk); - var parsed = await gpt.ParseAsync(diskBytes, sectorSize, CancellationToken.None); - Assert.IsTrue(parsed, "GPT should parse successfully."); - - // Get partition table data - using var ptStream = await gpt.GetPartitionTableDataAsync(CancellationToken.None); - Assert.IsNotNull(ptStream, "Partition table data stream should not be null."); - Assert.Greater(ptStream.Length, 0, "Partition table data should have content."); - - // Read the data and verify it contains the GPT signature - var ptBytes = new byte[ptStream.Length]; - var bytesRead = await ptStream.ReadAsync(ptBytes.AsMemory(), CancellationToken.None); - Assert.Greater(bytesRead, sectorSize, "Should have read more than one sector of partition table data."); - - // Verify GPT signature "EFI PART" at sector 1 (offset 512) - var expectedSignature = "EFI PART"u8.ToArray(); - for (int i = 0; i < 8; i++) - Assert.AreEqual(expectedSignature[i], ptBytes[sectorSize + i], $"GPT signature byte {i} should match."); - - gpt.Dispose(); - } - - [Test] - public async Task Test_UnknownFilesystem_GetFilesystemMetadataAsync_ReturnsCorrectMetadata() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - var blockSize = 1024 * 1024; - using var fs = new UnknownFilesystem(partition, blockSize: blockSize); - - var metadata = await fs.GetFilesystemMetadataAsync(CancellationToken.None); - Assert.IsNotNull(metadata, "Metadata should not be null."); - Assert.IsInstanceOf(metadata, "Metadata should be UnkownFilesystemMetadata."); - - var fsMetadata = (UnkownFilesystemMetadata)metadata!; - Assert.AreEqual(blockSize, fsMetadata.BlockSize, "BlockSize should match the configured value."); - } - - [Test] - public async Task Test_UnknownFilesystem_GetFileLengthAsync_ReturnsCorrectSize() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - // Get first file - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - var length = await fs.GetFileLengthAsync(firstFile!, CancellationToken.None); - Assert.AreEqual(firstFile!.Size, length, "GetFileLengthAsync should return the file's size."); - Assert.AreEqual(1024 * 1024, length, "First file should be 1MB (full block)."); - } - - [Test] - public async Task Test_UnknownFilesystemStream_SetLength_ThrowsNotSupported() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - using var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None); - Assert.Throws(() => writeStream.SetLength(100), - "SetLength should throw NotSupportedException on UnknownFilesystemStream."); - } - - [Test] - public async Task Test_UnknownFilesystemStream_WriteOnReadOnly_ThrowsNotSupported() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None); - Assert.IsFalse(readStream.CanWrite, "Read stream should not be writable."); - Assert.Throws(() => readStream.Write(new byte[10], 0, 10), - "Write on read-only stream should throw NotSupportedException."); - } - - [Test] - public async Task Test_UnknownFilesystemStream_ReadOnWriteOnly_ThrowsNotSupported() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - using var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None); - Assert.IsFalse(writeStream.CanRead, "Write stream should not be readable."); - Assert.Throws(() => { var _ = writeStream.Read(new byte[10], 0, 10); }, - "Read on write-only stream should throw NotSupportedException."); - } - - [Test] - public async Task Test_UnknownFilesystemStream_DisposeAsync_FlushesDirtyData() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - var blockSize = sectorSize * 2; - using var fs = new UnknownFilesystem(partition, blockSize: blockSize); - - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - // Write data using DisposeAsync (await using) - var writeData = new byte[blockSize]; - new Random(99).NextBytes(writeData); - - await using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) - { - await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); - } - - // Read back and verify data was flushed via DisposeAsync - using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None)) - { - var readData = new byte[blockSize]; - var bytesRead = await readStream.ReadAsync(readData.AsMemory(), CancellationToken.None); - Assert.AreEqual(blockSize, bytesRead, "Should have read the full block."); - Assert.AreEqual(writeData, readData, "Data should match after DisposeAsync flush."); - } - } - - [Test] - public void Test_UnknownFilesystem_DoubleDispose_DoesNotThrow() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - fs.Dispose(); - - // Second dispose should not throw - Assert.DoesNotThrow(() => fs.Dispose(), "Double dispose should not throw."); - } - - [Test] - public void Test_UnknownFilesystem_Type_ReturnsUnknown() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - Assert.AreEqual(FileSystemType.Unknown, fs.Type, "Type should be Unknown."); - Assert.AreEqual(partition, fs.Partition, "Partition should match the one passed to constructor."); - } - - [Test] - public async Task Test_UnknownFilesystemStream_PositionSetter_ValidatesBounds() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - var blockSize = 4096; - using var fs = new UnknownFilesystem(partition, blockSize: blockSize); - - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None); - - // Valid position - readStream.Position = 100; - Assert.AreEqual(100, readStream.Position, "Position should be 100."); - - // Position at end (equal to size) should be valid - readStream.Position = blockSize; - Assert.AreEqual(blockSize, readStream.Position, "Position at end should be valid."); - - // Negative position should throw - Assert.Throws(() => readStream.Position = -1, - "Negative position should throw ArgumentOutOfRangeException."); - - // Position beyond size should throw - Assert.Throws(() => readStream.Position = blockSize + 1, - "Position beyond size should throw ArgumentOutOfRangeException."); - } - - [Test] - public async Task Test_UnknownFilesystemStream_Flush_DoesNotThrow() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - using var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None); - Assert.DoesNotThrow(() => writeStream.Flush(), "Flush should be a no-op and not throw."); - } - - [Test] - public void Test_PartitionWriteStream_Flush_DoesNotThrow() - { - var sectorSize = _writableRawDisk.SectorSize; - using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, sectorSize); - - Assert.DoesNotThrow(() => stream.Flush(), "Flush should be a no-op and not throw."); - } - - [Test] - public async Task Test_UnknownFilesystemStream_WriteBeyondSize_ThrowsArgumentException() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - var blockSize = 4096; - using var fs = new UnknownFilesystem(partition, blockSize: blockSize); - - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - using var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None); - - // Try to write more data than the stream size - var largeData = new byte[blockSize + 100]; - Assert.Throws(() => writeStream.Write(largeData, 0, largeData.Length), - "Writing beyond stream size should throw ArgumentException."); - } - - [Test] - public async Task Test_UnknownFilesystemStream_SyncRead_ReturnsCorrectData() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - var blockSize = 4096; - using var fs = new UnknownFilesystem(partition, blockSize: blockSize); - - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - // Write test data - var writeData = new byte[blockSize]; - for (int i = 0; i < blockSize; i++) - writeData[i] = (byte)(i & 0xFF); - - using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) - await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); - - // Read using synchronous Read method - using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None); - var readBuffer = new byte[256]; - var bytesRead = readStream.Read(readBuffer, 0, 256); - - Assert.AreEqual(256, bytesRead, "Sync Read should return 256 bytes."); - for (int i = 0; i < 256; i++) - Assert.AreEqual((byte)(i & 0xFF), readBuffer[i], $"Byte at position {i} should match."); - } - - [Test] - public async Task Test_UnknownFilesystemStream_SyncWrite_DataMatches() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - var blockSize = 4096; - using var fs = new UnknownFilesystem(partition, blockSize: blockSize); - - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - // Write using synchronous Write method - var writeData = new byte[100]; - new Random(42).NextBytes(writeData); - - using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) - writeStream.Write(writeData, 0, writeData.Length); - - // Read back and verify - using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None); - var readBuffer = new byte[100]; - var bytesRead = readStream.Read(readBuffer, 0, 100); - - Assert.AreEqual(100, bytesRead, "Should read 100 bytes."); - Assert.AreEqual(writeData, readBuffer, "Sync-written data should match on read-back."); - } - - [Test] - public void Test_PooledMemoryStream_Flush_DoesNotThrow() - { - byte[] buffer; - using (var pooledBuffer = new PooledBuffer(100)) - buffer = pooledBuffer.Array; - - using var stream = new PooledMemoryStream(buffer, 100); - Assert.DoesNotThrow(() => stream.Flush(), "Flush should be a no-op and not throw."); - } - - [Test] - public void Test_PooledBuffer_SpanAndMemory_ReturnCorrectSize() - { - using var buffer = new PooledBuffer(512); - - Assert.AreEqual(512, buffer.Span.Length, "Span length should match requested size."); - Assert.AreEqual(512, buffer.Memory.Length, "Memory length should match requested size."); - - // Write via Span, read via Memory - buffer.Span[0] = 0xAA; - buffer.Span[511] = 0xBB; - - Assert.AreEqual(0xAA, buffer.Memory.Span[0], "Memory should reflect Span writes."); - Assert.AreEqual(0xBB, buffer.Memory.Span[511], "Memory should reflect Span writes."); - } - - /// - /// Tests that ReconstructedPartitionTable stores the correct table type and raw disk reference. - /// - [Test] - public void Test_ReconstructedPartitionTable_Constructor_SetsProperties() - { - var metadata = new GeometryMetadata - { - Disk = new DiskGeometry { SectorSize = 512, Size = 50 * MiB }, - PartitionTable = new PartitionTableGeometry { Type = PartitionTableType.GPT } - }; - - using var table = new ReconstructedPartitionTable(_writableRawDisk, metadata, PartitionTableType.GPT); - - Assert.AreEqual(PartitionTableType.GPT, table.TableType, "TableType should be GPT."); - Assert.AreEqual(_writableRawDisk, table.RawDisk, "RawDisk should match the provided disk."); - } - - /// - /// Tests that ReconstructedPartitionTable.EnumeratePartitions throws NotSupportedException. - /// - [Test] - public void Test_ReconstructedPartitionTable_EnumeratePartitions_ThrowsNotSupported() - { - var metadata = new GeometryMetadata(); - using var table = new ReconstructedPartitionTable(_writableRawDisk, metadata, PartitionTableType.GPT); - - Assert.Throws(() => - { - // Force enumeration to trigger the throw - var enumerator = table.EnumeratePartitions(CancellationToken.None).GetAsyncEnumerator(); - enumerator.MoveNextAsync().AsTask().GetAwaiter().GetResult(); - }, "EnumeratePartitions should throw NotSupportedException."); - } - - /// - /// Tests that ReconstructedPartitionTable.GetPartitionAsync throws NotSupportedException. - /// - [Test] - public void Test_ReconstructedPartitionTable_GetPartitionAsync_ThrowsNotSupported() - { - var metadata = new GeometryMetadata(); - using var table = new ReconstructedPartitionTable(_writableRawDisk, metadata, PartitionTableType.GPT); - - Assert.ThrowsAsync(async () => - { - await table.GetPartitionAsync(1, CancellationToken.None); - }, "GetPartitionAsync should throw NotSupportedException."); - } - - /// - /// Tests that ReconstructedPartitionTable.GetProtectiveMbrAsync throws NotSupportedException for GPT. - /// - [Test] - public void Test_ReconstructedPartitionTable_GetProtectiveMbrAsync_GPT_ThrowsNotSupported() - { - var metadata = new GeometryMetadata(); - using var table = new ReconstructedPartitionTable(_writableRawDisk, metadata, PartitionTableType.GPT); - - Assert.ThrowsAsync(async () => - { - await table.GetProtectiveMbrAsync(CancellationToken.None); - }, "GetProtectiveMbrAsync should throw NotSupportedException for GPT."); - } - - /// - /// Tests that ReconstructedPartitionTable.GetProtectiveMbrAsync throws NotSupportedException for MBR. - /// - [Test] - public void Test_ReconstructedPartitionTable_GetProtectiveMbrAsync_MBR_ThrowsNotSupported() - { - var metadata = new GeometryMetadata(); - using var table = new ReconstructedPartitionTable(_writableRawDisk, metadata, PartitionTableType.MBR); - - var ex = Assert.ThrowsAsync(async () => - { - await table.GetProtectiveMbrAsync(CancellationToken.None); - }, "GetProtectiveMbrAsync should throw NotSupportedException for MBR."); - - StringAssert.Contains("MBR", ex!.Message, "Exception message should mention MBR."); - } - - /// - /// Tests that ReconstructedPartitionTable.GetPartitionTableDataAsync throws NotSupportedException. - /// - [Test] - public void Test_ReconstructedPartitionTable_GetPartitionTableDataAsync_ThrowsNotSupported() - { - var metadata = new GeometryMetadata(); - using var table = new ReconstructedPartitionTable(_writableRawDisk, metadata, PartitionTableType.GPT); - - Assert.ThrowsAsync(async () => - { - await table.GetPartitionTableDataAsync(CancellationToken.None); - }, "GetPartitionTableDataAsync should throw NotSupportedException."); - } - - /// - /// Tests that ReconstructedPartitionTable.Dispose can be called multiple times without error. - /// - [Test] - public void Test_ReconstructedPartitionTable_DoubleDispose_DoesNotThrow() - { - var metadata = new GeometryMetadata(); - var table = new ReconstructedPartitionTable(_writableRawDisk, metadata, PartitionTableType.GPT); - table.Dispose(); - Assert.DoesNotThrow(() => table.Dispose(), "Double dispose should not throw."); - } - - /// - /// Tests that BasePartition.OpenReadAsync returns a readable stream. - /// - [Test] - public async Task Test_BasePartition_OpenReadAsync_ReturnsReadableStream() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionOffset = 10L * sectorSize; - var partitionSize = 4L * sectorSize; - - // Write known data to the partition area - var writeData = new byte[sectorSize]; - for (int i = 0; i < sectorSize; i++) - writeData[i] = (byte)(i & 0xFF); - await _writableRawDisk.WriteBytesAsync(partitionOffset, writeData, CancellationToken.None); - - var partition = new BasePartition - { - PartitionNumber = 1, - Type = PartitionType.Primary, - PartitionTable = new TestPartitionTable { RawDisk = _writableRawDisk }, - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var stream = await partition.OpenReadAsync(CancellationToken.None); - Assert.IsNotNull(stream, "OpenReadAsync should return a stream."); - Assert.IsTrue(stream.CanRead, "Stream should be readable."); - - var readBuffer = new byte[sectorSize]; - var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - Assert.AreEqual(sectorSize, bytesRead, "Should read one sector."); - Assert.AreEqual(writeData, readBuffer, "Data should match what was written."); - - partition.Dispose(); - } - - /// - /// Tests that BasePartition.OpenWriteAsync returns a writable PartitionWriteStream. - /// - [Test] - public async Task Test_BasePartition_OpenWriteAsync_ReturnsWritableStream() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionOffset = 10L * sectorSize; - var partitionSize = 4L * sectorSize; - - var partition = new BasePartition - { - PartitionNumber = 1, - Type = PartitionType.Primary, - PartitionTable = new TestPartitionTable { RawDisk = _writableRawDisk }, - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var stream = await partition.OpenWriteAsync(CancellationToken.None); - Assert.IsNotNull(stream, "OpenWriteAsync should return a stream."); - Assert.IsTrue(stream.CanWrite, "Stream should be writable."); - Assert.IsInstanceOf(stream, "Stream should be a PartitionWriteStream."); - - partition.Dispose(); - } - - /// - /// Tests that UnknownPartitionTable.GetProtectiveMbrAsync throws NotSupportedException. - /// - [Test] - public void Test_UnknownPartitionTable_GetProtectiveMbrAsync_ThrowsNotSupported() - { - using var table = new UnknownPartitionTable(null); - - Assert.ThrowsAsync(async () => - { - await table.GetProtectiveMbrAsync(CancellationToken.None); - }, "GetProtectiveMbrAsync should throw NotSupportedException."); - } - - /// - /// Tests that UnknownPartitionTable.GetPartitionTableDataAsync returns empty data. - /// - [Test] - public async Task Test_UnknownPartitionTable_GetPartitionTableDataAsync_ReturnsEmptyStream() - { - using var table = new UnknownPartitionTable(null); - - using var stream = await table.GetPartitionTableDataAsync(CancellationToken.None); - Assert.IsNotNull(stream, "Stream should not be null."); - Assert.AreEqual(0, stream.Length, "Stream should be empty."); - } - - /// - /// Tests that UnknownPartitionTable.Dispose can be called multiple times. - /// - [Test] - public void Test_UnknownPartitionTable_DoubleDispose_DoesNotThrow() - { - var table = new UnknownPartitionTable(null); - table.Dispose(); - Assert.DoesNotThrow(() => table.Dispose(), "Double dispose should not throw."); - } - - /// - /// Tests that UnknownPartitionTable.GetPartitionAsync returns null for invalid partition numbers. - /// - [Test] - public async Task Test_UnknownPartitionTable_GetPartitionAsync_InvalidNumber_ReturnsNull() - { - using var table = new UnknownPartitionTable(null); - - var result = await table.GetPartitionAsync(2, CancellationToken.None); - Assert.IsNull(result, "Should return null for partition number != 1."); - - var result0 = await table.GetPartitionAsync(0, CancellationToken.None); - Assert.IsNull(result0, "Should return null for partition number 0."); - } - - /// - /// Tests that UnknownPartitionTable.GetPartitionAsync returns null when no raw disk is set. - /// - [Test] - public async Task Test_UnknownPartitionTable_GetPartitionAsync_NullDisk_ReturnsNull() - { - using var table = new UnknownPartitionTable(null); - - var result = await table.GetPartitionAsync(1, CancellationToken.None); - Assert.IsNull(result, "Should return null when raw disk is null."); - } - - /// - /// Tests that UnknownPartitionTable.GetPartitionAsync returns a valid partition when disk is set. - /// - [Test] - public async Task Test_UnknownPartitionTable_GetPartitionAsync_ValidDisk_ReturnsPartition() - { - using var table = new UnknownPartitionTable(_writableRawDisk); - - var result = await table.GetPartitionAsync(1, CancellationToken.None); - Assert.IsNotNull(result, "Should return a partition when disk is available."); - Assert.AreEqual(1, result!.PartitionNumber, "Partition number should be 1."); - Assert.AreEqual(_writableRawDisk.Size, result.Size, "Partition size should equal disk size."); - Assert.AreEqual(0, result.StartOffset, "Partition start offset should be 0."); - - result.Dispose(); - } - - /// - /// Tests that PooledMemoryStream.Position setter validates bounds correctly. - /// - [Test] - public void Test_PooledMemoryStream_PositionSetter_ValidatesBounds() - { - byte[] buffer; - using (var pooledBuffer = new PooledBuffer(100)) - buffer = pooledBuffer.Array; - - using var stream = new PooledMemoryStream(buffer, 100); - - // Valid position - stream.Position = 50; - Assert.AreEqual(50, stream.Position, "Position should be 50."); - - // Position at end - stream.Position = 100; - Assert.AreEqual(100, stream.Position, "Position at end should be valid."); - - // Position at start - stream.Position = 0; - Assert.AreEqual(0, stream.Position, "Position at start should be valid."); - - // Negative position should throw - Assert.Throws(() => stream.Position = -1, - "Negative position should throw."); - - // Position beyond length should throw - Assert.Throws(() => stream.Position = 101, - "Position beyond length should throw."); - } - - /// - /// Tests that PooledMemoryStream.Seek with invalid origin throws ArgumentOutOfRangeException. - /// - [Test] - public void Test_PooledMemoryStream_Seek_InvalidOrigin_ThrowsArgumentOutOfRange() - { - byte[] buffer; - using (var pooledBuffer = new PooledBuffer(100)) - buffer = pooledBuffer.Array; - - using var stream = new PooledMemoryStream(buffer, 100); - - Assert.Throws(() => stream.Seek(0, (SeekOrigin)99), - "Invalid SeekOrigin should throw ArgumentOutOfRangeException."); - } - - /// - /// Tests that PartitionWriteStream.Position setter validates bounds correctly. - /// - [Test] - public void Test_PartitionWriteStream_PositionSetter_ValidatesBounds() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 2 * sectorSize; - - using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize); - - // Valid position - stream.Position = 100; + // Test SeekOrigin.Begin + var pos = stream.Seek(100, SeekOrigin.Begin); + Assert.AreEqual(100, pos, "Seek from Begin should return correct position."); Assert.AreEqual(100, stream.Position, "Position should be 100."); - // Position at max size - stream.Position = partitionSize; - Assert.AreEqual(partitionSize, stream.Position, "Position at max size should be valid."); + // Read a byte to verify position + var readBuffer = new byte[1]; + var bytesRead = stream.Read(readBuffer, 0, 1); + Assert.AreEqual(1, bytesRead, "Should read 1 byte."); + Assert.AreEqual(100, readBuffer[0], "Should read byte at position 100."); - // Negative position should throw - Assert.Throws(() => stream.Position = -1, - "Negative position should throw."); + // Test SeekOrigin.Current + pos = stream.Seek(50, SeekOrigin.Current); + Assert.AreEqual(151, pos, "Seek from Current should return correct position."); + Assert.AreEqual(151, stream.Position, "Position should be 151."); - // Position beyond max size should throw - Assert.Throws(() => stream.Position = partitionSize + 1, - "Position beyond max size should throw."); + bytesRead = stream.Read(readBuffer, 0, 1); + Assert.AreEqual(1, bytesRead, "Should read 1 byte."); + Assert.AreEqual(151, readBuffer[0], "Should read byte at position 151."); + + // Test SeekOrigin.End + pos = stream.Seek(-10, SeekOrigin.End); + Assert.AreEqual(502, pos, "Seek from End should return correct position."); + Assert.AreEqual(502, stream.Position, "Position should be 502."); + + bytesRead = stream.Read(readBuffer, 0, 1); + Assert.AreEqual(1, bytesRead, "Should read 1 byte."); + // 502 & 0xFF = 246 (byte overflow) + Assert.AreEqual(246, readBuffer[0], "Should read byte at position 502 (502 & 0xFF = 246)."); } - - /// - /// Tests that PartitionWriteStream.Seek with invalid origin throws ArgumentOutOfRangeException. - /// - [Test] - public void Test_PartitionWriteStream_Seek_InvalidOrigin_ThrowsArgumentOutOfRange() - { - var sectorSize = _writableRawDisk.SectorSize; - using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, sectorSize); - - Assert.Throws(() => stream.Seek(0, (SeekOrigin)99), - "Invalid SeekOrigin should throw ArgumentOutOfRangeException."); - } - - /// - /// Tests that PartitionWriteStream.SetLength adjusts position when it exceeds new length. - /// - [Test] - public void Test_PartitionWriteStream_SetLength_AdjustsPosition() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 4 * sectorSize; - - using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize); - - // Write some data to move position - stream.Write(new byte[200], 0, 200); - Assert.AreEqual(200, stream.Position, "Position should be 200."); - - // Set length to less than current position - stream.SetLength(100); - Assert.AreEqual(100, stream.Length, "Length should be 100."); - Assert.AreEqual(100, stream.Position, "Position should be adjusted to new length."); - } - - /// - /// Tests that UnknownFilesystem path-based OpenReadStreamAsync works correctly. - /// - [Test] - public async Task Test_UnknownFilesystem_OpenReadStreamAsync_ByPath_ReturnsStream() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - var blockSize = 1024 * 1024; - using var fs = new UnknownFilesystem(partition, blockSize: blockSize); - - // Write data first using IFile-based API - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - var writeData = new byte[100]; - new Random(42).NextBytes(writeData); - using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) - await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); - - // Read using path-based API - var path = $"root/part_Unknown_1/fs_Unknown/{((UnknownFilesystemFile)firstFile!).Address:X}"; - using var readStream = await fs.OpenReadStreamAsync(path, CancellationToken.None); - Assert.IsNotNull(readStream, "Path-based OpenReadStreamAsync should return a stream."); - - var readBuffer = new byte[100]; - var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); - Assert.AreEqual(100, bytesRead, "Should read 100 bytes."); - Assert.AreEqual(writeData, readBuffer, "Data should match."); - } - - /// - /// Tests that UnknownFilesystem path-based OpenWriteStreamAsync works correctly. - /// - [Test] - public async Task Test_UnknownFilesystem_OpenWriteStreamAsync_ByPath_ReturnsStream() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - // Write using path-based API - var path = $"root/part_Unknown_1/fs_Unknown/0"; - using var writeStream = await fs.OpenWriteStreamAsync(path, CancellationToken.None); - Assert.IsNotNull(writeStream, "Path-based OpenWriteStreamAsync should return a stream."); - Assert.IsTrue(writeStream.CanWrite, "Stream should be writable."); - } - - /// - /// Tests that UnknownFilesystem path-based OpenReadWriteStreamAsync works correctly. - /// - [Test] - public async Task Test_UnknownFilesystem_OpenReadWriteStreamAsync_ByPath_ReturnsStream() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - // Open read-write using path-based API - var path = $"root/part_Unknown_1/fs_Unknown/0"; - using var stream = await fs.OpenReadWriteStreamAsync(path, CancellationToken.None); - Assert.IsNotNull(stream, "Path-based OpenReadWriteStreamAsync should return a stream."); - Assert.IsTrue(stream.CanRead, "Stream should be readable."); - Assert.IsTrue(stream.CanWrite, "Stream should be writable."); - } - - /// - /// Tests that UnknownFilesystem path-based GetFileLengthAsync returns correct size. - /// - [Test] - public async Task Test_UnknownFilesystem_GetFileLengthAsync_ByPath_ReturnsCorrectSize() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - var blockSize = 1024 * 1024; - using var fs = new UnknownFilesystem(partition, blockSize: blockSize); - - // Get file length using path-based API - var path = $"root/part_Unknown_1/fs_Unknown/0"; - var length = await fs.GetFileLengthAsync(path, CancellationToken.None); - Assert.AreEqual(blockSize, length, "File length should equal block size for first block."); - } - - /// - /// Tests that UnknownFilesystem.GetFileLengthAsync throws for non-UnknownFilesystemFile (including directories). - /// - [Test] - public void Test_UnknownFilesystem_GetFileLengthAsync_NonUnknownFile_ThrowsArgumentException() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - // A directory file that is not UnknownFilesystemFile should throw "does not belong" - var dirFile = new MockDirectoryFile(); - - var ex = Assert.ThrowsAsync(async () => - { - await fs.GetFileLengthAsync(dirFile, CancellationToken.None); - }, "GetFileLengthAsync should throw for non-UnknownFilesystemFile."); - - StringAssert.Contains("does not belong", ex!.Message, "Exception message should mention file doesn't belong."); - } - - /// - /// Tests that UnknownFilesystem.GetFileLengthAsync throws for non-UnknownFilesystemFile. - /// - [Test] - public void Test_UnknownFilesystem_GetFileLengthAsync_WrongFileType_ThrowsArgumentException() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - // Create a mock file that is not UnknownFilesystemFile - var wrongFile = new MockNonUnknownFile(); - - var ex = Assert.ThrowsAsync(async () => - { - await fs.GetFileLengthAsync(wrongFile, CancellationToken.None); - }, "GetFileLengthAsync should throw for non-UnknownFilesystemFile."); - - StringAssert.Contains("does not belong", ex!.Message, "Exception message should mention file doesn't belong."); - } - - /// - /// Tests that UnknownFilesystemStream.Seek with invalid origin throws ArgumentException. - /// - [Test] - public async Task Test_UnknownFilesystemStream_Seek_InvalidOrigin_ThrowsArgumentException() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 4096); - - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - using var stream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None); - - Assert.Throws(() => stream.Seek(0, (SeekOrigin)99), - "Invalid SeekOrigin should throw ArgumentException."); - } - - /// - /// Tests that UnknownFilesystemStream.Seek beyond bounds throws ArgumentOutOfRangeException. - /// - [Test] - public async Task Test_UnknownFilesystemStream_Seek_BeyondBounds_ThrowsArgumentOutOfRange() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - var blockSize = 4096; - using var fs = new UnknownFilesystem(partition, blockSize: blockSize); - - IFile? firstFile = null; - await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) - { - firstFile = file; - break; - } - Assert.IsNotNull(firstFile, "Should have at least one file."); - - using var stream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None); - - // Seek before beginning - Assert.Throws(() => stream.Seek(-1, SeekOrigin.Begin), - "Seek before beginning should throw."); - - // Seek past end - Assert.Throws(() => stream.Seek(blockSize + 1, SeekOrigin.Begin), - "Seek past end should throw."); - } - - /// - /// Tests that UnknownFilesystem.ListFilesAsync with a non-UnknownFilesystemFile directory throws ArgumentException. - /// - [Test] - public void Test_UnknownFilesystem_ListFilesAsync_WithNonUnknownFile_ThrowsArgumentException() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - // ListFilesAsync with a non-UnknownFilesystemFile should throw - var dirFile = new MockDirectoryFile(); - Assert.ThrowsAsync(async () => - { - await foreach (var file in fs.ListFilesAsync(dirFile, CancellationToken.None)) - { } - }, "ListFilesAsync should throw for non-UnknownFilesystemFile."); - } - - /// - /// Tests that UnknownFilesystem.ListFilesAsync with an UnknownFilesystemFile (non-directory) throws ArgumentException. - /// - [Test] - public void Test_UnknownFilesystem_ListFilesAsync_WithNonDirectory_ThrowsArgumentException() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - // UnknownFilesystemFile.IsDirectory is always false, so this should throw "not a directory" - var nonDirFile = new UnknownFilesystemFile { Address = 0, Size = 1024 * 1024 }; - Assert.ThrowsAsync(async () => - { - await foreach (var file in fs.ListFilesAsync(nonDirFile, CancellationToken.None)) - { } - }, "ListFilesAsync should throw for non-directory UnknownFilesystemFile."); - } - - /// - /// Tests that UnknownFilesystem.ParsePathToAddress throws for invalid path format. - /// - [Test] - public async Task Test_UnknownFilesystem_OpenReadStreamAsync_InvalidPath_ThrowsArgumentException() - { - var sectorSize = _writableRawDisk.SectorSize; - var partitionSize = 5 * MiB; - var partitionOffset = sectorSize * 2048; - - var partition = new TestPartition - { - StartOffset = partitionOffset, - Size = partitionSize, - RawDisk = _writableRawDisk - }; - - using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); - - // Path with too few segments should throw - Assert.ThrowsAsync(async () => - { - await fs.OpenReadStreamAsync("invalid", CancellationToken.None); - }, "Invalid path should throw ArgumentException."); - } - - /// - /// Mock directory file for testing. - /// - private class MockDirectoryFile : IFile - { - public string? Path => "/dir"; - public long? Address => null; - public long Size => 0; - public bool IsDirectory => true; - public DateTime? LastModified => null; - } - - /// - /// Mock non-UnknownFilesystemFile for testing. - /// - private class MockNonUnknownFile : IFile - { - public string? Path => "/file.txt"; - public long? Address => 0; - public long Size => 100; - public bool IsDirectory => false; - public DateTime? LastModified => null; - } - - #endregion } + + /// + /// Tests that seeking beyond stream bounds throws ArgumentOutOfRangeException. + /// + [Test] + public void Test_PooledMemoryStream_SeekOutOfBounds_ThrowsArgumentOutOfRange() + { + byte[] buffer; + using (var pooledBuffer = new PooledBuffer(100)) + buffer = pooledBuffer.Array; + + using var stream = new PooledMemoryStream(buffer, 100); + + // Seek before beginning + Assert.Throws(() => stream.Seek(-1, SeekOrigin.Begin), "Seek before beginning should throw."); + + // Seek past end + Assert.Throws(() => stream.Seek(101, SeekOrigin.Begin), "Seek past end should throw."); + + // Seek before beginning using Current + stream.Seek(50, SeekOrigin.Begin); + Assert.Throws(() => stream.Seek(-51, SeekOrigin.Current), "Seek before beginning from current should throw."); + + // Seek past end using End + Assert.Throws(() => stream.Seek(1, SeekOrigin.End), "Seek past end from end should throw."); + } + + /// + /// Tests that reading past the end of PooledMemoryStream returns 0 bytes. + /// + [Test] + public void Test_PooledMemoryStream_ReadPastEnd_ReturnsZero() + { + byte[] buffer; + using (var pooledBuffer = new PooledBuffer(100)) + { + buffer = pooledBuffer.Array; + // Fill with test data + for (int i = 0; i < 100; i++) + buffer[i] = (byte)(i & 0xFF); + } + + using var stream = new PooledMemoryStream(buffer, 100); + + // Seek to near end + stream.Seek(95, SeekOrigin.Begin); + + // Read 10 bytes when only 5 remain + var readBuffer = new byte[10]; + var bytesRead = stream.Read(readBuffer, 0, 10); + + Assert.AreEqual(5, bytesRead, "Should only read remaining 5 bytes."); + Assert.AreEqual(100, stream.Position, "Position should be at end."); + + // Read again - should return 0 + bytesRead = stream.Read(readBuffer, 0, 10); + Assert.AreEqual(0, bytesRead, "Should return 0 when at end of stream."); + } + + /// + /// Tests that PooledMemoryStream returns buffer to pool on dispose. + /// + [Test] + public void Test_PooledMemoryStream_Dispose_ReturnsBufferToPool() + { + // This test verifies that the stream can be disposed without errors + // and that the buffer is returned to the pool (which we can't directly verify, + // but we can ensure disposal works correctly) + + byte[] buffer; + using (var pooledBuffer = new PooledBuffer(256)) + buffer = pooledBuffer.Array; + + var stream = new PooledMemoryStream(buffer, 256); + + // Use the stream + var readBuffer = new byte[64]; + var bytesRead = stream.Read(readBuffer, 0, 64); + Assert.AreEqual(64, bytesRead, "Should read 64 bytes."); + + // Dispose + stream.Dispose(); + + // After dispose, attempting to read should throw ObjectDisposedException + Assert.Throws(() => { var _ = stream.Read(readBuffer, 0, 64); }, "Reading after dispose should throw."); + + // Note: Seek doesn't throw ObjectDisposedException in this implementation + // because it doesn't access the buffer directly (only _position and _length) + + // CanRead, CanSeek should return false after dispose + Assert.IsFalse(stream.CanRead, "CanRead should be false after dispose."); + Assert.IsFalse(stream.CanSeek, "CanSeek should be false after dispose."); + } + + /// + /// Tests that PooledMemoryStream write and set length operations throw NotSupportedException. + /// + [Test] + public void Test_PooledMemoryStream_WriteAndSetLength_ThrowsNotSupported() + { + byte[] buffer; + using (var pooledBuffer = new PooledBuffer(100)) + buffer = pooledBuffer.Array; + + using var stream = new PooledMemoryStream(buffer, 100); + + // Write should throw + Assert.Throws(() => stream.Write(new byte[10], 0, 10), "Write should throw NotSupportedException."); + + // SetLength should throw + Assert.Throws(() => stream.SetLength(50), "SetLength should throw NotSupportedException."); + } + + /// + /// Tests that PooledMemoryStream handles empty buffers correctly. + /// + [Test] + public void Test_PooledMemoryStream_EmptyBuffer_WorksCorrectly() + { + byte[] buffer; + using (var pooledBuffer = new PooledBuffer(0)) + buffer = pooledBuffer.Array; + + using var stream = new PooledMemoryStream(buffer, 0); + + Assert.AreEqual(0, stream.Length, "Length should be 0."); + Assert.AreEqual(0, stream.Position, "Position should be 0."); + + // Read should return 0 + var readBuffer = new byte[10]; + var bytesRead = stream.Read(readBuffer, 0, 10); + Assert.AreEqual(0, bytesRead, "Read from empty stream should return 0."); + + // Seek to 0 should work + var pos = stream.Seek(0, SeekOrigin.Begin); + Assert.AreEqual(0, pos, "Seek to 0 should work."); + } + + /// + /// Tests that PooledBuffer can be used multiple times in succession. + /// + [Test] + public void Test_PooledBuffer_MultipleInstances_WorkIndependently() + { + // Create multiple PooledBuffers + using var buffer1 = new PooledBuffer(64); + using var buffer2 = new PooledBuffer(128); + using var buffer3 = new PooledBuffer(256); + + // Write different data to each + buffer1.Array[0] = 0x11; + buffer2.Array[0] = 0x22; + buffer3.Array[0] = 0x33; + + // Verify each buffer has its own data + Assert.AreEqual(0x11, buffer1.Array[0], "Buffer1 should have its own data."); + Assert.AreEqual(0x22, buffer2.Array[0], "Buffer2 should have its own data."); + Assert.AreEqual(0x33, buffer3.Array[0], "Buffer3 should have its own data."); + + // Verify lengths + Assert.AreEqual(64, buffer1.Length, "Buffer1 length should be correct."); + Assert.AreEqual(128, buffer2.Length, "Buffer2 length should be correct."); + Assert.AreEqual(256, buffer3.Length, "Buffer3 length should be correct."); + } + + #endregion + + #region RestoreProvider Path Parsing Tests + + /// + /// Creates a RestoreProvider with mock partitions and filesystems for testing path parsing. + /// Uses reflection to set up the internal state. + /// + private static RestoreProvider CreateRestoreProviderForPathParsingTests() + { + // Create a RestoreProvider using the default constructor (for metadata loading) + var provider = new RestoreProvider(); + + // Create mock partition tables + var gptTable = new MockPartitionTable(PartitionTableType.GPT); + var mbrTable = new MockPartitionTable(PartitionTableType.MBR); + + // Create mock partitions + var partitions = new List + { + new MockPartition(gptTable, 1, PartitionType.Primary, 1048576, 20971520, "EFI System", FileSystemType.FAT32), + new MockPartition(gptTable, 2, PartitionType.Primary, 22020096, 41943040, "Windows OS", FileSystemType.NTFS), + new MockPartition(mbrTable, 1, PartitionType.Primary, 1048576, 10485760, "MBR Partition 1", FileSystemType.FAT32), + new MockPartition(mbrTable, 2, PartitionType.Primary, 11534336, 20971520, "MBR Partition 2", FileSystemType.NTFS) + }; + + // Create mock filesystems + var filesystems = new List + { + new MockFilesystem(partitions[0], FileSystemType.FAT32), + new MockFilesystem(partitions[1], FileSystemType.NTFS), + new MockFilesystem(partitions[2], FileSystemType.FAT32), + new MockFilesystem(partitions[3], FileSystemType.NTFS) + }; + + // Use reflection to set the private fields + var partitionsField = typeof(RestoreProvider).GetField("_partitions", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var filesystemsField = typeof(RestoreProvider).GetField("_filesystems", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + partitionsField?.SetValue(provider, partitions); + filesystemsField?.SetValue(provider, filesystems); + + return provider; + } + + /// + /// Mock implementation of IPartitionTable for testing. + /// + private class MockPartitionTable(PartitionTableType tableType) : IPartitionTable + { + public IRawDisk? RawDisk => null; + public PartitionTableType TableType { get; } = tableType; + + public IAsyncEnumerable EnumeratePartitions(CancellationToken cancellationToken) + { + return AsyncEnumerable.Empty(); + } + + public Task GetPartitionAsync(int partitionNumber, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public Task GetProtectiveMbrAsync(CancellationToken cancellationToken) + { + return Task.FromResult(new MemoryStream()); + } + + public Task GetPartitionTableDataAsync(CancellationToken cancellationToken) + { + return Task.FromResult(new MemoryStream()); + } + + public void Dispose() { } + } + + /// + /// Mock implementation of IPartition for testing. + /// + private class MockPartition(IPartitionTable table, int number, PartitionType type, long startOffset, long size, string? name, FileSystemType fsType) : IPartition + { + public IPartitionTable PartitionTable { get; } = table; + public int PartitionNumber { get; } = number; + public PartitionType Type { get; } = type; + public long StartOffset { get; } = startOffset; + public long Size { get; } = size; + public string? Name { get; } = name; + public FileSystemType FilesystemType { get; } = fsType; + public Guid? VolumeGuid { get; } = Guid.NewGuid(); + + public Task OpenReadAsync(CancellationToken cancellationToken) + { + return Task.FromResult(new MemoryStream()); + } + + public Task OpenWriteAsync(CancellationToken cancellationToken) + { + return Task.FromResult(new MemoryStream()); + } + + public void Dispose() { } + } + + /// + /// Mock implementation of IFilesystem for testing. + /// + private class MockFilesystem(IPartition partition, FileSystemType type) : IFilesystem + { + public IPartition Partition { get; } = partition; + public FileSystemType Type { get; } = type; + + public Task GetFilesystemMetadataAsync(CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public IAsyncEnumerable ListFilesAsync(CancellationToken cancellationToken) + { + return AsyncEnumerable.Empty(); + } + + public IAsyncEnumerable ListFilesAsync(IFile directory, CancellationToken cancellationToken) + { + return AsyncEnumerable.Empty(); + } + + public Task OpenReadStreamAsync(IFile file, CancellationToken cancellationToken) + { + return Task.FromResult(new MemoryStream()); + } + + public Task OpenWriteStreamAsync(IFile file, CancellationToken cancellationToken) + { + return Task.FromResult(new MemoryStream()); + } + + public Task OpenReadWriteStreamAsync(IFile file, CancellationToken cancellationToken) + { + return Task.FromResult(new MemoryStream()); + } + + public Task GetFileLengthAsync(IFile file, CancellationToken cancellationToken) + { + return Task.FromResult(0L); + } + + public Task OpenReadStreamAsync(string path, CancellationToken cancellationToken) + { + return Task.FromResult(new MemoryStream()); + } + + public Task OpenWriteStreamAsync(string path, CancellationToken cancellationToken) + { + return Task.FromResult(new MemoryStream()); + } + + public Task OpenReadWriteStreamAsync(string path, CancellationToken cancellationToken) + { + return Task.FromResult(new MemoryStream()); + } + + public Task GetFileLengthAsync(string path, CancellationToken cancellationToken) + { + return Task.FromResult(0L); + } + + public void Dispose() { } + } + + /// + /// Tests that ParsePartition correctly parses a valid GPT partition segment. + /// + [Test] + public void Test_RestoreProvider_ParsePartition_ValidGPTPartition_ReturnsCorrectPartition() + { + using var provider = CreateRestoreProviderForPathParsingTests(); + var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist."); + + // Test parsing "part_GPT_1" + var result = parsePartitionMethod!.Invoke(provider, ["part_GPT_1"]); + Assert.IsNotNull(result, "Should return a partition."); + + var partition = (IPartition)result!; + Assert.AreEqual(1, partition.PartitionNumber, "Partition number should be 1."); + Assert.AreEqual(PartitionTableType.GPT, partition.PartitionTable.TableType, "Partition table type should be GPT."); + Assert.AreEqual("EFI System", partition.Name, "Partition name should match."); + } + + /// + /// Tests that ParsePartition correctly parses a valid MBR partition segment. + /// + [Test] + public void Test_RestoreProvider_ParsePartition_ValidMBRPartition_ReturnsCorrectPartition() + { + using var provider = CreateRestoreProviderForPathParsingTests(); + var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist."); + + // Test parsing "part_MBR_2" + var result = parsePartitionMethod!.Invoke(provider, ["part_MBR_2"]); + Assert.IsNotNull(result, "Should return a partition."); + + var partition = (IPartition)result!; + Assert.AreEqual(2, partition.PartitionNumber, "Partition number should be 2."); + Assert.AreEqual(PartitionTableType.MBR, partition.PartitionTable.TableType, "Partition table type should be MBR."); + Assert.AreEqual("MBR Partition 2", partition.Name, "Partition name should match."); + } + + /// + /// Tests that ParsePartition throws InvalidOperationException for malformed partition segments. + /// + [Test] + public void Test_RestoreProvider_ParsePartition_MalformedSegment_ThrowsInvalidOperationException() + { + using var provider = CreateRestoreProviderForPathParsingTests(); + var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist."); + + // Test with too few parts + var ex = Assert.Throws(() => + parsePartitionMethod!.Invoke(provider, ["part_GPT"]), + "Should throw for malformed segment."); + Assert.IsInstanceOf(ex!.InnerException, "Inner exception should be InvalidOperationException."); + + // Test with invalid partition table type + ex = Assert.Throws(() => + parsePartitionMethod!.Invoke(provider, ["part_INVALID_1"]), + "Should throw for invalid table type."); + Assert.IsInstanceOf(ex!.InnerException, "Inner exception should be InvalidOperationException."); + + // Test with non-numeric partition number + ex = Assert.Throws(() => + parsePartitionMethod!.Invoke(provider, ["part_GPT_ABC"]), + "Should throw for non-numeric partition number."); + Assert.IsInstanceOf(ex!.InnerException, "Inner exception should be InvalidOperationException."); + } + + /// + /// Tests that ParsePartition throws InvalidOperationException for non-existent partitions. + /// + [Test] + public void Test_RestoreProvider_ParsePartition_NonExistentPartition_ThrowsInvalidOperationException() + { + using var provider = CreateRestoreProviderForPathParsingTests(); + var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist."); + + // Test with partition number that doesn't exist + var ex = Assert.Throws(() => + parsePartitionMethod!.Invoke(provider, ["part_GPT_99"]), + "Should throw for non-existent partition."); + Assert.IsInstanceOf(ex!.InnerException, "Inner exception should be InvalidOperationException."); + } + + /// + /// Tests that ParseFilesystem correctly parses a valid NTFS filesystem segment. + /// + [Test] + public void Test_RestoreProvider_ParseFilesystem_ValidNTFS_ReturnsCorrectFilesystem() + { + using var provider = CreateRestoreProviderForPathParsingTests(); + var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var parseFilesystemMethod = typeof(RestoreProvider).GetMethod("ParseFilesystem", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist."); + Assert.IsNotNull(parseFilesystemMethod, "ParseFilesystem method should exist."); + + // Get a partition first + var partitionResult = parsePartitionMethod!.Invoke(provider, ["part_GPT_2"]); + Assert.IsNotNull(partitionResult, "Should return a partition."); + var partition = (IPartition)partitionResult!; + + // Test parsing "fs_NTFS" + var result = parseFilesystemMethod!.Invoke(provider, [partition, "fs_NTFS"]); + Assert.IsNotNull(result, "Should return a filesystem."); + + var filesystem = (IFilesystem)result!; + Assert.AreEqual(FileSystemType.NTFS, filesystem.Type, "Filesystem type should be NTFS."); + Assert.AreEqual(partition.PartitionNumber, filesystem.Partition.PartitionNumber, "Partition number should match."); + } + + /// + /// Tests that ParseFilesystem correctly parses a valid FAT32 filesystem segment. + /// + [Test] + public void Test_RestoreProvider_ParseFilesystem_ValidFAT32_ReturnsCorrectFilesystem() + { + using var provider = CreateRestoreProviderForPathParsingTests(); + var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var parseFilesystemMethod = typeof(RestoreProvider).GetMethod("ParseFilesystem", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist."); + Assert.IsNotNull(parseFilesystemMethod, "ParseFilesystem method should exist."); + + // Get a partition first + var partitionResult = parsePartitionMethod!.Invoke(provider, ["part_GPT_1"]); + Assert.IsNotNull(partitionResult, "Should return a partition."); + var partition = (IPartition)partitionResult!; + + // Test parsing "fs_FAT32" + var result = parseFilesystemMethod!.Invoke(provider, [partition, "fs_FAT32"]); + Assert.IsNotNull(result, "Should return a filesystem."); + + var filesystem = (IFilesystem)result!; + Assert.AreEqual(FileSystemType.FAT32, filesystem.Type, "Filesystem type should be FAT32."); + } + + /// + /// Tests that ParseFilesystem throws InvalidOperationException for malformed filesystem segments. + /// + [Test] + public void Test_RestoreProvider_ParseFilesystem_MalformedSegment_ThrowsInvalidOperationException() + { + using var provider = CreateRestoreProviderForPathParsingTests(); + var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var parseFilesystemMethod = typeof(RestoreProvider).GetMethod("ParseFilesystem", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist."); + Assert.IsNotNull(parseFilesystemMethod, "ParseFilesystem method should exist."); + + // Get a partition first + var partitionResult = parsePartitionMethod!.Invoke(provider, ["part_GPT_1"]); + Assert.IsNotNull(partitionResult, "Should return a partition."); + var partition = (IPartition)partitionResult!; + + // Test with too few parts + var ex = Assert.Throws(() => + parseFilesystemMethod!.Invoke(provider, [partition, "fs"]), + "Should throw for malformed segment."); + Assert.IsInstanceOf(ex!.InnerException, "Inner exception should be InvalidOperationException."); + + // Test with invalid filesystem type + ex = Assert.Throws(() => + parseFilesystemMethod!.Invoke(provider, [partition, "fs_INVALID"]), + "Should throw for invalid filesystem type."); + Assert.IsInstanceOf(ex!.InnerException, "Inner exception should be InvalidOperationException."); + } + + /// + /// Tests that ParseFilesystem throws InvalidOperationException for non-existent filesystems. + /// + [Test] + public void Test_RestoreProvider_ParseFilesystem_NonExistentFilesystem_ThrowsInvalidOperationException() + { + using var provider = CreateRestoreProviderForPathParsingTests(); + var parsePartitionMethod = typeof(RestoreProvider).GetMethod("ParsePartition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var parseFilesystemMethod = typeof(RestoreProvider).GetMethod("ParseFilesystem", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + Assert.IsNotNull(parsePartitionMethod, "ParsePartition method should exist."); + Assert.IsNotNull(parseFilesystemMethod, "ParseFilesystem method should exist."); + + // Get a partition that doesn't have an Ext4 filesystem + var partitionResult = parsePartitionMethod!.Invoke(provider, ["part_GPT_1"]); + Assert.IsNotNull(partitionResult, "Should return a partition."); + var partition = (IPartition)partitionResult!; + + // Test with filesystem type that doesn't exist for this partition + var ex = Assert.Throws(() => + parseFilesystemMethod!.Invoke(provider, [partition, "fs_Ext4"]), + "Should throw for non-existent filesystem."); + Assert.IsInstanceOf(ex!.InnerException, "Inner exception should be InvalidOperationException."); + } + + /// + /// Tests that ParsePath correctly identifies a geometry file path. + /// + [Test] + public void Test_RestoreProvider_ParsePath_GeometryFile_ReturnsGeometryType() + { + using var provider = CreateRestoreProviderForPathParsingTests(); + var parsePathMethod = typeof(RestoreProvider).GetMethod("ParsePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + Assert.IsNotNull(parsePathMethod, "ParsePath method should exist."); + + // Test with geometry.json file + var result = parsePathMethod!.Invoke(provider, ["geometry.json"]); + Assert.IsNotNull(result, "Should return a tuple."); + + var tuple = ((string Type, IPartition? Partition, IFilesystem? Filesystem))result!; + Assert.AreEqual("geometry", tuple.Type, "Type should be 'geometry'."); + Assert.IsNull(tuple.Partition, "Partition should be null for geometry."); + Assert.IsNull(tuple.Filesystem, "Filesystem should be null for geometry."); + } + + /// + /// Tests that ParsePath correctly identifies a disk-level path. + /// + [Test] + public void Test_RestoreProvider_ParsePath_DiskPath_ReturnsDiskType() + { + using var provider = CreateRestoreProviderForPathParsingTests(); + var parsePathMethod = typeof(RestoreProvider).GetMethod("ParsePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + Assert.IsNotNull(parsePathMethod, "ParsePath method should exist."); + + // Test with root path + var result = parsePathMethod!.Invoke(provider, ["/"]); + Assert.IsNotNull(result, "Should return a tuple."); + + var tuple = ((string Type, IPartition? Partition, IFilesystem? Filesystem))result!; + Assert.AreEqual("disk", tuple.Type, "Type should be 'disk'."); + Assert.IsNull(tuple.Partition, "Partition should be null for disk."); + Assert.IsNull(tuple.Filesystem, "Filesystem should be null for disk."); + } + + /// + /// Tests that ParsePath correctly identifies a partition path. + /// + [Test] + public void Test_RestoreProvider_ParsePath_PartitionPath_ReturnsPartitionType() + { + using var provider = CreateRestoreProviderForPathParsingTests(); + var parsePathMethod = typeof(RestoreProvider).GetMethod("ParsePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + Assert.IsNotNull(parsePathMethod, "ParsePath method should exist."); + + // Test with partition path (no filesystem segment) + var result = parsePathMethod!.Invoke(provider, ["part_GPT_1"]); + Assert.IsNotNull(result, "Should return a tuple."); + + var tuple = ((string Type, IPartition? Partition, IFilesystem? Filesystem))result!; + Assert.AreEqual("partition", tuple.Type, "Type should be 'partition'."); + Assert.IsNotNull(tuple.Partition, "Partition should not be null."); + Assert.AreEqual(1, tuple.Partition!.PartitionNumber, "Partition number should be 1."); + Assert.IsNull(tuple.Filesystem, "Filesystem should be null for partition-only path."); + } + + /// + /// Tests that ParsePath correctly identifies a file path with partition and filesystem. + /// + [Test] + public void Test_RestoreProvider_ParsePath_FilePath_ReturnsFileType() + { + using var provider = CreateRestoreProviderForPathParsingTests(); + var parsePathMethod = typeof(RestoreProvider).GetMethod("ParsePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + Assert.IsNotNull(parsePathMethod, "ParsePath method should exist."); + + // Test with full file path + var result = parsePathMethod!.Invoke(provider, ["part_GPT_1/fs_FAT32/test/file.txt"]); + Assert.IsNotNull(result, "Should return a tuple."); + + var tuple = ((string Type, IPartition? Partition, IFilesystem? Filesystem))result!; + Assert.AreEqual("file", tuple.Type, "Type should be 'file'."); + Assert.IsNotNull(tuple.Partition, "Partition should not be null."); + Assert.IsNotNull(tuple.Filesystem, "Filesystem should not be null."); + Assert.AreEqual(1, tuple.Partition!.PartitionNumber, "Partition number should be 1."); + Assert.AreEqual(FileSystemType.FAT32, tuple.Filesystem!.Type, "Filesystem type should be FAT32."); + } + + /// + /// Tests that ParsePath handles MBR partition paths correctly. + /// + [Test] + public void Test_RestoreProvider_ParsePath_MBRPartitionPath_ReturnsCorrectPartition() + { + using var provider = CreateRestoreProviderForPathParsingTests(); + var parsePathMethod = typeof(RestoreProvider).GetMethod("ParsePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + Assert.IsNotNull(parsePathMethod, "ParsePath method should exist."); + + // Test with MBR partition path + var result = parsePathMethod!.Invoke(provider, ["part_MBR_2/fs_NTFS/data/file.dat"]); + Assert.IsNotNull(result, "Should return a tuple."); + + var tuple = ((string Type, IPartition? Partition, IFilesystem? Filesystem))result!; + Assert.AreEqual("file", tuple.Type, "Type should be 'file'."); + Assert.IsNotNull(tuple.Partition, "Partition should not be null."); + Assert.IsNotNull(tuple.Filesystem, "Filesystem should not be null."); + Assert.AreEqual(2, tuple.Partition!.PartitionNumber, "Partition number should be 2."); + Assert.AreEqual(PartitionTableType.MBR, tuple.Partition.PartitionTable.TableType, "Table type should be MBR."); + Assert.AreEqual(FileSystemType.NTFS, tuple.Filesystem!.Type, "Filesystem type should be NTFS."); + } + + /// + /// Tests that ParsePath handles paths with different separators correctly on Windows. + /// On Windows, both forward slash and backslash are valid path separators. + /// On Linux/macOS, backslash is a valid filename character, not a separator. + /// + [Test] + public void Test_RestoreProvider_ParsePath_DifferentSeparators_NormalizesCorrectly() + { + using var provider = CreateRestoreProviderForPathParsingTests(); + var parsePathMethod = typeof(RestoreProvider).GetMethod("ParsePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + Assert.IsNotNull(parsePathMethod, "ParsePath method should exist."); + + if (OperatingSystem.IsWindows()) + { + // On Windows, backslash is a path separator + var result = parsePathMethod!.Invoke(provider, ["part_GPT_1\\fs_FAT32\\test\\file.txt"]); + Assert.IsNotNull(result, "Should return a tuple."); + + var tuple = ((string Type, IPartition? Partition, IFilesystem? Filesystem))result!; + Assert.AreEqual("file", tuple.Type, "Type should be 'file'."); + Assert.IsNotNull(tuple.Partition, "Partition should not be null."); + Assert.IsNotNull(tuple.Filesystem, "Filesystem should not be null."); + } + else + { + // On Linux/macOS, backslash is a filename character, not a separator + // The entire string becomes one segment, which fails partition parsing + var ex = Assert.Throws(() => + parsePathMethod!.Invoke(provider, ["part_GPT_1\\fs_FAT32\\test\\file.txt"]), + "Should throw for backslash path on non-Windows platforms."); + Assert.IsInstanceOf(ex!.InnerException, "Inner exception should be InvalidOperationException."); + } + } + + /// + /// Tests that ParsePath throws appropriate exceptions for invalid paths. + /// + [Test] + public void Test_RestoreProvider_ParsePath_InvalidPartitionSegment_ThrowsException() + { + using var provider = CreateRestoreProviderForPathParsingTests(); + var parsePathMethod = typeof(RestoreProvider).GetMethod("ParsePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + Assert.IsNotNull(parsePathMethod, "ParsePath method should exist."); + + // Test with invalid partition segment format - this should still parse as file type + // but will fail when trying to parse the partition + var ex = Assert.Throws(() => + parsePathMethod!.Invoke(provider, ["part_INVALID/fs_NTFS/file.txt"]), + "Should throw for invalid partition segment."); + Assert.IsInstanceOf(ex!.InnerException, "Inner exception should be InvalidOperationException."); + } + + #endregion + + #region Edge Case and Boundary Tests + + /// + /// Tests reading at offset 0 (first sector) returns correct data. + /// + [Test] + public async Task Test_RawDisk_ReadAtOffsetZero_ReturnsCorrectData() + { + var sectorSize = _writableRawDisk.SectorSize; + + // Write known pattern to sector 0 (first sector) + var writeBuffer = new byte[sectorSize]; + for (int i = 0; i < sectorSize; i++) + writeBuffer[i] = (byte)(i & 0xFF); + + await _writableRawDisk.WriteSectorsAsync(0, writeBuffer, CancellationToken.None); + + // Read at offset 0 + using var stream = await _writableRawDisk.ReadBytesAsync(0, sectorSize, CancellationToken.None); + var readBuffer = new byte[sectorSize]; + var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector."); + Assert.AreEqual(writeBuffer, readBuffer, "Data at offset 0 should match what was written."); + } + + /// + /// Tests writing at offset 0 (first sector) works correctly. + /// + [Test] + public async Task Test_RawDisk_WriteAtOffsetZero_DataMatches() + { + var sectorSize = _writableRawDisk.SectorSize; + + // Write at offset 0 + var writeBuffer = new byte[sectorSize]; + new Random(123).NextBytes(writeBuffer); + + var bytesWritten = await _writableRawDisk.WriteBytesAsync(0, writeBuffer, CancellationToken.None); + Assert.AreEqual(sectorSize, bytesWritten, "Should have written one sector."); + + // Read back and verify + using var readStream = await _writableRawDisk.ReadSectorsAsync(0, 1, CancellationToken.None); + var readBuffer = new byte[sectorSize]; + var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector."); + Assert.AreEqual(writeBuffer, readBuffer, "Data at offset 0 should match."); + } + + /// + /// Tests reading at the last sector of the disk returns correct data. + /// + [Test] + public async Task Test_RawDisk_ReadAtLastSector_ReturnsCorrectData() + { + var sectorSize = _writableRawDisk.SectorSize; + var diskSize = _writableRawDisk.Size; + var lastSector = (diskSize / sectorSize) - 1; + + // Write known pattern to last sector + var writeBuffer = new byte[sectorSize]; + for (int i = 0; i < sectorSize; i++) + writeBuffer[i] = (byte)((i + 100) & 0xFF); + + await _writableRawDisk.WriteSectorsAsync(lastSector, writeBuffer, CancellationToken.None); + + // Read at last sector offset + var lastSectorOffset = lastSector * sectorSize; + using var stream = await _writableRawDisk.ReadBytesAsync(lastSectorOffset, sectorSize, CancellationToken.None); + var readBuffer = new byte[sectorSize]; + var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector."); + Assert.AreEqual(writeBuffer, readBuffer, "Data at last sector should match."); + } + + /// + /// Tests writing at the last sector of the disk works correctly. + /// + [Test] + public async Task Test_RawDisk_WriteAtLastSector_DataMatches() + { + var sectorSize = _writableRawDisk.SectorSize; + var diskSize = _writableRawDisk.Size; + var lastSector = (diskSize / sectorSize) - 1; + + // Write at last sector + var writeBuffer = new byte[sectorSize]; + new Random(456).NextBytes(writeBuffer); + + var bytesWritten = await _writableRawDisk.WriteSectorsAsync(lastSector, writeBuffer, CancellationToken.None); + Assert.AreEqual(sectorSize, bytesWritten, "Should have written one sector at last position."); + + // Read back and verify + using var readStream = await _writableRawDisk.ReadSectorsAsync(lastSector, 1, CancellationToken.None); + var readBuffer = new byte[sectorSize]; + var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector."); + Assert.AreEqual(writeBuffer, readBuffer, "Data at last sector should match."); + } + + /// + /// Tests reading exactly one sector returns correct data. + /// + [Test] + public async Task Test_RawDisk_ReadExactlyOneSector_ReturnsCorrectData() + { + var sectorSize = _writableRawDisk.SectorSize; + + // Write known pattern to sector 5 + var writeBuffer = new byte[sectorSize]; + for (int i = 0; i < sectorSize; i++) + writeBuffer[i] = (byte)((i * 3) & 0xFF); + + await _writableRawDisk.WriteSectorsAsync(5, writeBuffer, CancellationToken.None); + + // Read exactly one sector using ReadBytesAsync + var offset = 5 * sectorSize; + using var stream = await _writableRawDisk.ReadBytesAsync(offset, sectorSize, CancellationToken.None); + var readBuffer = new byte[sectorSize]; + var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(sectorSize, bytesRead, "Should have read exactly one sector."); + Assert.AreEqual(sectorSize, stream.Length, "Stream length should be exactly one sector."); + Assert.AreEqual(writeBuffer, readBuffer, "Data should match exactly."); + } + + /// + /// Tests writing exactly one sector works correctly. + /// + [Test] + public async Task Test_RawDisk_WriteExactlyOneSector_DataMatches() + { + var sectorSize = _writableRawDisk.SectorSize; + + // Write exactly one sector using WriteBytesAsync + var writeBuffer = new byte[sectorSize]; + new Random(789).NextBytes(writeBuffer); + + var offset = 10 * sectorSize; + var bytesWritten = await _writableRawDisk.WriteBytesAsync(offset, writeBuffer, CancellationToken.None); + + Assert.AreEqual(sectorSize, bytesWritten, "Should have written exactly one sector."); + + // Read back and verify + using var readStream = await _writableRawDisk.ReadSectorsAsync(10, 1, CancellationToken.None); + var readBuffer = new byte[sectorSize]; + var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(sectorSize, bytesRead, "Should have read one sector."); + Assert.AreEqual(writeBuffer, readBuffer, "Data should match exactly."); + } + + /// + /// Tests reading the full disk size works correctly. + /// + [Test] + public async Task Test_RawDisk_ReadFullDiskSize_ReturnsCorrectData() + { + // Use a smaller test disk for this test to avoid memory issues + var sectorSize = _writableRawDisk.SectorSize; + var diskSize = _writableRawDisk.Size; + + // Read full disk in chunks to verify it works + const int chunkSectors = 10; + var chunkSize = chunkSectors * sectorSize; + var totalSectors = diskSize / sectorSize; + var sectorsRead = 0L; + + for (long sector = 0; sector < totalSectors; sector += chunkSectors) + { + var sectorsToRead = (int)Math.Min(chunkSectors, totalSectors - sector); + using var stream = await _writableRawDisk.ReadSectorsAsync(sector, sectorsToRead, CancellationToken.None); + + var buffer = new byte[sectorsToRead * sectorSize]; + var bytesRead = await stream.ReadAsync(buffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(sectorsToRead * sectorSize, bytesRead, $"Should have read {sectorsToRead} sectors at offset {sector}."); + sectorsRead += sectorsToRead; + } + + Assert.AreEqual(totalSectors, sectorsRead, "Should have read all sectors."); + } + + /// + /// Tests writing the full disk size works correctly. + /// + [Test] + public async Task Test_RawDisk_WriteFullDiskSize_DataMatches() + { + var sectorSize = _writableRawDisk.SectorSize; + var diskSize = _writableRawDisk.Size; + + // Write a unique pattern to each sector and verify + var totalSectors = diskSize / sectorSize; + const int testSectors = 20; // Test a subset to keep test fast + + for (long sector = 0; sector < Math.Min(testSectors, totalSectors); sector++) + { + // Create unique pattern for this sector + var writeBuffer = new byte[sectorSize]; + var patternByte = (byte)(sector & 0xFF); + for (int i = 0; i < sectorSize; i++) + writeBuffer[i] = (byte)((patternByte + i) & 0xFF); + + await _writableRawDisk.WriteSectorsAsync(sector, writeBuffer, CancellationToken.None); + + // Read back and verify + using var readStream = await _writableRawDisk.ReadSectorsAsync(sector, 1, CancellationToken.None); + var readBuffer = new byte[sectorSize]; + var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(sectorSize, bytesRead, $"Should read sector {sector}."); + Assert.AreEqual(writeBuffer, readBuffer, $"Data at sector {sector} should match."); + } + } + + /// + /// Tests writing empty data (0 bytes) returns 0 without error. + /// + [Test] + public async Task Test_RawDisk_WriteEmptyData_ReturnsZero() + { + var sectorSize = _writableRawDisk.SectorSize; + + // Write empty data + var emptyData = Array.Empty(); + var bytesWritten = await _writableRawDisk.WriteBytesAsync(sectorSize, emptyData, CancellationToken.None); + + Assert.AreEqual(0, bytesWritten, "Writing empty data should return 0 bytes written."); + } + + /// + /// Tests reading empty data (0 bytes) returns 0 without error. + /// + [Test] + public async Task Test_RawDisk_ReadEmptyData_ReturnsZero() + { + var sectorSize = _writableRawDisk.SectorSize; + + // Read 0 bytes + using var stream = await _writableRawDisk.ReadBytesAsync(sectorSize, 0, CancellationToken.None); + var buffer = Array.Empty(); + var bytesRead = await stream.ReadAsync(buffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(0, bytesRead, "Reading 0 bytes should return 0."); + Assert.AreEqual(0, stream.Length, "Stream length should be 0 for empty read."); + } + + /// + /// Tests that a disk with minimum possible size still supports basic operations. + /// Creates a small test disk and verifies read/write operations work. + /// + [Test] + public async Task Test_RawDisk_MinimumSizeDisk_OperationsWork() + { + // The minimum practical disk size is at least a few sectors + // We'll use the writable disk which is already created at 100 MiB + // but verify operations work at the boundaries + var sectorSize = _writableRawDisk.SectorSize; + var diskSize = _writableRawDisk.Size; + + // Minimum operations: read/write first sector + var firstSectorData = new byte[sectorSize]; + new Random(111).NextBytes(firstSectorData); + await _writableRawDisk.WriteSectorsAsync(0, firstSectorData, CancellationToken.None); + + using var firstReadStream = await _writableRawDisk.ReadSectorsAsync(0, 1, CancellationToken.None); + var firstReadBuffer = new byte[sectorSize]; + var firstBytesRead = await firstReadStream.ReadAsync(firstReadBuffer.AsMemory(), CancellationToken.None); + Assert.AreEqual(sectorSize, firstBytesRead, "Should read exactly one sector."); + Assert.AreEqual(firstSectorData, firstReadBuffer, "First sector should be readable/writable."); + + // Minimum operations: read/write last sector + var lastSector = (diskSize / sectorSize) - 1; + var lastSectorData = new byte[sectorSize]; + new Random(222).NextBytes(lastSectorData); + await _writableRawDisk.WriteSectorsAsync(lastSector, lastSectorData, CancellationToken.None); + + using var lastReadStream = await _writableRawDisk.ReadSectorsAsync(lastSector, 1, CancellationToken.None); + var lastReadBuffer = new byte[sectorSize]; + var lastBytesRead = await lastReadStream.ReadAsync(lastReadBuffer.AsMemory(), CancellationToken.None); + Assert.AreEqual(sectorSize, lastBytesRead, "Should read exactly one sector."); + Assert.AreEqual(lastSectorData, lastReadBuffer, "Last sector should be readable/writable."); + + // Verify disk size is reported correctly + Assert.Greater(diskSize, 0, "Disk size should be greater than 0."); + Assert.AreEqual(0, diskSize % sectorSize, "Disk size should be sector-aligned."); + } + + /// + /// Tests boundary condition: reading at exactly the disk size boundary should fail gracefully. + /// + [Test] + public void Test_RawDisk_ReadAtDiskSizeBoundary_ThrowsInvalidOperationException() + { + var sectorSize = _writableRawDisk.SectorSize; + var diskSize = _writableRawDisk.Size; + + // Attempting to read at or beyond disk size should throw InvalidOperationException + var ex = Assert.ThrowsAsync(async () => + { + using var stream = await _writableRawDisk.ReadBytesAsync(diskSize, sectorSize, CancellationToken.None); + var buffer = new byte[sectorSize]; + var _ = await stream.ReadAsync(buffer.AsMemory(), CancellationToken.None); + }, "Reading at disk boundary should throw InvalidOperationException."); + + StringAssert.Contains("beyond disk size", ex!.Message, "Exception message should mention beyond disk size."); + } + + /// + /// Tests boundary condition: writing at exactly the disk size boundary should fail gracefully. + /// + [Test] + public void Test_RawDisk_WriteAtDiskSizeBoundary_ThrowsInvalidOperationException() + { + var sectorSize = _writableRawDisk.SectorSize; + var diskSize = _writableRawDisk.Size; + + // Attempting to write at or beyond disk size should throw InvalidOperationException + var ex = Assert.ThrowsAsync(async () => + { + var writeData = new byte[sectorSize]; + await _writableRawDisk.WriteBytesAsync(diskSize, writeData, CancellationToken.None); + }, "Writing at disk boundary should throw InvalidOperationException."); + + StringAssert.Contains("beyond disk size", ex!.Message, "Exception message should mention beyond disk size."); + } + + /// + /// Tests reading/writing a single byte at various boundary positions. + /// + [Test] + public async Task Test_RawDisk_SingleByteAtBoundaries_DataMatches() + { + var sectorSize = _writableRawDisk.SectorSize; + + // Test positions: first byte of disk, last byte of first sector, + // first byte of second sector, and various other positions + var testPositions = new[] + { + 0L, // First byte of disk + sectorSize - 1, // Last byte of first sector + sectorSize, // First byte of second sector + sectorSize + 1, // Second byte of second sector + (2 * sectorSize) - 1, // Last byte of second sector + 2 * sectorSize // First byte of third sector + }; + + for (int i = 0; i < testPositions.Length; i++) + { + var position = testPositions[i]; + var testByte = (byte)(0xA0 + i); + + // Write single byte + var writeData = new[] { testByte }; + var bytesWritten = await _writableRawDisk.WriteBytesAsync(position, writeData, CancellationToken.None); + Assert.AreEqual(1, bytesWritten, $"Should write 1 byte at position {position}."); + + // Read back the byte + using var stream = await _writableRawDisk.ReadBytesAsync(position, 1, CancellationToken.None); + var readBuffer = new byte[1]; + var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + + Assert.AreEqual(1, bytesRead, $"Should read 1 byte at position {position}."); + Assert.AreEqual(testByte, readBuffer[0], $"Byte at position {position} should match."); + } + } + + #endregion + + #region Coverage increasing tests + + [Test] + public async Task Test_GPT_GetProtectiveMbrAsync_ReturnsValidMbr() + { + var sectorSize = s_gptRawDisk!.SectorSize; + + // Read enough sectors to parse GPT + var sectorsToRead = 34; + using var stream = await s_gptRawDisk.ReadSectorsAsync(0, sectorsToRead, CancellationToken.None); + var diskBytes = new byte[sectorSize * sectorsToRead]; + await stream.ReadAtLeastAsync(diskBytes, diskBytes.Length, cancellationToken: CancellationToken.None); + + // Parse GPT + var gpt = new GPT(s_gptRawDisk); + var parsed = await gpt.ParseAsync(diskBytes, sectorSize, CancellationToken.None); + Assert.IsTrue(parsed, "GPT should parse successfully."); + + // Get protective MBR + using var mbrStream = await gpt.GetProtectiveMbrAsync(CancellationToken.None); + Assert.IsNotNull(mbrStream, "Protective MBR stream should not be null."); + Assert.Greater(mbrStream.Length, 0, "Protective MBR stream should have data."); + + // Read MBR bytes and verify boot signature + var mbrBytes = new byte[mbrStream.Length]; + var bytesRead = await mbrStream.ReadAsync(mbrBytes.AsMemory(), CancellationToken.None); + Assert.Greater(bytesRead, 0, "Should have read MBR bytes."); + + // Verify MBR boot signature (0x55AA at offset 510-511) + if (bytesRead >= 512) + { + Assert.AreEqual(0x55, mbrBytes[510], "MBR boot signature first byte should be 0x55."); + Assert.AreEqual(0xAA, mbrBytes[511], "MBR boot signature second byte should be 0xAA."); + } + + gpt.Dispose(); + } + + [Test] + public async Task Test_GPT_GetPartitionTableDataAsync_ReturnsValidData() + { + var sectorSize = s_gptRawDisk!.SectorSize; + + // Read enough sectors to parse GPT + var sectorsToRead = 34; + using var stream = await s_gptRawDisk.ReadSectorsAsync(0, sectorsToRead, CancellationToken.None); + var diskBytes = new byte[sectorSize * sectorsToRead]; + await stream.ReadAtLeastAsync(diskBytes, diskBytes.Length, cancellationToken: CancellationToken.None); + + // Parse GPT with raw disk reference + var gpt = new GPT(s_gptRawDisk); + var parsed = await gpt.ParseAsync(diskBytes, sectorSize, CancellationToken.None); + Assert.IsTrue(parsed, "GPT should parse successfully."); + + // Get partition table data + using var ptStream = await gpt.GetPartitionTableDataAsync(CancellationToken.None); + Assert.IsNotNull(ptStream, "Partition table data stream should not be null."); + Assert.Greater(ptStream.Length, 0, "Partition table data should have content."); + + // Read the data and verify it contains the GPT signature + var ptBytes = new byte[ptStream.Length]; + var bytesRead = await ptStream.ReadAsync(ptBytes.AsMemory(), CancellationToken.None); + Assert.Greater(bytesRead, sectorSize, "Should have read more than one sector of partition table data."); + + // Verify GPT signature "EFI PART" at sector 1 (offset 512) + var expectedSignature = "EFI PART"u8.ToArray(); + for (int i = 0; i < 8; i++) + Assert.AreEqual(expectedSignature[i], ptBytes[sectorSize + i], $"GPT signature byte {i} should match."); + + gpt.Dispose(); + } + + [Test] + public async Task Test_UnknownFilesystem_GetFilesystemMetadataAsync_ReturnsCorrectMetadata() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + var blockSize = 1024 * 1024; + using var fs = new UnknownFilesystem(partition, blockSize: blockSize); + + var metadata = await fs.GetFilesystemMetadataAsync(CancellationToken.None); + Assert.IsNotNull(metadata, "Metadata should not be null."); + Assert.IsInstanceOf(metadata, "Metadata should be UnkownFilesystemMetadata."); + + var fsMetadata = (UnkownFilesystemMetadata)metadata!; + Assert.AreEqual(blockSize, fsMetadata.BlockSize, "BlockSize should match the configured value."); + } + + [Test] + public async Task Test_UnknownFilesystem_GetFileLengthAsync_ReturnsCorrectSize() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + // Get first file + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + var length = await fs.GetFileLengthAsync(firstFile!, CancellationToken.None); + Assert.AreEqual(firstFile!.Size, length, "GetFileLengthAsync should return the file's size."); + Assert.AreEqual(1024 * 1024, length, "First file should be 1MB (full block)."); + } + + [Test] + public async Task Test_UnknownFilesystemStream_SetLength_ThrowsNotSupported() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + using var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None); + Assert.Throws(() => writeStream.SetLength(100), + "SetLength should throw NotSupportedException on UnknownFilesystemStream."); + } + + [Test] + public async Task Test_UnknownFilesystemStream_WriteOnReadOnly_ThrowsNotSupported() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None); + Assert.IsFalse(readStream.CanWrite, "Read stream should not be writable."); + Assert.Throws(() => readStream.Write(new byte[10], 0, 10), + "Write on read-only stream should throw NotSupportedException."); + } + + [Test] + public async Task Test_UnknownFilesystemStream_ReadOnWriteOnly_ThrowsNotSupported() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + using var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None); + Assert.IsFalse(writeStream.CanRead, "Write stream should not be readable."); + Assert.Throws(() => { var _ = writeStream.Read(new byte[10], 0, 10); }, + "Read on write-only stream should throw NotSupportedException."); + } + + [Test] + public async Task Test_UnknownFilesystemStream_DisposeAsync_FlushesDirtyData() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + var blockSize = sectorSize * 2; + using var fs = new UnknownFilesystem(partition, blockSize: blockSize); + + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + // Write data using DisposeAsync (await using) + var writeData = new byte[blockSize]; + new Random(99).NextBytes(writeData); + + await using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) + { + await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); + } + + // Read back and verify data was flushed via DisposeAsync + using (var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None)) + { + var readData = new byte[blockSize]; + var bytesRead = await readStream.ReadAsync(readData.AsMemory(), CancellationToken.None); + Assert.AreEqual(blockSize, bytesRead, "Should have read the full block."); + Assert.AreEqual(writeData, readData, "Data should match after DisposeAsync flush."); + } + } + + [Test] + public void Test_UnknownFilesystem_DoubleDispose_DoesNotThrow() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + fs.Dispose(); + + // Second dispose should not throw + Assert.DoesNotThrow(() => fs.Dispose(), "Double dispose should not throw."); + } + + [Test] + public void Test_UnknownFilesystem_Type_ReturnsUnknown() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + Assert.AreEqual(FileSystemType.Unknown, fs.Type, "Type should be Unknown."); + Assert.AreEqual(partition, fs.Partition, "Partition should match the one passed to constructor."); + } + + [Test] + public async Task Test_UnknownFilesystemStream_PositionSetter_ValidatesBounds() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + var blockSize = 4096; + using var fs = new UnknownFilesystem(partition, blockSize: blockSize); + + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None); + + // Valid position + readStream.Position = 100; + Assert.AreEqual(100, readStream.Position, "Position should be 100."); + + // Position at end (equal to size) should be valid + readStream.Position = blockSize; + Assert.AreEqual(blockSize, readStream.Position, "Position at end should be valid."); + + // Negative position should throw + Assert.Throws(() => readStream.Position = -1, + "Negative position should throw ArgumentOutOfRangeException."); + + // Position beyond size should throw + Assert.Throws(() => readStream.Position = blockSize + 1, + "Position beyond size should throw ArgumentOutOfRangeException."); + } + + [Test] + public async Task Test_UnknownFilesystemStream_Flush_DoesNotThrow() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + using var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None); + Assert.DoesNotThrow(() => writeStream.Flush(), "Flush should be a no-op and not throw."); + } + + [Test] + public void Test_PartitionWriteStream_Flush_DoesNotThrow() + { + var sectorSize = _writableRawDisk.SectorSize; + using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, sectorSize); + + Assert.DoesNotThrow(() => stream.Flush(), "Flush should be a no-op and not throw."); + } + + [Test] + public async Task Test_UnknownFilesystemStream_WriteBeyondSize_ThrowsArgumentException() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + var blockSize = 4096; + using var fs = new UnknownFilesystem(partition, blockSize: blockSize); + + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + using var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None); + + // Try to write more data than the stream size + var largeData = new byte[blockSize + 100]; + Assert.Throws(() => writeStream.Write(largeData, 0, largeData.Length), + "Writing beyond stream size should throw ArgumentException."); + } + + [Test] + public async Task Test_UnknownFilesystemStream_SyncRead_ReturnsCorrectData() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + var blockSize = 4096; + using var fs = new UnknownFilesystem(partition, blockSize: blockSize); + + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + // Write test data + var writeData = new byte[blockSize]; + for (int i = 0; i < blockSize; i++) + writeData[i] = (byte)(i & 0xFF); + + using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) + await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); + + // Read using synchronous Read method + using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None); + var readBuffer = new byte[256]; + var bytesRead = readStream.Read(readBuffer, 0, 256); + + Assert.AreEqual(256, bytesRead, "Sync Read should return 256 bytes."); + for (int i = 0; i < 256; i++) + Assert.AreEqual((byte)(i & 0xFF), readBuffer[i], $"Byte at position {i} should match."); + } + + [Test] + public async Task Test_UnknownFilesystemStream_SyncWrite_DataMatches() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + var blockSize = 4096; + using var fs = new UnknownFilesystem(partition, blockSize: blockSize); + + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + // Write using synchronous Write method + var writeData = new byte[100]; + new Random(42).NextBytes(writeData); + + using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) + writeStream.Write(writeData, 0, writeData.Length); + + // Read back and verify + using var readStream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None); + var readBuffer = new byte[100]; + var bytesRead = readStream.Read(readBuffer, 0, 100); + + Assert.AreEqual(100, bytesRead, "Should read 100 bytes."); + Assert.AreEqual(writeData, readBuffer, "Sync-written data should match on read-back."); + } + + [Test] + public void Test_PooledMemoryStream_Flush_DoesNotThrow() + { + byte[] buffer; + using (var pooledBuffer = new PooledBuffer(100)) + buffer = pooledBuffer.Array; + + using var stream = new PooledMemoryStream(buffer, 100); + Assert.DoesNotThrow(() => stream.Flush(), "Flush should be a no-op and not throw."); + } + + [Test] + public void Test_PooledBuffer_SpanAndMemory_ReturnCorrectSize() + { + using var buffer = new PooledBuffer(512); + + Assert.AreEqual(512, buffer.Span.Length, "Span length should match requested size."); + Assert.AreEqual(512, buffer.Memory.Length, "Memory length should match requested size."); + + // Write via Span, read via Memory + buffer.Span[0] = 0xAA; + buffer.Span[511] = 0xBB; + + Assert.AreEqual(0xAA, buffer.Memory.Span[0], "Memory should reflect Span writes."); + Assert.AreEqual(0xBB, buffer.Memory.Span[511], "Memory should reflect Span writes."); + } + + /// + /// Tests that ReconstructedPartitionTable stores the correct table type and raw disk reference. + /// + [Test] + public void Test_ReconstructedPartitionTable_Constructor_SetsProperties() + { + var metadata = new GeometryMetadata + { + Disk = new DiskGeometry { SectorSize = 512, Size = 50 * MiB }, + PartitionTable = new PartitionTableGeometry { Type = PartitionTableType.GPT } + }; + + using var table = new ReconstructedPartitionTable(_writableRawDisk, metadata, PartitionTableType.GPT); + + Assert.AreEqual(PartitionTableType.GPT, table.TableType, "TableType should be GPT."); + Assert.AreEqual(_writableRawDisk, table.RawDisk, "RawDisk should match the provided disk."); + } + + /// + /// Tests that ReconstructedPartitionTable.EnumeratePartitions throws NotSupportedException. + /// + [Test] + public void Test_ReconstructedPartitionTable_EnumeratePartitions_ThrowsNotSupported() + { + var metadata = new GeometryMetadata(); + using var table = new ReconstructedPartitionTable(_writableRawDisk, metadata, PartitionTableType.GPT); + + Assert.Throws(() => + { + // Force enumeration to trigger the throw + var enumerator = table.EnumeratePartitions(CancellationToken.None).GetAsyncEnumerator(); + enumerator.MoveNextAsync().AsTask().GetAwaiter().GetResult(); + }, "EnumeratePartitions should throw NotSupportedException."); + } + + /// + /// Tests that ReconstructedPartitionTable.GetPartitionAsync throws NotSupportedException. + /// + [Test] + public void Test_ReconstructedPartitionTable_GetPartitionAsync_ThrowsNotSupported() + { + var metadata = new GeometryMetadata(); + using var table = new ReconstructedPartitionTable(_writableRawDisk, metadata, PartitionTableType.GPT); + + Assert.ThrowsAsync(async () => + { + await table.GetPartitionAsync(1, CancellationToken.None); + }, "GetPartitionAsync should throw NotSupportedException."); + } + + /// + /// Tests that ReconstructedPartitionTable.GetProtectiveMbrAsync throws NotSupportedException for GPT. + /// + [Test] + public void Test_ReconstructedPartitionTable_GetProtectiveMbrAsync_GPT_ThrowsNotSupported() + { + var metadata = new GeometryMetadata(); + using var table = new ReconstructedPartitionTable(_writableRawDisk, metadata, PartitionTableType.GPT); + + Assert.ThrowsAsync(async () => + { + await table.GetProtectiveMbrAsync(CancellationToken.None); + }, "GetProtectiveMbrAsync should throw NotSupportedException for GPT."); + } + + /// + /// Tests that ReconstructedPartitionTable.GetProtectiveMbrAsync throws NotSupportedException for MBR. + /// + [Test] + public void Test_ReconstructedPartitionTable_GetProtectiveMbrAsync_MBR_ThrowsNotSupported() + { + var metadata = new GeometryMetadata(); + using var table = new ReconstructedPartitionTable(_writableRawDisk, metadata, PartitionTableType.MBR); + + var ex = Assert.ThrowsAsync(async () => + { + await table.GetProtectiveMbrAsync(CancellationToken.None); + }, "GetProtectiveMbrAsync should throw NotSupportedException for MBR."); + + StringAssert.Contains("MBR", ex!.Message, "Exception message should mention MBR."); + } + + /// + /// Tests that ReconstructedPartitionTable.GetPartitionTableDataAsync throws NotSupportedException. + /// + [Test] + public void Test_ReconstructedPartitionTable_GetPartitionTableDataAsync_ThrowsNotSupported() + { + var metadata = new GeometryMetadata(); + using var table = new ReconstructedPartitionTable(_writableRawDisk, metadata, PartitionTableType.GPT); + + Assert.ThrowsAsync(async () => + { + await table.GetPartitionTableDataAsync(CancellationToken.None); + }, "GetPartitionTableDataAsync should throw NotSupportedException."); + } + + /// + /// Tests that ReconstructedPartitionTable.Dispose can be called multiple times without error. + /// + [Test] + public void Test_ReconstructedPartitionTable_DoubleDispose_DoesNotThrow() + { + var metadata = new GeometryMetadata(); + var table = new ReconstructedPartitionTable(_writableRawDisk, metadata, PartitionTableType.GPT); + table.Dispose(); + Assert.DoesNotThrow(() => table.Dispose(), "Double dispose should not throw."); + } + + /// + /// Tests that BasePartition.OpenReadAsync returns a readable stream. + /// + [Test] + public async Task Test_BasePartition_OpenReadAsync_ReturnsReadableStream() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionOffset = 10L * sectorSize; + var partitionSize = 4L * sectorSize; + + // Write known data to the partition area + var writeData = new byte[sectorSize]; + for (int i = 0; i < sectorSize; i++) + writeData[i] = (byte)(i & 0xFF); + await _writableRawDisk.WriteBytesAsync(partitionOffset, writeData, CancellationToken.None); + + var partition = new BasePartition + { + PartitionNumber = 1, + Type = PartitionType.Primary, + PartitionTable = new TestPartitionTable { RawDisk = _writableRawDisk }, + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var stream = await partition.OpenReadAsync(CancellationToken.None); + Assert.IsNotNull(stream, "OpenReadAsync should return a stream."); + Assert.IsTrue(stream.CanRead, "Stream should be readable."); + + var readBuffer = new byte[sectorSize]; + var bytesRead = await stream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + Assert.AreEqual(sectorSize, bytesRead, "Should read one sector."); + Assert.AreEqual(writeData, readBuffer, "Data should match what was written."); + + partition.Dispose(); + } + + /// + /// Tests that BasePartition.OpenWriteAsync returns a writable PartitionWriteStream. + /// + [Test] + public async Task Test_BasePartition_OpenWriteAsync_ReturnsWritableStream() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionOffset = 10L * sectorSize; + var partitionSize = 4L * sectorSize; + + var partition = new BasePartition + { + PartitionNumber = 1, + Type = PartitionType.Primary, + PartitionTable = new TestPartitionTable { RawDisk = _writableRawDisk }, + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var stream = await partition.OpenWriteAsync(CancellationToken.None); + Assert.IsNotNull(stream, "OpenWriteAsync should return a stream."); + Assert.IsTrue(stream.CanWrite, "Stream should be writable."); + Assert.IsInstanceOf(stream, "Stream should be a PartitionWriteStream."); + + partition.Dispose(); + } + + /// + /// Tests that UnknownPartitionTable.GetProtectiveMbrAsync throws NotSupportedException. + /// + [Test] + public void Test_UnknownPartitionTable_GetProtectiveMbrAsync_ThrowsNotSupported() + { + using var table = new UnknownPartitionTable(null); + + Assert.ThrowsAsync(async () => + { + await table.GetProtectiveMbrAsync(CancellationToken.None); + }, "GetProtectiveMbrAsync should throw NotSupportedException."); + } + + /// + /// Tests that UnknownPartitionTable.GetPartitionTableDataAsync returns empty data. + /// + [Test] + public async Task Test_UnknownPartitionTable_GetPartitionTableDataAsync_ReturnsEmptyStream() + { + using var table = new UnknownPartitionTable(null); + + using var stream = await table.GetPartitionTableDataAsync(CancellationToken.None); + Assert.IsNotNull(stream, "Stream should not be null."); + Assert.AreEqual(0, stream.Length, "Stream should be empty."); + } + + /// + /// Tests that UnknownPartitionTable.Dispose can be called multiple times. + /// + [Test] + public void Test_UnknownPartitionTable_DoubleDispose_DoesNotThrow() + { + var table = new UnknownPartitionTable(null); + table.Dispose(); + Assert.DoesNotThrow(() => table.Dispose(), "Double dispose should not throw."); + } + + /// + /// Tests that UnknownPartitionTable.GetPartitionAsync returns null for invalid partition numbers. + /// + [Test] + public async Task Test_UnknownPartitionTable_GetPartitionAsync_InvalidNumber_ReturnsNull() + { + using var table = new UnknownPartitionTable(null); + + var result = await table.GetPartitionAsync(2, CancellationToken.None); + Assert.IsNull(result, "Should return null for partition number != 1."); + + var result0 = await table.GetPartitionAsync(0, CancellationToken.None); + Assert.IsNull(result0, "Should return null for partition number 0."); + } + + /// + /// Tests that UnknownPartitionTable.GetPartitionAsync returns null when no raw disk is set. + /// + [Test] + public async Task Test_UnknownPartitionTable_GetPartitionAsync_NullDisk_ReturnsNull() + { + using var table = new UnknownPartitionTable(null); + + var result = await table.GetPartitionAsync(1, CancellationToken.None); + Assert.IsNull(result, "Should return null when raw disk is null."); + } + + /// + /// Tests that UnknownPartitionTable.GetPartitionAsync returns a valid partition when disk is set. + /// + [Test] + public async Task Test_UnknownPartitionTable_GetPartitionAsync_ValidDisk_ReturnsPartition() + { + using var table = new UnknownPartitionTable(_writableRawDisk); + + var result = await table.GetPartitionAsync(1, CancellationToken.None); + Assert.IsNotNull(result, "Should return a partition when disk is available."); + Assert.AreEqual(1, result!.PartitionNumber, "Partition number should be 1."); + Assert.AreEqual(_writableRawDisk.Size, result.Size, "Partition size should equal disk size."); + Assert.AreEqual(0, result.StartOffset, "Partition start offset should be 0."); + + result.Dispose(); + } + + /// + /// Tests that PooledMemoryStream.Position setter validates bounds correctly. + /// + [Test] + public void Test_PooledMemoryStream_PositionSetter_ValidatesBounds() + { + byte[] buffer; + using (var pooledBuffer = new PooledBuffer(100)) + buffer = pooledBuffer.Array; + + using var stream = new PooledMemoryStream(buffer, 100); + + // Valid position + stream.Position = 50; + Assert.AreEqual(50, stream.Position, "Position should be 50."); + + // Position at end + stream.Position = 100; + Assert.AreEqual(100, stream.Position, "Position at end should be valid."); + + // Position at start + stream.Position = 0; + Assert.AreEqual(0, stream.Position, "Position at start should be valid."); + + // Negative position should throw + Assert.Throws(() => stream.Position = -1, + "Negative position should throw."); + + // Position beyond length should throw + Assert.Throws(() => stream.Position = 101, + "Position beyond length should throw."); + } + + /// + /// Tests that PooledMemoryStream.Seek with invalid origin throws ArgumentOutOfRangeException. + /// + [Test] + public void Test_PooledMemoryStream_Seek_InvalidOrigin_ThrowsArgumentOutOfRange() + { + byte[] buffer; + using (var pooledBuffer = new PooledBuffer(100)) + buffer = pooledBuffer.Array; + + using var stream = new PooledMemoryStream(buffer, 100); + + Assert.Throws(() => stream.Seek(0, (SeekOrigin)99), + "Invalid SeekOrigin should throw ArgumentOutOfRangeException."); + } + + /// + /// Tests that PartitionWriteStream.Position setter validates bounds correctly. + /// + [Test] + public void Test_PartitionWriteStream_PositionSetter_ValidatesBounds() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 2 * sectorSize; + + using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize); + + // Valid position + stream.Position = 100; + Assert.AreEqual(100, stream.Position, "Position should be 100."); + + // Position at max size + stream.Position = partitionSize; + Assert.AreEqual(partitionSize, stream.Position, "Position at max size should be valid."); + + // Negative position should throw + Assert.Throws(() => stream.Position = -1, + "Negative position should throw."); + + // Position beyond max size should throw + Assert.Throws(() => stream.Position = partitionSize + 1, + "Position beyond max size should throw."); + } + + /// + /// Tests that PartitionWriteStream.Seek with invalid origin throws ArgumentOutOfRangeException. + /// + [Test] + public void Test_PartitionWriteStream_Seek_InvalidOrigin_ThrowsArgumentOutOfRange() + { + var sectorSize = _writableRawDisk.SectorSize; + using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, sectorSize); + + Assert.Throws(() => stream.Seek(0, (SeekOrigin)99), + "Invalid SeekOrigin should throw ArgumentOutOfRangeException."); + } + + /// + /// Tests that PartitionWriteStream.SetLength adjusts position when it exceeds new length. + /// + [Test] + public void Test_PartitionWriteStream_SetLength_AdjustsPosition() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 4 * sectorSize; + + using var stream = new PartitionWriteStream(_writableRawDisk, 10 * sectorSize, partitionSize); + + // Write some data to move position + stream.Write(new byte[200], 0, 200); + Assert.AreEqual(200, stream.Position, "Position should be 200."); + + // Set length to less than current position + stream.SetLength(100); + Assert.AreEqual(100, stream.Length, "Length should be 100."); + Assert.AreEqual(100, stream.Position, "Position should be adjusted to new length."); + } + + /// + /// Tests that UnknownFilesystem path-based OpenReadStreamAsync works correctly. + /// + [Test] + public async Task Test_UnknownFilesystem_OpenReadStreamAsync_ByPath_ReturnsStream() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + var blockSize = 1024 * 1024; + using var fs = new UnknownFilesystem(partition, blockSize: blockSize); + + // Write data first using IFile-based API + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + var writeData = new byte[100]; + new Random(42).NextBytes(writeData); + using (var writeStream = await fs.OpenWriteStreamAsync(firstFile!, CancellationToken.None)) + await writeStream.WriteAsync(writeData.AsMemory(), CancellationToken.None); + + // Read using path-based API + var path = $"root/part_Unknown_1/fs_Unknown/{((UnknownFilesystemFile)firstFile!).Address:X}"; + using var readStream = await fs.OpenReadStreamAsync(path, CancellationToken.None); + Assert.IsNotNull(readStream, "Path-based OpenReadStreamAsync should return a stream."); + + var readBuffer = new byte[100]; + var bytesRead = await readStream.ReadAsync(readBuffer.AsMemory(), CancellationToken.None); + Assert.AreEqual(100, bytesRead, "Should read 100 bytes."); + Assert.AreEqual(writeData, readBuffer, "Data should match."); + } + + /// + /// Tests that UnknownFilesystem path-based OpenWriteStreamAsync works correctly. + /// + [Test] + public async Task Test_UnknownFilesystem_OpenWriteStreamAsync_ByPath_ReturnsStream() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + // Write using path-based API + var path = $"root/part_Unknown_1/fs_Unknown/0"; + using var writeStream = await fs.OpenWriteStreamAsync(path, CancellationToken.None); + Assert.IsNotNull(writeStream, "Path-based OpenWriteStreamAsync should return a stream."); + Assert.IsTrue(writeStream.CanWrite, "Stream should be writable."); + } + + /// + /// Tests that UnknownFilesystem path-based OpenReadWriteStreamAsync works correctly. + /// + [Test] + public async Task Test_UnknownFilesystem_OpenReadWriteStreamAsync_ByPath_ReturnsStream() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + // Open read-write using path-based API + var path = $"root/part_Unknown_1/fs_Unknown/0"; + using var stream = await fs.OpenReadWriteStreamAsync(path, CancellationToken.None); + Assert.IsNotNull(stream, "Path-based OpenReadWriteStreamAsync should return a stream."); + Assert.IsTrue(stream.CanRead, "Stream should be readable."); + Assert.IsTrue(stream.CanWrite, "Stream should be writable."); + } + + /// + /// Tests that UnknownFilesystem path-based GetFileLengthAsync returns correct size. + /// + [Test] + public async Task Test_UnknownFilesystem_GetFileLengthAsync_ByPath_ReturnsCorrectSize() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + var blockSize = 1024 * 1024; + using var fs = new UnknownFilesystem(partition, blockSize: blockSize); + + // Get file length using path-based API + var path = $"root/part_Unknown_1/fs_Unknown/0"; + var length = await fs.GetFileLengthAsync(path, CancellationToken.None); + Assert.AreEqual(blockSize, length, "File length should equal block size for first block."); + } + + /// + /// Tests that UnknownFilesystem.GetFileLengthAsync throws for non-UnknownFilesystemFile (including directories). + /// + [Test] + public void Test_UnknownFilesystem_GetFileLengthAsync_NonUnknownFile_ThrowsArgumentException() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + // A directory file that is not UnknownFilesystemFile should throw "does not belong" + var dirFile = new MockDirectoryFile(); + + var ex = Assert.ThrowsAsync(async () => + { + await fs.GetFileLengthAsync(dirFile, CancellationToken.None); + }, "GetFileLengthAsync should throw for non-UnknownFilesystemFile."); + + StringAssert.Contains("does not belong", ex!.Message, "Exception message should mention file doesn't belong."); + } + + /// + /// Tests that UnknownFilesystem.GetFileLengthAsync throws for non-UnknownFilesystemFile. + /// + [Test] + public void Test_UnknownFilesystem_GetFileLengthAsync_WrongFileType_ThrowsArgumentException() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + // Create a mock file that is not UnknownFilesystemFile + var wrongFile = new MockNonUnknownFile(); + + var ex = Assert.ThrowsAsync(async () => + { + await fs.GetFileLengthAsync(wrongFile, CancellationToken.None); + }, "GetFileLengthAsync should throw for non-UnknownFilesystemFile."); + + StringAssert.Contains("does not belong", ex!.Message, "Exception message should mention file doesn't belong."); + } + + /// + /// Tests that UnknownFilesystemStream.Seek with invalid origin throws ArgumentException. + /// + [Test] + public async Task Test_UnknownFilesystemStream_Seek_InvalidOrigin_ThrowsArgumentException() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 4096); + + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + using var stream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None); + + Assert.Throws(() => stream.Seek(0, (SeekOrigin)99), + "Invalid SeekOrigin should throw ArgumentException."); + } + + /// + /// Tests that UnknownFilesystemStream.Seek beyond bounds throws ArgumentOutOfRangeException. + /// + [Test] + public async Task Test_UnknownFilesystemStream_Seek_BeyondBounds_ThrowsArgumentOutOfRange() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + var blockSize = 4096; + using var fs = new UnknownFilesystem(partition, blockSize: blockSize); + + IFile? firstFile = null; + await foreach (var file in fs.ListFilesAsync(CancellationToken.None)) + { + firstFile = file; + break; + } + Assert.IsNotNull(firstFile, "Should have at least one file."); + + using var stream = await fs.OpenReadStreamAsync(firstFile!, CancellationToken.None); + + // Seek before beginning + Assert.Throws(() => stream.Seek(-1, SeekOrigin.Begin), + "Seek before beginning should throw."); + + // Seek past end + Assert.Throws(() => stream.Seek(blockSize + 1, SeekOrigin.Begin), + "Seek past end should throw."); + } + + /// + /// Tests that UnknownFilesystem.ListFilesAsync with a non-UnknownFilesystemFile directory throws ArgumentException. + /// + [Test] + public void Test_UnknownFilesystem_ListFilesAsync_WithNonUnknownFile_ThrowsArgumentException() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + // ListFilesAsync with a non-UnknownFilesystemFile should throw + var dirFile = new MockDirectoryFile(); + Assert.ThrowsAsync(async () => + { + await foreach (var file in fs.ListFilesAsync(dirFile, CancellationToken.None)) + { } + }, "ListFilesAsync should throw for non-UnknownFilesystemFile."); + } + + /// + /// Tests that UnknownFilesystem.ListFilesAsync with an UnknownFilesystemFile (non-directory) throws ArgumentException. + /// + [Test] + public void Test_UnknownFilesystem_ListFilesAsync_WithNonDirectory_ThrowsArgumentException() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + // UnknownFilesystemFile.IsDirectory is always false, so this should throw "not a directory" + var nonDirFile = new UnknownFilesystemFile { Address = 0, Size = 1024 * 1024 }; + Assert.ThrowsAsync(async () => + { + await foreach (var file in fs.ListFilesAsync(nonDirFile, CancellationToken.None)) + { } + }, "ListFilesAsync should throw for non-directory UnknownFilesystemFile."); + } + + /// + /// Tests that UnknownFilesystem.ParsePathToAddress throws for invalid path format. + /// + [Test] + public async Task Test_UnknownFilesystem_OpenReadStreamAsync_InvalidPath_ThrowsArgumentException() + { + var sectorSize = _writableRawDisk.SectorSize; + var partitionSize = 5 * MiB; + var partitionOffset = sectorSize * 2048; + + var partition = new TestPartition + { + StartOffset = partitionOffset, + Size = partitionSize, + RawDisk = _writableRawDisk + }; + + using var fs = new UnknownFilesystem(partition, blockSize: 1024 * 1024); + + // Path with too few segments should throw + Assert.ThrowsAsync(async () => + { + await fs.OpenReadStreamAsync("invalid", CancellationToken.None); + }, "Invalid path should throw ArgumentException."); + } + + /// + /// Mock directory file for testing. + /// + private class MockDirectoryFile : IFile + { + public string? Path => "/dir"; + public long? Address => null; + public long Size => 0; + public bool IsDirectory => true; + public DateTime? LastModified => null; + } + + /// + /// Mock non-UnknownFilesystemFile for testing. + /// + private class MockNonUnknownFile : IFile + { + public string? Path => "/file.txt"; + public long? Address => 0; + public long Size => 100; + public bool IsDirectory => false; + public DateTime? LastModified => null; + } + + #endregion } diff --git a/Duplicati/UnitTest/DiskImage/Fat32/Fat32BootSectorTests.cs b/Duplicati/UnitTest/DiskImage/Fat32/Fat32BootSectorTests.cs index 05dad730c..621479ab8 100644 --- a/Duplicati/UnitTest/DiskImage/Fat32/Fat32BootSectorTests.cs +++ b/Duplicati/UnitTest/DiskImage/Fat32/Fat32BootSectorTests.cs @@ -3,233 +3,232 @@ using System; using System.Buffers.Binary; using System.Text; -using Duplicati.Proprietary.DiskImage.Filesystem; +using Duplicati.Proprietary.DiskImage.Filesystem.Fat32; using NUnit.Framework; using NUnit.Framework.Legacy; using Assert = NUnit.Framework.Legacy.ClassicAssert; #nullable enable -namespace Duplicati.UnitTest.DiskImage +namespace Duplicati.UnitTest.DiskImage.Fat32; + +/// +/// Unit tests for the parser. +/// +[TestFixture] +[Category("DiskImageUnit")] +public class Fat32BootSectorTests { /// - /// Unit tests for the parser. + /// Creates a valid FAT32 boot sector with known values for testing. /// - [TestFixture] - [Category("DiskImageUnit")] - public class Fat32BootSectorTests + private static byte[] CreateValidBootSector() { - /// - /// Creates a valid FAT32 boot sector with known values for testing. - /// - private static byte[] CreateValidBootSector() - { - var bootSector = new byte[512]; + var bootSector = new byte[512]; - // Bytes per sector at offset 0x0B (512 = 0x0200) - BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0B, 2), 512); + // 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; + // Sectors per cluster at offset 0x0D (8 sectors) + bootSector[0x0D] = 8; - // Reserved sector count at offset 0x0E (32 sectors) - BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0E, 2), 32); + // Reserved sector count at offset 0x0E (32 sectors) + BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0E, 2), 32); - // Number of FATs at offset 0x10 (2 FATs) - bootSector[0x10] = 2; + // Number of FATs at offset 0x10 (2 FATs) + bootSector[0x10] = 2; - // Total sectors 32 at offset 0x20 (1,000,000 sectors = ~512MB) - BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x20, 4), 1_000_000); + // Total sectors 32 at offset 0x20 (1,000,000 sectors = ~512MB) + BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x20, 4), 1_000_000); - // FAT size 32 at offset 0x24 (244 sectors per FAT) - BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x24, 4), 244); + // FAT size 32 at offset 0x24 (244 sectors per FAT) + BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x24, 4), 244); - // Root cluster at offset 0x2C (cluster 2) - BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x2C, 4), 2); + // Root cluster at offset 0x2C (cluster 2) + BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x2C, 4), 2); - // FSInfo sector at offset 0x30 (sector 1) - BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x30, 2), 1); + // FSInfo sector at offset 0x30 (sector 1) + BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x30, 2), 1); - // Filesystem type string at offset 0x52 (8 bytes: "FAT32 ") - var fsType = Encoding.ASCII.GetBytes("FAT32 "); - fsType.CopyTo(bootSector, 0x52); + // Filesystem type string at offset 0x52 (8 bytes: "FAT32 ") + var fsType = Encoding.ASCII.GetBytes("FAT32 "); + fsType.CopyTo(bootSector, 0x52); - // Boot sector signature at offset 510: bytes 0x55, 0xAA on disk (0xAA55 as little-endian uint16) - BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(510, 2), 0xAA55); + // 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; - } + return bootSector; + } - [Test] - public void Test_Fat32BootSector_ParseValidBootSector() + [Test] + public void Test_Fat32BootSector_ParseValidBootSector() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + Assert.AreEqual(512, bootSector.BytesPerSector); + Assert.AreEqual(8, bootSector.SectorsPerCluster); + Assert.AreEqual(32, bootSector.ReservedSectorCount); + Assert.AreEqual(2, bootSector.NumberOfFats); + Assert.AreEqual(1_000_000u, bootSector.TotalSectors32); + Assert.AreEqual(244u, bootSector.FatSize32); + Assert.AreEqual(2u, bootSector.RootCluster); + Assert.AreEqual(1, bootSector.FsInfoSector); + } + + [Test] + public void Test_Fat32BootSector_InvalidSignature_Throws() + { + var bootSectorData = CreateValidBootSector(); + // Change the signature to an invalid value + BinaryPrimitives.WriteUInt16LittleEndian(bootSectorData.AsSpan(510, 2), 0x1234); + + var ex = Assert.Throws(() => new Fat32BootSector(bootSectorData)); + StringAssert.Contains("signature", ex!.Message); + } + + [Test] + public void Test_Fat32BootSector_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 Fat32BootSector(bootSectorData)); + StringAssert.Contains("BytesPerSector", ex!.Message); + } + + [Test] + public void Test_Fat32BootSector_InvalidSectorsPerCluster_Throws() + { + var bootSectorData = CreateValidBootSector(); + // Set sectors per cluster to 3 (not a power of 2) + bootSectorData[0x0D] = 3; + + var ex = Assert.Throws(() => new Fat32BootSector(bootSectorData)); + StringAssert.Contains("SectorsPerCluster", ex!.Message); + } + + [Test] + public void Test_Fat32BootSector_DerivedProperties_Correct() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // ClusterSize = BytesPerSector * SectorsPerCluster = 512 * 8 = 4096 + Assert.AreEqual(4096, bootSector.ClusterSize); + + // FatStartOffset = ReservedSectorCount * BytesPerSector = 32 * 512 = 16384 + Assert.AreEqual(16384, bootSector.FatStartOffset); + + // DataStartOffset = (ReservedSectorCount + NumberOfFats * FatSize32) * BytesPerSector + // = (32 + 2 * 244) * 512 = (32 + 488) * 512 = 520 * 512 = 266240 + Assert.AreEqual(266240, bootSector.DataStartOffset); + + // TotalDataClusters = (TotalSectors32 - (ReservedSectorCount + NumberOfFats * FatSize32)) / SectorsPerCluster + // = (1,000,000 - (32 + 2 * 244)) / 8 = (1,000,000 - 520) / 8 = 999,480 / 8 = 124935 + Assert.AreEqual(124935u, bootSector.TotalDataClusters); + } + + [Test] + public void Test_Fat32BootSector_ClusterToByteOffset_Correct() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Cluster 2 should be at DataStartOffset (first data cluster) + Assert.AreEqual(bootSector.DataStartOffset, bootSector.ClusterToByteOffset(2)); + + // Cluster 3 should be at DataStartOffset + ClusterSize + Assert.AreEqual(bootSector.DataStartOffset + bootSector.ClusterSize, bootSector.ClusterToByteOffset(3)); + + // Cluster 10 should be at DataStartOffset + 8 * ClusterSize + Assert.AreEqual(bootSector.DataStartOffset + 8 * bootSector.ClusterSize, bootSector.ClusterToByteOffset(10)); + } + + [Test] + public void Test_Fat32BootSector_TooSmallBuffer_Throws() + { + var smallBuffer = new byte[256]; + + var ex = Assert.Throws(() => new Fat32BootSector(smallBuffer)); + StringAssert.Contains("512", ex!.Message); + } + + [Test] + public void Test_Fat32BootSector_ClusterToByteOffset_InvalidCluster_Throws() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + var ex = Assert.Throws(() => bootSector.ClusterToByteOffset(0)); + StringAssert.Contains("Cluster numbers start at 2", ex!.Message); + + ex = Assert.Throws(() => bootSector.ClusterToByteOffset(1)); + StringAssert.Contains("Cluster numbers start at 2", ex!.Message); + } + + [Test] + public void Test_Fat32BootSector_NumberOfFats_Zero_Throws() + { + var bootSectorData = CreateValidBootSector(); + bootSectorData[0x10] = 0; // Set NumberOfFats to 0 + + var ex = Assert.Throws(() => new Fat32BootSector(bootSectorData)); + StringAssert.Contains("NumberOfFats", ex!.Message); + } + + [Test] + public void Test_Fat32BootSector_InvalidFilesystemType_Throws() + { + var bootSectorData = CreateValidBootSector(); + // Change filesystem type to something other than FAT32 + var fsType = Encoding.ASCII.GetBytes("FAT16 "); + fsType.CopyTo(bootSectorData, 0x52); + + var ex = Assert.Throws(() => new Fat32BootSector(bootSectorData)); + StringAssert.Contains("FAT32", ex!.Message); + } + + [Test] + public void Test_Fat32BootSector_ValidBytesPerSectorValues() + { + ushort[] validValues = { 512, 1024, 2048, 4096 }; + + foreach (var bytesPerSector in validValues) { var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - Assert.AreEqual(512, bootSector.BytesPerSector); - Assert.AreEqual(8, bootSector.SectorsPerCluster); - Assert.AreEqual(32, bootSector.ReservedSectorCount); - Assert.AreEqual(2, bootSector.NumberOfFats); - Assert.AreEqual(1_000_000u, bootSector.TotalSectors32); - Assert.AreEqual(244u, bootSector.FatSize32); - Assert.AreEqual(2u, bootSector.RootCluster); - Assert.AreEqual(1, bootSector.FsInfoSector); - } - - [Test] - public void Test_Fat32BootSector_InvalidSignature_Throws() - { - var bootSectorData = CreateValidBootSector(); - // Change the signature to an invalid value - BinaryPrimitives.WriteUInt16LittleEndian(bootSectorData.AsSpan(510, 2), 0x1234); - - var ex = Assert.Throws(() => new Fat32BootSector(bootSectorData)); - StringAssert.Contains("signature", ex!.Message); - } - - [Test] - public void Test_Fat32BootSector_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 Fat32BootSector(bootSectorData)); - StringAssert.Contains("BytesPerSector", ex!.Message); - } - - [Test] - public void Test_Fat32BootSector_InvalidSectorsPerCluster_Throws() - { - var bootSectorData = CreateValidBootSector(); - // Set sectors per cluster to 3 (not a power of 2) - bootSectorData[0x0D] = 3; - - var ex = Assert.Throws(() => new Fat32BootSector(bootSectorData)); - StringAssert.Contains("SectorsPerCluster", ex!.Message); - } - - [Test] - public void Test_Fat32BootSector_DerivedProperties_Correct() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - // ClusterSize = BytesPerSector * SectorsPerCluster = 512 * 8 = 4096 - Assert.AreEqual(4096, bootSector.ClusterSize); - - // FatStartOffset = ReservedSectorCount * BytesPerSector = 32 * 512 = 16384 - Assert.AreEqual(16384, bootSector.FatStartOffset); - - // DataStartOffset = (ReservedSectorCount + NumberOfFats * FatSize32) * BytesPerSector - // = (32 + 2 * 244) * 512 = (32 + 488) * 512 = 520 * 512 = 266240 - Assert.AreEqual(266240, bootSector.DataStartOffset); - - // TotalDataClusters = (TotalSectors32 - (ReservedSectorCount + NumberOfFats * FatSize32)) / SectorsPerCluster - // = (1,000,000 - (32 + 2 * 244)) / 8 = (1,000,000 - 520) / 8 = 999,480 / 8 = 124935 - Assert.AreEqual(124935u, bootSector.TotalDataClusters); - } - - [Test] - public void Test_Fat32BootSector_ClusterToByteOffset_Correct() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - // Cluster 2 should be at DataStartOffset (first data cluster) - Assert.AreEqual(bootSector.DataStartOffset, bootSector.ClusterToByteOffset(2)); - - // Cluster 3 should be at DataStartOffset + ClusterSize - Assert.AreEqual(bootSector.DataStartOffset + bootSector.ClusterSize, bootSector.ClusterToByteOffset(3)); - - // Cluster 10 should be at DataStartOffset + 8 * ClusterSize - Assert.AreEqual(bootSector.DataStartOffset + 8 * bootSector.ClusterSize, bootSector.ClusterToByteOffset(10)); - } - - [Test] - public void Test_Fat32BootSector_TooSmallBuffer_Throws() - { - var smallBuffer = new byte[256]; - - var ex = Assert.Throws(() => new Fat32BootSector(smallBuffer)); - StringAssert.Contains("512", ex!.Message); - } - - [Test] - public void Test_Fat32BootSector_ClusterToByteOffset_InvalidCluster_Throws() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - var ex = Assert.Throws(() => bootSector.ClusterToByteOffset(0)); - StringAssert.Contains("Cluster numbers start at 2", ex!.Message); - - ex = Assert.Throws(() => bootSector.ClusterToByteOffset(1)); - StringAssert.Contains("Cluster numbers start at 2", ex!.Message); - } - - [Test] - public void Test_Fat32BootSector_NumberOfFats_Zero_Throws() - { - var bootSectorData = CreateValidBootSector(); - bootSectorData[0x10] = 0; // Set NumberOfFats to 0 - - var ex = Assert.Throws(() => new Fat32BootSector(bootSectorData)); - StringAssert.Contains("NumberOfFats", ex!.Message); - } - - [Test] - public void Test_Fat32BootSector_InvalidFilesystemType_Throws() - { - var bootSectorData = CreateValidBootSector(); - // Change filesystem type to something other than FAT32 - var fsType = Encoding.ASCII.GetBytes("FAT16 "); - fsType.CopyTo(bootSectorData, 0x52); - - var ex = Assert.Throws(() => new Fat32BootSector(bootSectorData)); - StringAssert.Contains("FAT32", ex!.Message); - } - - [Test] - public void Test_Fat32BootSector_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 Fat32BootSector(bootSectorData); - Assert.AreEqual(bytesPerSector, bootSector.BytesPerSector); - } - } - - [Test] - public void Test_Fat32BootSector_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 Fat32BootSector(bootSectorData); - Assert.AreEqual(sectorsPerCluster, bootSector.SectorsPerCluster); - } - } - - [Test] - public void Test_Fat32BootSector_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); + BinaryPrimitives.WriteUInt16LittleEndian(bootSectorData.AsSpan(0x0B, 2), bytesPerSector); var bootSector = new Fat32BootSector(bootSectorData); - Assert.AreEqual(512, bootSector.BytesPerSector); + Assert.AreEqual(bytesPerSector, bootSector.BytesPerSector); } } + + [Test] + public void Test_Fat32BootSector_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 Fat32BootSector(bootSectorData); + Assert.AreEqual(sectorsPerCluster, bootSector.SectorsPerCluster); + } + } + + [Test] + public void Test_Fat32BootSector_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 Fat32BootSector(bootSectorData); + Assert.AreEqual(512, bootSector.BytesPerSector); + } } diff --git a/Duplicati/UnitTest/DiskImage/Fat32/Fat32DirectoryWalkerTests.cs b/Duplicati/UnitTest/DiskImage/Fat32/Fat32DirectoryWalkerTests.cs index 2e859138c..cbfa536d3 100644 --- a/Duplicati/UnitTest/DiskImage/Fat32/Fat32DirectoryWalkerTests.cs +++ b/Duplicati/UnitTest/DiskImage/Fat32/Fat32DirectoryWalkerTests.cs @@ -5,735 +5,735 @@ using System.Buffers.Binary; using System.Collections.Generic; using System.Linq; using Duplicati.Proprietary.DiskImage.Filesystem; +using Duplicati.Proprietary.DiskImage.Filesystem.Fat32; using NUnit.Framework; using NUnit.Framework.Legacy; using Assert = NUnit.Framework.Legacy.ClassicAssert; #nullable enable -namespace Duplicati.UnitTest.DiskImage +namespace Duplicati.UnitTest.DiskImage.Fat32; + +/// +/// Unit tests for the directory entry walker. +/// +[TestFixture] +[Category("DiskImageUnit")] +public class Fat32DirectoryWalkerTests { /// - /// Unit tests for the directory entry walker. + /// Creates a valid FAT32 boot sector with known values for testing. /// - [TestFixture] - [Category("DiskImageUnit")] - public class Fat32DirectoryWalkerTests + private static byte[] CreateValidBootSector() { - /// - /// Creates a valid FAT32 boot sector with known values for testing. - /// - private static byte[] CreateValidBootSector() + var bootSector = new byte[512]; + + // Bytes per sector at offset 0x0B (512 = 0x0200) + BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0B, 2), 512); + + // Sectors per cluster at offset 0x0D (8 sectors = 4KB clusters) + bootSector[0x0D] = 8; + + // Reserved sector count at offset 0x0E (32 sectors) + BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0E, 2), 32); + + // Number of FATs at offset 0x10 (2 FATs) + bootSector[0x10] = 2; + + // Total sectors 32 at offset 0x20 (10,000 sectors = ~5MB) + BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x20, 4), 10_000); + + // FAT size 32 at offset 0x24 (10 sectors per FAT) + BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x24, 4), 10); + + // Root cluster at offset 0x2C (cluster 2) + BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x2C, 4), 2); + + // FSInfo sector at offset 0x30 (sector 1) + BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x30, 2), 1); + + // Filesystem type string at offset 0x52 (8 bytes: "FAT32 ") + var fsType = System.Text.Encoding.ASCII.GetBytes("FAT32 "); + fsType.CopyTo(bootSector, 0x52); + + // 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; + } + + /// + /// Creates FAT data with specified entries. + /// + private static byte[] CreateFatData(params uint[] entries) + { + var fatData = new byte[entries.Length * 4]; + for (int i = 0; i < entries.Length; i++) { - var bootSector = new byte[512]; - - // Bytes per sector at offset 0x0B (512 = 0x0200) - BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0B, 2), 512); - - // Sectors per cluster at offset 0x0D (8 sectors = 4KB clusters) - bootSector[0x0D] = 8; - - // Reserved sector count at offset 0x0E (32 sectors) - BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0E, 2), 32); - - // Number of FATs at offset 0x10 (2 FATs) - bootSector[0x10] = 2; - - // Total sectors 32 at offset 0x20 (10,000 sectors = ~5MB) - BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x20, 4), 10_000); - - // FAT size 32 at offset 0x24 (10 sectors per FAT) - BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x24, 4), 10); - - // Root cluster at offset 0x2C (cluster 2) - BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x2C, 4), 2); - - // FSInfo sector at offset 0x30 (sector 1) - BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x30, 2), 1); - - // Filesystem type string at offset 0x52 (8 bytes: "FAT32 ") - var fsType = System.Text.Encoding.ASCII.GetBytes("FAT32 "); - fsType.CopyTo(bootSector, 0x52); - - // 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; + BinaryPrimitives.WriteUInt32LittleEndian(fatData.AsSpan(i * 4, 4), entries[i]); } + return fatData; + } - /// - /// Creates FAT data with specified entries. - /// - private static byte[] CreateFatData(params uint[] entries) + /// + /// Creates a directory entry (32 bytes) with the specified parameters. + /// + private static byte[] CreateDirectoryEntry( + string filename, + byte attributes, + ushort writeDate, + ushort writeTime, + ushort clusterHigh, + ushort clusterLow, + uint fileSize = 0) + { + var entry = new byte[32]; + + // Filename (11 bytes: 8 for name, 3 for extension) + var nameBytes = System.Text.Encoding.ASCII.GetBytes(filename.PadRight(11).Substring(0, 11)); + nameBytes.CopyTo(entry, 0); + + // Attributes at offset 0x0B + entry[0x0B] = attributes; + + // Write time at offset 0x16 + BinaryPrimitives.WriteUInt16LittleEndian(entry.AsSpan(0x16, 2), writeTime); + + // Write date at offset 0x18 + BinaryPrimitives.WriteUInt16LittleEndian(entry.AsSpan(0x18, 2), writeDate); + + // Cluster high at offset 0x14 + BinaryPrimitives.WriteUInt16LittleEndian(entry.AsSpan(0x14, 2), clusterHigh); + + // Cluster low at offset 0x1A + BinaryPrimitives.WriteUInt16LittleEndian(entry.AsSpan(0x1A, 2), clusterLow); + + // File size at offset 0x1C + BinaryPrimitives.WriteUInt32LittleEndian(entry.AsSpan(0x1C, 4), fileSize); + + return entry; + } + + [Test] + public void Test_Fat32DirectoryWalker_ParseTimestamp_Correct() + { + // Test known FAT32 date/time values + // Date: 2023-06-15 = (2023-1980) << 9 | 6 << 5 | 15 = 43 << 9 | 192 | 15 = 22080 + 192 + 15 = 22287 + // Time: 14:30:20 = 14 << 11 | 30 << 5 | 10 = 28672 + 960 + 10 = 29642 + var date = (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15); + var time = (ushort)((14 << 11) | (30 << 5) | (20 / 2)); + + var result = Fat32DirectoryWalker.ParseDateTime(date, time); + + Assert.AreEqual(2023, result.Year); + Assert.AreEqual(6, result.Month); + Assert.AreEqual(15, result.Day); + Assert.AreEqual(14, result.Hour); + Assert.AreEqual(30, result.Minute); + Assert.AreEqual(20, result.Second); + Assert.AreEqual(DateTimeKind.Utc, result.Kind); + } + + [Test] + public void Test_Fat32DirectoryWalker_ParseTimestamp_Zero_ReturnsEpoch() + { + var result = Fat32DirectoryWalker.ParseDateTime(0, 0); + Assert.AreEqual(DateTime.UnixEpoch, result); + } + + [Test] + public void Test_Fat32DirectoryWalker_ParseTimestamp_InvalidDate_ReturnsEpoch() + { + // Invalid month (13) + var invalidDate = (ushort)((43 << 9) | (13 << 5) | 15); + var time = (ushort)((14 << 11) | (30 << 5) | 10); + + var result = Fat32DirectoryWalker.ParseDateTime(invalidDate, time); + Assert.AreEqual(DateTime.UnixEpoch, result); + } + + [Test] + public void Test_Fat32DirectoryWalker_ParseTimestamp_InvalidTime_ReturnsEpoch() + { + // Valid date but invalid hour (25) + var date = (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15); + var invalidTime = (ushort)((25 << 11) | (30 << 5) | 10); + + var result = Fat32DirectoryWalker.ParseDateTime(date, invalidTime); + Assert.AreEqual(DateTime.UnixEpoch, result); + } + + [Test] + public void Test_Fat32DirectoryWalker_SkipDeletedEntries() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create a FAT where cluster 2 (root) points to EOC, with enough entries for clusters 3 and 4 + var fatData = CreateFatData( + 0x00000000, // Cluster 0: reserved + 0x0FFFFFFF, // Cluster 1: reserved (EOC marker) + 0x0FFFFFFF, // Cluster 2: root directory (EOC) + 0x0FFFFFFF, // Cluster 3: valid entry (EOC) + 0x0FFFFFFF // Cluster 4: valid entry (EOC) + ); + var fatTable = new Fat32Table(bootSector, fatData); + + // Create root directory cluster data with a deleted entry followed by a valid entry + var deletedEntry = CreateDirectoryEntry( + "DELETEDTXT", + 0x00, // Regular file + (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15), + (ushort)((14 << 11) | (30 << 5) | 10), + 0, + 3, // Would point to cluster 3 + 100); + deletedEntry[0] = 0xE5; // Mark as deleted + + var validEntry = CreateDirectoryEntry( + "VALID FIL", + 0x00, // Regular file + (ushort)(((2023 - 1980) << 9) | (6 << 5) | 20), + (ushort)((10 << 11) | (0 << 5) | 0), + 0, + 4, // Points to cluster 4 + 200); + + var clusterData = new Dictionary { - var fatData = new byte[entries.Length * 4]; - for (int i = 0; i < entries.Length; i++) - { - BinaryPrimitives.WriteUInt32LittleEndian(fatData.AsSpan(i * 4, 4), entries[i]); - } - return fatData; - } + [2] = [.. deletedEntry, .. validEntry, .. new byte[4096 - 64]] + }; - /// - /// Creates a directory entry (32 bytes) with the specified parameters. - /// - private static byte[] CreateDirectoryEntry( - string filename, - byte attributes, - ushort writeDate, - ushort writeTime, - ushort clusterHigh, - ushort clusterLow, - uint fileSize = 0) + byte[] getClusterData(uint clusterNum) => clusterData[clusterNum]; + + var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData); + + // Should only have cluster 4 mapped (cluster 3 should not be mapped as the entry was deleted) + Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(3), "Deleted entry's cluster should not be mapped"); + Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(4), "Valid entry's cluster should be mapped"); + } + + [Test] + public void Test_Fat32DirectoryWalker_StopAtEndMarker() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create a FAT where cluster 2 (root) points to EOC, with enough entries for clusters 3 and 4 + var fatData = CreateFatData( + 0x00000000, // Cluster 0: reserved + 0x0FFFFFFF, // Cluster 1: reserved (EOC marker) + 0x0FFFFFFF, // Cluster 2: root directory (EOC) + 0x0FFFFFFF, // Cluster 3: valid entry (EOC) + 0x0FFFFFFF // Cluster 4: valid entry (EOC) + ); + var fatTable = new Fat32Table(bootSector, fatData); + + // Create root directory cluster data with an end marker followed by a valid entry + var validEntry1 = CreateDirectoryEntry( + "FILE1 TXT", + 0x00, // Regular file + (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15), + (ushort)((14 << 11) | (30 << 5) | 10), + 0, + 3, // Points to cluster 3 + 100); + + var endMarker = new byte[32]; // All zeros including first byte = end of directory + + var validEntry2 = CreateDirectoryEntry( + "FILE2 TXT", + 0x00, // Regular file + (ushort)(((2023 - 1980) << 9) | (6 << 5) | 20), + (ushort)((10 << 11) | (0 << 5) | 0), + 0, + 4, // Points to cluster 4 + 200); + + var clusterData = new Dictionary { - var entry = new byte[32]; + [2] = [.. validEntry1, .. endMarker, .. validEntry2, .. new byte[4096 - 96]] + }; - // Filename (11 bytes: 8 for name, 3 for extension) - var nameBytes = System.Text.Encoding.ASCII.GetBytes(filename.PadRight(11).Substring(0, 11)); - nameBytes.CopyTo(entry, 0); + byte[] getClusterData(uint clusterNum) => clusterData[clusterNum]; - // Attributes at offset 0x0B - entry[0x0B] = attributes; + var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData); - // Write time at offset 0x16 - BinaryPrimitives.WriteUInt16LittleEndian(entry.AsSpan(0x16, 2), writeTime); + // Should only have cluster 3 mapped (FILE2 after end marker should be ignored) + Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(3), "Entry before end marker should be mapped"); + Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(4), "Entry after end marker should not be mapped"); + } - // Write date at offset 0x18 - BinaryPrimitives.WriteUInt16LittleEndian(entry.AsSpan(0x18, 2), writeDate); + [Test] + public void Test_Fat32DirectoryWalker_SkipLfnEntries() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); - // Cluster high at offset 0x14 - BinaryPrimitives.WriteUInt16LittleEndian(entry.AsSpan(0x14, 2), clusterHigh); + // Create a FAT where cluster 2 (root) points to EOC, with enough entries for cluster 3 + var fatData = CreateFatData( + 0x00000000, // Cluster 0: reserved + 0x0FFFFFFF, // Cluster 1: reserved (EOC marker) + 0x0FFFFFFF, // Cluster 2: root directory (EOC) + 0x0FFFFFFF // Cluster 3: valid entry (EOC) + ); + var fatTable = new Fat32Table(bootSector, fatData); - // Cluster low at offset 0x1A - BinaryPrimitives.WriteUInt16LittleEndian(entry.AsSpan(0x1A, 2), clusterLow); + // Create root directory cluster data with an LFN entry followed by a valid short entry + var lfnEntry = CreateDirectoryEntry( + "LFN_ENTRY ", + 0x0F, // Long filename attribute + 0, + 0, + 0, + 0, + 0); - // File size at offset 0x1C - BinaryPrimitives.WriteUInt32LittleEndian(entry.AsSpan(0x1C, 4), fileSize); + var validEntry = CreateDirectoryEntry( + "VALID FIL", + 0x00, // Regular file + (ushort)(((2023 - 1980) << 9) | (6 << 5) | 20), + (ushort)((10 << 11) | (0 << 5) | 0), + 0, + 3, // Points to cluster 3 + 200); - return entry; - } - - [Test] - public void Test_Fat32DirectoryWalker_ParseTimestamp_Correct() + var clusterData = new Dictionary { - // Test known FAT32 date/time values - // Date: 2023-06-15 = (2023-1980) << 9 | 6 << 5 | 15 = 43 << 9 | 192 | 15 = 22080 + 192 + 15 = 22287 - // Time: 14:30:20 = 14 << 11 | 30 << 5 | 10 = 28672 + 960 + 10 = 29642 - var date = (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15); - var time = (ushort)((14 << 11) | (30 << 5) | (20 / 2)); + [2] = [.. lfnEntry, .. validEntry, .. new byte[4096 - 64]] + }; - var result = Fat32DirectoryWalker.ParseDateTime(date, time); + byte[] getClusterData(uint clusterNum) => clusterData[clusterNum]; - Assert.AreEqual(2023, result.Year); - Assert.AreEqual(6, result.Month); - Assert.AreEqual(15, result.Day); - Assert.AreEqual(14, result.Hour); - Assert.AreEqual(30, result.Minute); - Assert.AreEqual(20, result.Second); - Assert.AreEqual(DateTimeKind.Utc, result.Kind); - } + var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData); - [Test] - public void Test_Fat32DirectoryWalker_ParseTimestamp_Zero_ReturnsEpoch() + // Should only have cluster 3 mapped (LFN entry should be skipped) + Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(0), "LFN entry should not create mapping"); + Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(3), "Valid entry's cluster should be mapped"); + } + + [Test] + public void Test_Fat32DirectoryWalker_SkipDotEntries() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create a FAT where cluster 2 (root) and cluster 3 (subdir) both point to EOC, with enough entries for cluster 4 + var fatData = CreateFatData( + 0x00000000, // Cluster 0: reserved + 0x0FFFFFFF, // Cluster 1: reserved (EOC marker) + 0x0FFFFFFF, // Cluster 2: root directory (EOC) + 0x0FFFFFFF, // Cluster 3: subdirectory (EOC) + 0x0FFFFFFF // Cluster 4: valid entry (EOC) + ); + var fatTable = new Fat32Table(bootSector, fatData); + + // Create root directory with a subdirectory entry + var subdirEntry = CreateDirectoryEntry( + "SUBDIR ", + 0x10, // Directory attribute + (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15), + (ushort)((14 << 11) | (30 << 5) | 10), + 0, + 3, // Points to cluster 3 + 0); + + var rootClusterData = subdirEntry.Concat(new byte[4096 - 32]).ToArray(); + + // Create subdirectory cluster with . and .. entries and a file entry + var dotEntry = CreateDirectoryEntry( + ". ", + 0x10, // Directory attribute + (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15), + (ushort)((14 << 11) | (30 << 5) | 10), + 0, + 3, // Points to self (cluster 3) + 0); + + var dotDotEntry = CreateDirectoryEntry( + ".. ", + 0x10, // Directory attribute + (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15), + (ushort)((14 << 11) | (30 << 5) | 10), + 0, + 2, // Points to parent (cluster 2) + 0); + + var fileEntry = CreateDirectoryEntry( + "FILE TXT", + 0x00, // Regular file + (ushort)(((2023 - 1980) << 9) | (6 << 5) | 20), + (ushort)((10 << 11) | (0 << 5) | 0), + 0, + 4, // Points to cluster 4 + 100); + + var subdirClusterData = dotEntry.Concat(dotDotEntry).Concat(fileEntry).Concat(new byte[4096 - 96]).ToArray(); + + var clusterData = new Dictionary { - var result = Fat32DirectoryWalker.ParseDateTime(0, 0); - Assert.AreEqual(DateTime.UnixEpoch, result); - } + [2] = rootClusterData, + [3] = subdirClusterData + }; - [Test] - public void Test_Fat32DirectoryWalker_ParseTimestamp_InvalidDate_ReturnsEpoch() + byte[] getClusterData(uint clusterNum) => clusterData[clusterNum]; + + var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData); + + // Cluster 2 should be mapped (it's the root directory) + Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(2), "Root directory cluster should be mapped"); + + // Cluster 3 should be mapped (it's the subdirectory itself) + Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(3), "Subdirectory cluster should be mapped"); + + // Cluster 4 should be mapped (it's the file in the subdirectory) + Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(4), "File cluster should be mapped"); + + // We should have exactly 3 mappings (clusters 2, 3, and 4) + Assert.AreEqual(3, walker.ClusterToTimestampMap.Count, "Should only have 3 cluster mappings"); + } + + [Test] + public void Test_Fat32DirectoryWalker_ExtractClusterNumber() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create a FAT where cluster 2 (root) and cluster 0x1234ABCD both point to EOC + var fatData = CreateFatData( + 0x00000000, // Cluster 0: reserved + 0x0FFFFFFF, // Cluster 1: reserved (EOC marker) + 0x0FFFFFFF, // Cluster 2: root directory (EOC) + 0x00000000, // Cluster 3: free (not used in this test) + 0x00000000, // ... more free clusters + 0x00000000, + 0x00000000, + 0x00000000, + 0x00000000, + 0x00000000, + 0x0FFFFFFF // Cluster 10 (0x0A): high cluster test (EOC) + ); + var fatTable = new Fat32Table(bootSector, fatData); + + // Test with a file entry that has a large cluster number requiring both high and low words + // Cluster 0x0A = 10 (within our small FAT) + var fileEntry = CreateDirectoryEntry( + "TEST FIL", + 0x00, // Regular file + (ushort)(((2023 - 1980) << 9) | (6 << 5) | 20), + (ushort)((10 << 11) | (0 << 5) | 0), + 0x0000, // High word + 0x000A, // Low word = 10 + 100); + + var clusterData = new Dictionary { - // Invalid month (13) - var invalidDate = (ushort)((43 << 9) | (13 << 5) | 15); - var time = (ushort)((14 << 11) | (30 << 5) | 10); + [2] = [.. fileEntry, .. new byte[4096 - 32]] + }; - var result = Fat32DirectoryWalker.ParseDateTime(invalidDate, time); - Assert.AreEqual(DateTime.UnixEpoch, result); - } + byte[] getClusterData(uint clusterNum) => clusterData[clusterNum]; - [Test] - public void Test_Fat32DirectoryWalker_ParseTimestamp_InvalidTime_ReturnsEpoch() + var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData); + + // Cluster 10 should be mapped + Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(10), "Cluster with combined high/low words should be mapped"); + } + + [Test] + public void Test_Fat32DirectoryWalker_ClusterTimestampMap_Correct() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create a FAT with cluster chains + var fatData = CreateFatData( + 0x00000000, // Cluster 0: reserved + 0x0FFFFFFF, // Cluster 1: reserved (EOC marker) + 0x0FFFFFFF, // Cluster 2: root directory (EOC) + 0x00000004, // Cluster 3: points to cluster 4 + 0x0FFFFFFF, // Cluster 4: EOC (file data) + 0x00000006, // Cluster 5: points to cluster 6 + 0x00000007, // Cluster 6: points to cluster 7 + 0x0FFFFFFF // Cluster 7: EOC (file data) + ); + var fatTable = new Fat32Table(bootSector, fatData); + + // Create root directory with two file entries + var file1Entry = CreateDirectoryEntry( + "FILE1 TXT", + 0x00, // Regular file + (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15), + (ushort)((14 << 11) | (30 << 5) | 10), + 0, + 3, // Points to cluster 3 (chain: 3 -> 4) + 8192); // 2 clusters worth of data + + var file2Entry = CreateDirectoryEntry( + "FILE2 DAT", + 0x00, // Regular file + (ushort)(((2023 - 1980) << 9) | (6 << 5) | 20), + (ushort)((16 << 11) | (45 << 5) | (30 / 2)), + 0, + 5, // Points to cluster 5 (chain: 5 -> 6 -> 7) + 12288); // 3 clusters worth of data + + var clusterData = new Dictionary { - // Valid date but invalid hour (25) - var date = (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15); - var invalidTime = (ushort)((25 << 11) | (30 << 5) | 10); + [2] = [.. file1Entry, .. file2Entry, .. new byte[4096 - 64]] + }; - var result = Fat32DirectoryWalker.ParseDateTime(date, invalidTime); - Assert.AreEqual(DateTime.UnixEpoch, result); - } + byte[] getClusterData(uint clusterNum) => clusterData[clusterNum]; - [Test] - public void Test_Fat32DirectoryWalker_SkipDeletedEntries() + var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData); + + // Should have mappings for all file clusters (3, 4, 5, 6, 7) plus root directory (2) + Assert.AreEqual(6, walker.ClusterToTimestampMap.Count, "Should have 6 cluster mappings (root + 5 file clusters)"); + + // Verify root directory cluster (2) is mapped + Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(2), "Root directory cluster should be mapped"); + + // Verify FILE1 clusters (3 and 4) have the FILE1 timestamp + var file1ExpectedTime = new DateTime(2023, 6, 15, 14, 30, 20, DateTimeKind.Utc); + Assert.AreEqual(file1ExpectedTime, walker.ClusterToTimestampMap[3], "Cluster 3 should have FILE1 timestamp"); + Assert.AreEqual(file1ExpectedTime, walker.ClusterToTimestampMap[4], "Cluster 4 should have FILE1 timestamp"); + + // Verify FILE2 clusters (5, 6, 7) have the FILE2 timestamp + var file2ExpectedTime = new DateTime(2023, 6, 20, 16, 45, 30, DateTimeKind.Utc); + Assert.AreEqual(file2ExpectedTime, walker.ClusterToTimestampMap[5], "Cluster 5 should have FILE2 timestamp"); + Assert.AreEqual(file2ExpectedTime, walker.ClusterToTimestampMap[6], "Cluster 6 should have FILE2 timestamp"); + Assert.AreEqual(file2ExpectedTime, walker.ClusterToTimestampMap[7], "Cluster 7 should have FILE2 timestamp"); + } + + [Test] + public void Test_Fat32DirectoryWalker_SubdirectoryRecursion() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create a FAT with cluster chains for root and subdirectory + var fatData = CreateFatData( + 0x00000000, // Cluster 0: reserved + 0x0FFFFFFF, // Cluster 1: reserved (EOC marker) + 0x0FFFFFFF, // Cluster 2: root directory (EOC) + 0x0FFFFFFF, // Cluster 3: subdirectory (EOC) + 0x00000005, // Cluster 4: file in subdirectory points to cluster 5 + 0x0FFFFFFF, // Cluster 5: EOC (file data) + 0x0FFFFFFF // Cluster 6: another file in subdirectory (EOC) + ); + var fatTable = new Fat32Table(bootSector, fatData); + + // Create root directory with a subdirectory entry + var subdirEntry = CreateDirectoryEntry( + "SUBDIR ", + 0x10, // Directory attribute + (ushort)(((2023 - 1980) << 9) | (6 << 5) | 10), + (ushort)((9 << 11) | (0 << 5) | 0), + 0, + 3, // Points to cluster 3 + 0); + + var rootClusterData = subdirEntry.Concat(new byte[4096 - 32]).ToArray(); + + // Create subdirectory cluster with two file entries + var subdirFile1Entry = CreateDirectoryEntry( + "SUBFILE1TXT", + 0x00, // Regular file + (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15), + (ushort)((14 << 11) | (30 << 5) | 10), + 0, + 4, // Points to cluster 4 (chain: 4 -> 5) + 8192); + + var subdirFile2Entry = CreateDirectoryEntry( + "SUBFILE2DAT", + 0x00, // Regular file + (ushort)(((2023 - 1980) << 9) | (6 << 5) | 20), + (ushort)((16 << 11) | (45 << 5) | (30 / 2)), + 0, + 6, // Points to cluster 6 + 4096); + + var subdirClusterData = subdirFile1Entry.Concat(subdirFile2Entry).Concat(new byte[4096 - 64]).ToArray(); + + var clusterData = new Dictionary { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); + [2] = rootClusterData, + [3] = subdirClusterData + }; - // Create a FAT where cluster 2 (root) points to EOC, with enough entries for clusters 3 and 4 - var fatData = CreateFatData( - 0x00000000, // Cluster 0: reserved - 0x0FFFFFFF, // Cluster 1: reserved (EOC marker) - 0x0FFFFFFF, // Cluster 2: root directory (EOC) - 0x0FFFFFFF, // Cluster 3: valid entry (EOC) - 0x0FFFFFFF // Cluster 4: valid entry (EOC) - ); - var fatTable = new Fat32Table(bootSector, fatData); + byte[] getClusterData(uint clusterNum) => clusterData[clusterNum]; - // Create root directory cluster data with a deleted entry followed by a valid entry - var deletedEntry = CreateDirectoryEntry( - "DELETEDTXT", - 0x00, // Regular file - (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15), - (ushort)((14 << 11) | (30 << 5) | 10), - 0, - 3, // Would point to cluster 3 - 100); - deletedEntry[0] = 0xE5; // Mark as deleted + var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData); - var validEntry = CreateDirectoryEntry( - "VALID FIL", - 0x00, // Regular file - (ushort)(((2023 - 1980) << 9) | (6 << 5) | 20), - (ushort)((10 << 11) | (0 << 5) | 0), - 0, - 4, // Points to cluster 4 - 200); + // Should have mappings for: + // - Root directory cluster (2) + // - Subdirectory cluster (3) with its own timestamp + // - File1 clusters (4, 5) with their timestamp + // - File2 cluster (6) with its timestamp + Assert.AreEqual(5, walker.ClusterToTimestampMap.Count, "Should have 5 cluster mappings (root + subdir + file clusters)"); - var clusterData = new Dictionary - { - [2] = [.. deletedEntry, .. validEntry, .. new byte[4096 - 64]] - }; + // Verify root directory cluster is mapped + Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(2), "Root directory cluster should be mapped"); - byte[] getClusterData(uint clusterNum) => clusterData[clusterNum]; + // Verify subdirectory cluster has the subdirectory timestamp + var subdirExpectedTime = new DateTime(2023, 6, 10, 9, 0, 0, DateTimeKind.Utc); + Assert.AreEqual(subdirExpectedTime, walker.ClusterToTimestampMap[3], "Subdirectory cluster should have subdirectory timestamp"); - var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData); + // Verify subfile1 clusters have their timestamp + var file1ExpectedTime = new DateTime(2023, 6, 15, 14, 30, 20, DateTimeKind.Utc); + Assert.AreEqual(file1ExpectedTime, walker.ClusterToTimestampMap[4], "Subfile1 cluster 4 should have file timestamp"); + Assert.AreEqual(file1ExpectedTime, walker.ClusterToTimestampMap[5], "Subfile1 cluster 5 should have file timestamp"); - // Should only have cluster 4 mapped (cluster 3 should not be mapped as the entry was deleted) - Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(3), "Deleted entry's cluster should not be mapped"); - Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(4), "Valid entry's cluster should be mapped"); - } + // Verify subfile2 cluster has its timestamp + var file2ExpectedTime = new DateTime(2023, 6, 20, 16, 45, 30, DateTimeKind.Utc); + Assert.AreEqual(file2ExpectedTime, walker.ClusterToTimestampMap[6], "Subfile2 cluster should have file timestamp"); + } - [Test] - public void Test_Fat32DirectoryWalker_StopAtEndMarker() + [Test] + public void Test_Fat32DirectoryWalker_OverlappingClusters_UsesMostRecentTimestamp() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create a FAT where cluster 3 is referenced by both files + var fatData = CreateFatData( + 0x00000000, // Cluster 0: reserved + 0x0FFFFFFF, // Cluster 1: reserved (EOC marker) + 0x0FFFFFFF, // Cluster 2: root directory (EOC) + 0x0FFFFFFF, // Cluster 3: EOC (shared by both files - unusual but testable) + 0x0FFFFFFF, // Cluster 4: EOC + 0x0FFFFFFF // Cluster 5: EOC + ); + var fatTable = new Fat32Table(bootSector, fatData); + + // Create root directory with two file entries referencing the same cluster + // This is unusual in practice but tests the "most recent timestamp" logic + var olderFileEntry = CreateDirectoryEntry( + "OLDER TXT", + 0x00, // Regular file + (ushort)(((2020 - 1980) << 9) | (1 << 5) | 1), + (ushort)((0 << 11) | (0 << 5) | 0), + 0, + 3, // Points to cluster 3 + 4096); + + var newerFileEntry = CreateDirectoryEntry( + "NEWER TXT", + 0x00, // Regular file + (ushort)(((2023 - 1980) << 9) | (6 << 5) | 20), + (ushort)((16 << 11) | (45 << 5) | (30 / 2)), + 0, + 3, // Also points to cluster 3 + 4096); + + var clusterData = new Dictionary { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); + [2] = [.. olderFileEntry, .. newerFileEntry, .. new byte[4096 - 64]] + }; - // Create a FAT where cluster 2 (root) points to EOC, with enough entries for clusters 3 and 4 - var fatData = CreateFatData( - 0x00000000, // Cluster 0: reserved - 0x0FFFFFFF, // Cluster 1: reserved (EOC marker) - 0x0FFFFFFF, // Cluster 2: root directory (EOC) - 0x0FFFFFFF, // Cluster 3: valid entry (EOC) - 0x0FFFFFFF // Cluster 4: valid entry (EOC) - ); - var fatTable = new Fat32Table(bootSector, fatData); + byte[] getClusterData(uint clusterNum) => clusterData[clusterNum]; - // Create root directory cluster data with an end marker followed by a valid entry - var validEntry1 = CreateDirectoryEntry( - "FILE1 TXT", - 0x00, // Regular file - (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15), - (ushort)((14 << 11) | (30 << 5) | 10), - 0, - 3, // Points to cluster 3 - 100); + var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData); - var endMarker = new byte[32]; // All zeros including first byte = end of directory + // Cluster 3 should have the newer timestamp + var newerExpectedTime = new DateTime(2023, 6, 20, 16, 45, 30, DateTimeKind.Utc); + Assert.AreEqual(newerExpectedTime, walker.ClusterToTimestampMap[3], "Shared cluster should have most recent timestamp"); + } - var validEntry2 = CreateDirectoryEntry( - "FILE2 TXT", - 0x00, // Regular file - (ushort)(((2023 - 1980) << 9) | (6 << 5) | 20), - (ushort)((10 << 11) | (0 << 5) | 0), - 0, - 4, // Points to cluster 4 - 200); + [Test] + public void Test_Fat32DirectoryWalker_SkipVolumeLabel() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); - var clusterData = new Dictionary - { - [2] = [.. validEntry1, .. endMarker, .. validEntry2, .. new byte[4096 - 96]] - }; + // Create a FAT where cluster 2 (root) points to EOC, with enough entries for clusters 3 and 4 + var fatData = CreateFatData( + 0x00000000, // Cluster 0: reserved + 0x0FFFFFFF, // Cluster 1: reserved (EOC marker) + 0x0FFFFFFF, // Cluster 2: root directory (EOC) + 0x0FFFFFFF, // Cluster 3: volume label would point here (but should be skipped) + 0x0FFFFFFF // Cluster 4: valid entry (EOC) + ); + var fatTable = new Fat32Table(bootSector, fatData); - byte[] getClusterData(uint clusterNum) => clusterData[clusterNum]; + // Create root directory cluster data with a volume label followed by a valid file entry + var volumeLabelEntry = CreateDirectoryEntry( + "VOLUMELABEL", + 0x08, // Volume label attribute + (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15), + (ushort)((14 << 11) | (30 << 5) | 10), + 0, + 3, // Would point to cluster 3 + 0); - var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData); + var validEntry = CreateDirectoryEntry( + "VALID FIL", + 0x00, // Regular file + (ushort)(((2023 - 1980) << 9) | (6 << 5) | 20), + (ushort)((10 << 11) | (0 << 5) | 0), + 0, + 4, // Points to cluster 4 + 200); - // Should only have cluster 3 mapped (FILE2 after end marker should be ignored) - Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(3), "Entry before end marker should be mapped"); - Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(4), "Entry after end marker should not be mapped"); - } - - [Test] - public void Test_Fat32DirectoryWalker_SkipLfnEntries() + var clusterData = new Dictionary { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); + [2] = [.. volumeLabelEntry, .. validEntry, .. new byte[4096 - 64]] + }; - // Create a FAT where cluster 2 (root) points to EOC, with enough entries for cluster 3 - var fatData = CreateFatData( - 0x00000000, // Cluster 0: reserved - 0x0FFFFFFF, // Cluster 1: reserved (EOC marker) - 0x0FFFFFFF, // Cluster 2: root directory (EOC) - 0x0FFFFFFF // Cluster 3: valid entry (EOC) - ); - var fatTable = new Fat32Table(bootSector, fatData); + byte[] getClusterData(uint clusterNum) => clusterData[clusterNum]; - // Create root directory cluster data with an LFN entry followed by a valid short entry - var lfnEntry = CreateDirectoryEntry( - "LFN_ENTRY ", - 0x0F, // Long filename attribute - 0, - 0, - 0, - 0, - 0); + var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData); - var validEntry = CreateDirectoryEntry( - "VALID FIL", - 0x00, // Regular file - (ushort)(((2023 - 1980) << 9) | (6 << 5) | 20), - (ushort)((10 << 11) | (0 << 5) | 0), - 0, - 3, // Points to cluster 3 - 200); + // Should only have cluster 4 mapped (volume label should be skipped) + Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(3), "Volume label's cluster should not be mapped"); + Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(4), "Valid entry's cluster should be mapped"); + } - var clusterData = new Dictionary - { - [2] = [.. lfnEntry, .. validEntry, .. new byte[4096 - 64]] - }; + [Test] + public void Test_Fat32DirectoryWalker_InvalidClusterNumber_Skipped() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); - byte[] getClusterData(uint clusterNum) => clusterData[clusterNum]; + // Create a FAT where cluster 2 (root) points to EOC, with enough entries for cluster 3 + var fatData = CreateFatData( + 0x00000000, // Cluster 0: reserved + 0x0FFFFFFF, // Cluster 1: reserved (EOC marker) + 0x0FFFFFFF, // Cluster 2: root directory (EOC) + 0x0FFFFFFF // Cluster 3: valid entry (EOC) - for the valid file + ); + var fatTable = new Fat32Table(bootSector, fatData); - var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData); + // Create root directory cluster data with an entry that has cluster 0 (invalid) + var invalidClusterEntry = CreateDirectoryEntry( + "INVALID TXT", + 0x00, // Regular file + (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15), + (ushort)((14 << 11) | (30 << 5) | 10), + 0, + 0, // Cluster 0 is invalid (reserved) + 100); - // Should only have cluster 3 mapped (LFN entry should be skipped) - Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(0), "LFN entry should not create mapping"); - Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(3), "Valid entry's cluster should be mapped"); - } + var validEntry = CreateDirectoryEntry( + "VALID FIL", + 0x00, // Regular file + (ushort)(((2023 - 1980) << 9) | (6 << 5) | 20), + (ushort)((10 << 11) | (0 << 5) | 0), + 0, + 3, // Points to cluster 3 + 200); - [Test] - public void Test_Fat32DirectoryWalker_SkipDotEntries() + var clusterData = new Dictionary { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); + [2] = [.. invalidClusterEntry, .. validEntry, .. new byte[4096 - 64]] + }; - // Create a FAT where cluster 2 (root) and cluster 3 (subdir) both point to EOC, with enough entries for cluster 4 - var fatData = CreateFatData( - 0x00000000, // Cluster 0: reserved - 0x0FFFFFFF, // Cluster 1: reserved (EOC marker) - 0x0FFFFFFF, // Cluster 2: root directory (EOC) - 0x0FFFFFFF, // Cluster 3: subdirectory (EOC) - 0x0FFFFFFF // Cluster 4: valid entry (EOC) - ); - var fatTable = new Fat32Table(bootSector, fatData); + byte[] getClusterData(uint clusterNum) => clusterData[clusterNum]; - // Create root directory with a subdirectory entry - var subdirEntry = CreateDirectoryEntry( - "SUBDIR ", - 0x10, // Directory attribute - (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15), - (ushort)((14 << 11) | (30 << 5) | 10), - 0, - 3, // Points to cluster 3 - 0); + var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData); - var rootClusterData = subdirEntry.Concat(new byte[4096 - 32]).ToArray(); - - // Create subdirectory cluster with . and .. entries and a file entry - var dotEntry = CreateDirectoryEntry( - ". ", - 0x10, // Directory attribute - (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15), - (ushort)((14 << 11) | (30 << 5) | 10), - 0, - 3, // Points to self (cluster 3) - 0); - - var dotDotEntry = CreateDirectoryEntry( - ".. ", - 0x10, // Directory attribute - (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15), - (ushort)((14 << 11) | (30 << 5) | 10), - 0, - 2, // Points to parent (cluster 2) - 0); - - var fileEntry = CreateDirectoryEntry( - "FILE TXT", - 0x00, // Regular file - (ushort)(((2023 - 1980) << 9) | (6 << 5) | 20), - (ushort)((10 << 11) | (0 << 5) | 0), - 0, - 4, // Points to cluster 4 - 100); - - var subdirClusterData = dotEntry.Concat(dotDotEntry).Concat(fileEntry).Concat(new byte[4096 - 96]).ToArray(); - - var clusterData = new Dictionary - { - [2] = rootClusterData, - [3] = subdirClusterData - }; - - byte[] getClusterData(uint clusterNum) => clusterData[clusterNum]; - - var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData); - - // Cluster 2 should be mapped (it's the root directory) - Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(2), "Root directory cluster should be mapped"); - - // Cluster 3 should be mapped (it's the subdirectory itself) - Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(3), "Subdirectory cluster should be mapped"); - - // Cluster 4 should be mapped (it's the file in the subdirectory) - Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(4), "File cluster should be mapped"); - - // We should have exactly 3 mappings (clusters 2, 3, and 4) - Assert.AreEqual(3, walker.ClusterToTimestampMap.Count, "Should only have 3 cluster mappings"); - } - - [Test] - public void Test_Fat32DirectoryWalker_ExtractClusterNumber() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - // Create a FAT where cluster 2 (root) and cluster 0x1234ABCD both point to EOC - var fatData = CreateFatData( - 0x00000000, // Cluster 0: reserved - 0x0FFFFFFF, // Cluster 1: reserved (EOC marker) - 0x0FFFFFFF, // Cluster 2: root directory (EOC) - 0x00000000, // Cluster 3: free (not used in this test) - 0x00000000, // ... more free clusters - 0x00000000, - 0x00000000, - 0x00000000, - 0x00000000, - 0x00000000, - 0x0FFFFFFF // Cluster 10 (0x0A): high cluster test (EOC) - ); - var fatTable = new Fat32Table(bootSector, fatData); - - // Test with a file entry that has a large cluster number requiring both high and low words - // Cluster 0x0A = 10 (within our small FAT) - var fileEntry = CreateDirectoryEntry( - "TEST FIL", - 0x00, // Regular file - (ushort)(((2023 - 1980) << 9) | (6 << 5) | 20), - (ushort)((10 << 11) | (0 << 5) | 0), - 0x0000, // High word - 0x000A, // Low word = 10 - 100); - - var clusterData = new Dictionary - { - [2] = [.. fileEntry, .. new byte[4096 - 32]] - }; - - byte[] getClusterData(uint clusterNum) => clusterData[clusterNum]; - - var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData); - - // Cluster 10 should be mapped - Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(10), "Cluster with combined high/low words should be mapped"); - } - - [Test] - public void Test_Fat32DirectoryWalker_ClusterTimestampMap_Correct() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - // Create a FAT with cluster chains - var fatData = CreateFatData( - 0x00000000, // Cluster 0: reserved - 0x0FFFFFFF, // Cluster 1: reserved (EOC marker) - 0x0FFFFFFF, // Cluster 2: root directory (EOC) - 0x00000004, // Cluster 3: points to cluster 4 - 0x0FFFFFFF, // Cluster 4: EOC (file data) - 0x00000006, // Cluster 5: points to cluster 6 - 0x00000007, // Cluster 6: points to cluster 7 - 0x0FFFFFFF // Cluster 7: EOC (file data) - ); - var fatTable = new Fat32Table(bootSector, fatData); - - // Create root directory with two file entries - var file1Entry = CreateDirectoryEntry( - "FILE1 TXT", - 0x00, // Regular file - (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15), - (ushort)((14 << 11) | (30 << 5) | 10), - 0, - 3, // Points to cluster 3 (chain: 3 -> 4) - 8192); // 2 clusters worth of data - - var file2Entry = CreateDirectoryEntry( - "FILE2 DAT", - 0x00, // Regular file - (ushort)(((2023 - 1980) << 9) | (6 << 5) | 20), - (ushort)((16 << 11) | (45 << 5) | (30 / 2)), - 0, - 5, // Points to cluster 5 (chain: 5 -> 6 -> 7) - 12288); // 3 clusters worth of data - - var clusterData = new Dictionary - { - [2] = [.. file1Entry, .. file2Entry, .. new byte[4096 - 64]] - }; - - byte[] getClusterData(uint clusterNum) => clusterData[clusterNum]; - - var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData); - - // Should have mappings for all file clusters (3, 4, 5, 6, 7) plus root directory (2) - Assert.AreEqual(6, walker.ClusterToTimestampMap.Count, "Should have 6 cluster mappings (root + 5 file clusters)"); - - // Verify root directory cluster (2) is mapped - Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(2), "Root directory cluster should be mapped"); - - // Verify FILE1 clusters (3 and 4) have the FILE1 timestamp - var file1ExpectedTime = new DateTime(2023, 6, 15, 14, 30, 20, DateTimeKind.Utc); - Assert.AreEqual(file1ExpectedTime, walker.ClusterToTimestampMap[3], "Cluster 3 should have FILE1 timestamp"); - Assert.AreEqual(file1ExpectedTime, walker.ClusterToTimestampMap[4], "Cluster 4 should have FILE1 timestamp"); - - // Verify FILE2 clusters (5, 6, 7) have the FILE2 timestamp - var file2ExpectedTime = new DateTime(2023, 6, 20, 16, 45, 30, DateTimeKind.Utc); - Assert.AreEqual(file2ExpectedTime, walker.ClusterToTimestampMap[5], "Cluster 5 should have FILE2 timestamp"); - Assert.AreEqual(file2ExpectedTime, walker.ClusterToTimestampMap[6], "Cluster 6 should have FILE2 timestamp"); - Assert.AreEqual(file2ExpectedTime, walker.ClusterToTimestampMap[7], "Cluster 7 should have FILE2 timestamp"); - } - - [Test] - public void Test_Fat32DirectoryWalker_SubdirectoryRecursion() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - // Create a FAT with cluster chains for root and subdirectory - var fatData = CreateFatData( - 0x00000000, // Cluster 0: reserved - 0x0FFFFFFF, // Cluster 1: reserved (EOC marker) - 0x0FFFFFFF, // Cluster 2: root directory (EOC) - 0x0FFFFFFF, // Cluster 3: subdirectory (EOC) - 0x00000005, // Cluster 4: file in subdirectory points to cluster 5 - 0x0FFFFFFF, // Cluster 5: EOC (file data) - 0x0FFFFFFF // Cluster 6: another file in subdirectory (EOC) - ); - var fatTable = new Fat32Table(bootSector, fatData); - - // Create root directory with a subdirectory entry - var subdirEntry = CreateDirectoryEntry( - "SUBDIR ", - 0x10, // Directory attribute - (ushort)(((2023 - 1980) << 9) | (6 << 5) | 10), - (ushort)((9 << 11) | (0 << 5) | 0), - 0, - 3, // Points to cluster 3 - 0); - - var rootClusterData = subdirEntry.Concat(new byte[4096 - 32]).ToArray(); - - // Create subdirectory cluster with two file entries - var subdirFile1Entry = CreateDirectoryEntry( - "SUBFILE1TXT", - 0x00, // Regular file - (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15), - (ushort)((14 << 11) | (30 << 5) | 10), - 0, - 4, // Points to cluster 4 (chain: 4 -> 5) - 8192); - - var subdirFile2Entry = CreateDirectoryEntry( - "SUBFILE2DAT", - 0x00, // Regular file - (ushort)(((2023 - 1980) << 9) | (6 << 5) | 20), - (ushort)((16 << 11) | (45 << 5) | (30 / 2)), - 0, - 6, // Points to cluster 6 - 4096); - - var subdirClusterData = subdirFile1Entry.Concat(subdirFile2Entry).Concat(new byte[4096 - 64]).ToArray(); - - var clusterData = new Dictionary - { - [2] = rootClusterData, - [3] = subdirClusterData - }; - - byte[] getClusterData(uint clusterNum) => clusterData[clusterNum]; - - var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData); - - // Should have mappings for: - // - Root directory cluster (2) - // - Subdirectory cluster (3) with its own timestamp - // - File1 clusters (4, 5) with their timestamp - // - File2 cluster (6) with its timestamp - Assert.AreEqual(5, walker.ClusterToTimestampMap.Count, "Should have 5 cluster mappings (root + subdir + file clusters)"); - - // Verify root directory cluster is mapped - Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(2), "Root directory cluster should be mapped"); - - // Verify subdirectory cluster has the subdirectory timestamp - var subdirExpectedTime = new DateTime(2023, 6, 10, 9, 0, 0, DateTimeKind.Utc); - Assert.AreEqual(subdirExpectedTime, walker.ClusterToTimestampMap[3], "Subdirectory cluster should have subdirectory timestamp"); - - // Verify subfile1 clusters have their timestamp - var file1ExpectedTime = new DateTime(2023, 6, 15, 14, 30, 20, DateTimeKind.Utc); - Assert.AreEqual(file1ExpectedTime, walker.ClusterToTimestampMap[4], "Subfile1 cluster 4 should have file timestamp"); - Assert.AreEqual(file1ExpectedTime, walker.ClusterToTimestampMap[5], "Subfile1 cluster 5 should have file timestamp"); - - // Verify subfile2 cluster has its timestamp - var file2ExpectedTime = new DateTime(2023, 6, 20, 16, 45, 30, DateTimeKind.Utc); - Assert.AreEqual(file2ExpectedTime, walker.ClusterToTimestampMap[6], "Subfile2 cluster should have file timestamp"); - } - - [Test] - public void Test_Fat32DirectoryWalker_OverlappingClusters_UsesMostRecentTimestamp() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - // Create a FAT where cluster 3 is referenced by both files - var fatData = CreateFatData( - 0x00000000, // Cluster 0: reserved - 0x0FFFFFFF, // Cluster 1: reserved (EOC marker) - 0x0FFFFFFF, // Cluster 2: root directory (EOC) - 0x0FFFFFFF, // Cluster 3: EOC (shared by both files - unusual but testable) - 0x0FFFFFFF, // Cluster 4: EOC - 0x0FFFFFFF // Cluster 5: EOC - ); - var fatTable = new Fat32Table(bootSector, fatData); - - // Create root directory with two file entries referencing the same cluster - // This is unusual in practice but tests the "most recent timestamp" logic - var olderFileEntry = CreateDirectoryEntry( - "OLDER TXT", - 0x00, // Regular file - (ushort)(((2020 - 1980) << 9) | (1 << 5) | 1), - (ushort)((0 << 11) | (0 << 5) | 0), - 0, - 3, // Points to cluster 3 - 4096); - - var newerFileEntry = CreateDirectoryEntry( - "NEWER TXT", - 0x00, // Regular file - (ushort)(((2023 - 1980) << 9) | (6 << 5) | 20), - (ushort)((16 << 11) | (45 << 5) | (30 / 2)), - 0, - 3, // Also points to cluster 3 - 4096); - - var clusterData = new Dictionary - { - [2] = [.. olderFileEntry, .. newerFileEntry, .. new byte[4096 - 64]] - }; - - byte[] getClusterData(uint clusterNum) => clusterData[clusterNum]; - - var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData); - - // Cluster 3 should have the newer timestamp - var newerExpectedTime = new DateTime(2023, 6, 20, 16, 45, 30, DateTimeKind.Utc); - Assert.AreEqual(newerExpectedTime, walker.ClusterToTimestampMap[3], "Shared cluster should have most recent timestamp"); - } - - [Test] - public void Test_Fat32DirectoryWalker_SkipVolumeLabel() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - // Create a FAT where cluster 2 (root) points to EOC, with enough entries for clusters 3 and 4 - var fatData = CreateFatData( - 0x00000000, // Cluster 0: reserved - 0x0FFFFFFF, // Cluster 1: reserved (EOC marker) - 0x0FFFFFFF, // Cluster 2: root directory (EOC) - 0x0FFFFFFF, // Cluster 3: volume label would point here (but should be skipped) - 0x0FFFFFFF // Cluster 4: valid entry (EOC) - ); - var fatTable = new Fat32Table(bootSector, fatData); - - // Create root directory cluster data with a volume label followed by a valid file entry - var volumeLabelEntry = CreateDirectoryEntry( - "VOLUMELABEL", - 0x08, // Volume label attribute - (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15), - (ushort)((14 << 11) | (30 << 5) | 10), - 0, - 3, // Would point to cluster 3 - 0); - - var validEntry = CreateDirectoryEntry( - "VALID FIL", - 0x00, // Regular file - (ushort)(((2023 - 1980) << 9) | (6 << 5) | 20), - (ushort)((10 << 11) | (0 << 5) | 0), - 0, - 4, // Points to cluster 4 - 200); - - var clusterData = new Dictionary - { - [2] = [.. volumeLabelEntry, .. validEntry, .. new byte[4096 - 64]] - }; - - byte[] getClusterData(uint clusterNum) => clusterData[clusterNum]; - - var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData); - - // Should only have cluster 4 mapped (volume label should be skipped) - Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(3), "Volume label's cluster should not be mapped"); - Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(4), "Valid entry's cluster should be mapped"); - } - - [Test] - public void Test_Fat32DirectoryWalker_InvalidClusterNumber_Skipped() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - // Create a FAT where cluster 2 (root) points to EOC, with enough entries for cluster 3 - var fatData = CreateFatData( - 0x00000000, // Cluster 0: reserved - 0x0FFFFFFF, // Cluster 1: reserved (EOC marker) - 0x0FFFFFFF, // Cluster 2: root directory (EOC) - 0x0FFFFFFF // Cluster 3: valid entry (EOC) - for the valid file - ); - var fatTable = new Fat32Table(bootSector, fatData); - - // Create root directory cluster data with an entry that has cluster 0 (invalid) - var invalidClusterEntry = CreateDirectoryEntry( - "INVALID TXT", - 0x00, // Regular file - (ushort)(((2023 - 1980) << 9) | (6 << 5) | 15), - (ushort)((14 << 11) | (30 << 5) | 10), - 0, - 0, // Cluster 0 is invalid (reserved) - 100); - - var validEntry = CreateDirectoryEntry( - "VALID FIL", - 0x00, // Regular file - (ushort)(((2023 - 1980) << 9) | (6 << 5) | 20), - (ushort)((10 << 11) | (0 << 5) | 0), - 0, - 3, // Points to cluster 3 - 200); - - var clusterData = new Dictionary - { - [2] = [.. invalidClusterEntry, .. validEntry, .. new byte[4096 - 64]] - }; - - byte[] getClusterData(uint clusterNum) => clusterData[clusterNum]; - - var walker = new Fat32DirectoryWalker(bootSector, fatTable, getClusterData); - - // Should only have cluster 3 mapped (invalid cluster 0 should be skipped) - Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(0), "Invalid cluster 0 should not be mapped"); - Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(3), "Valid entry's cluster should be mapped"); - } + // Should only have cluster 3 mapped (invalid cluster 0 should be skipped) + Assert.IsFalse(walker.ClusterToTimestampMap.ContainsKey(0), "Invalid cluster 0 should not be mapped"); + Assert.IsTrue(walker.ClusterToTimestampMap.ContainsKey(3), "Valid entry's cluster should be mapped"); } } diff --git a/Duplicati/UnitTest/DiskImage/Fat32/Fat32FilesystemTests.cs b/Duplicati/UnitTest/DiskImage/Fat32/Fat32FilesystemTests.cs index f08ba2c40..402a0ac48 100644 --- a/Duplicati/UnitTest/DiskImage/Fat32/Fat32FilesystemTests.cs +++ b/Duplicati/UnitTest/DiskImage/Fat32/Fat32FilesystemTests.cs @@ -9,171 +9,171 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using Duplicati.Proprietary.DiskImage.Filesystem; +using Duplicati.Proprietary.DiskImage.Filesystem.Fat32; using NUnit.Framework; using NUnit.Framework.Legacy; using Assert = NUnit.Framework.Legacy.ClassicAssert; #nullable enable -namespace Duplicati.UnitTest.DiskImage +namespace Duplicati.UnitTest.DiskImage.Fat32; + +/// +/// Unit tests for the class and related types. +/// +[TestFixture] +[Category("DiskImageUnit")] +public class Fat32FilesystemTests { - /// - /// Unit tests for the class and related types. - /// - [TestFixture] - [Category("DiskImageUnit")] - public class Fat32FilesystemTests + [Test] + public void Test_Fat32FilesystemMetadata_Properties() { - [Test] - public void Test_Fat32FilesystemMetadata_Properties() + var metadata = new Fat32FilesystemMetadata { - var metadata = new Fat32FilesystemMetadata - { - BlockSize = 1024 * 1024, - ClusterSize = 4096, - TotalClusters = 1000, - AllocatedClusters = 500, - FreeClusters = 500 - }; + BlockSize = 1024 * 1024, + ClusterSize = 4096, + TotalClusters = 1000, + AllocatedClusters = 500, + FreeClusters = 500 + }; - 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 * 1024, metadata.BlockSize); + Assert.AreEqual(4096, metadata.ClusterSize); + Assert.AreEqual(1000, metadata.TotalClusters); + Assert.AreEqual(500, metadata.AllocatedClusters); + Assert.AreEqual(500, metadata.FreeClusters); + } - [Test] - public void Test_Fat32FilesystemMetadata_RecordEquality() + [Test] + public void Test_Fat32FilesystemMetadata_RecordEquality() + { + var metadata1 = new Fat32FilesystemMetadata { - var metadata1 = new Fat32FilesystemMetadata - { - BlockSize = 1024 * 1024, - ClusterSize = 4096, - TotalClusters = 1000, - AllocatedClusters = 500, - FreeClusters = 500 - }; + BlockSize = 1024 * 1024, + ClusterSize = 4096, + TotalClusters = 1000, + AllocatedClusters = 500, + FreeClusters = 500 + }; - var metadata2 = new Fat32FilesystemMetadata - { - BlockSize = 1024 * 1024, - ClusterSize = 4096, - TotalClusters = 1000, - AllocatedClusters = 500, - FreeClusters = 500 - }; - - var metadata3 = new Fat32FilesystemMetadata - { - BlockSize = 512 * 1024, // Different - ClusterSize = 4096, - TotalClusters = 1000, - AllocatedClusters = 500, - FreeClusters = 500 - }; - - Assert.AreEqual(metadata1, metadata2); - Assert.AreNotEqual(metadata1, metadata3); - } - - [Test] - public void Test_Fat32File_DefaultValues() + var metadata2 = new Fat32FilesystemMetadata { - var file = new Fat32File(); + BlockSize = 1024 * 1024, + ClusterSize = 4096, + TotalClusters = 1000, + AllocatedClusters = 500, + FreeClusters = 500 + }; - 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_Fat32File_WithValues() + var metadata3 = new Fat32FilesystemMetadata { - var timestamp = new DateTime(2023, 6, 15, 10, 30, 0, DateTimeKind.Utc); - var file = new Fat32File - { - Path = "test_block", - Address = 1024, - Size = 1024 * 1024, - LastModified = timestamp, - IsAllocated = true - }; + BlockSize = 512 * 1024, // Different + ClusterSize = 4096, + TotalClusters = 1000, + AllocatedClusters = 500, + FreeClusters = 500 + }; - 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); - } + Assert.AreEqual(metadata1, metadata2); + Assert.AreNotEqual(metadata1, metadata3); + } - [Test] - public void Test_Fat32File_ImplementsIFile() + [Test] + public void Test_Fat32File_DefaultValues() + { + var file = new Fat32File(); + + 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_Fat32File_WithValues() + { + var timestamp = new DateTime(2023, 6, 15, 10, 30, 0, DateTimeKind.Utc); + var file = new Fat32File { - var file = new Fat32File(); - Assert.IsInstanceOf(file); - } + Path = "test_block", + Address = 1024, + Size = 1024 * 1024, + LastModified = timestamp, + IsAllocated = true + }; - [Test] - public void Test_Fat32File_LastModified_Nullable() - { - var file1 = new Fat32File { LastModified = DateTime.UtcNow }; - var file2 = new Fat32File { LastModified = null }; + 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); + } - Assert.IsTrue(file1.LastModified.HasValue); - Assert.IsFalse(file2.LastModified.HasValue); - } + [Test] + public void Test_Fat32File_ImplementsIFile() + { + var file = new Fat32File(); + Assert.IsInstanceOf(file); + } - [Test] - public void Test_Fat32File_IsAllocated_FalseByDefault() - { - var file = new Fat32File(); - Assert.IsFalse(file.IsAllocated); - } + [Test] + public void Test_Fat32File_LastModified_Nullable() + { + var file1 = new Fat32File { LastModified = DateTime.UtcNow }; + var file2 = new Fat32File { LastModified = null }; - [Test] - public void Test_Fat32File_IsAllocated_CanBeTrue() - { - var file = new Fat32File { IsAllocated = true }; - Assert.IsTrue(file.IsAllocated); - } + Assert.IsTrue(file1.LastModified.HasValue); + Assert.IsFalse(file2.LastModified.HasValue); + } - [Test] - public void Test_Fat32File_Address_LongNullable() - { - var file1 = new Fat32File { Address = 0 }; - var file2 = new Fat32File { Address = long.MaxValue }; - var file3 = new Fat32File { Address = null }; + [Test] + public void Test_Fat32File_IsAllocated_FalseByDefault() + { + var file = new Fat32File(); + Assert.IsFalse(file.IsAllocated); + } - Assert.AreEqual(0, file1.Address); - Assert.AreEqual(long.MaxValue, file2.Address); - Assert.IsNull(file3.Address); - } + [Test] + public void Test_Fat32File_IsAllocated_CanBeTrue() + { + var file = new Fat32File { IsAllocated = true }; + Assert.IsTrue(file.IsAllocated); + } - [Test] - public void Test_Fat32File_Size_Long() - { - var file1 = new Fat32File { Size = 0 }; - var file2 = new Fat32File { Size = 1024 * 1024 }; - var file3 = new Fat32File { Size = long.MaxValue }; + [Test] + public void Test_Fat32File_Address_LongNullable() + { + var file1 = new Fat32File { Address = 0 }; + var file2 = new Fat32File { Address = long.MaxValue }; + var file3 = new Fat32File { Address = null }; - Assert.AreEqual(0, file1.Size); - Assert.AreEqual(1024 * 1024, file2.Size); - Assert.AreEqual(long.MaxValue, file3.Size); - } + Assert.AreEqual(0, file1.Address); + Assert.AreEqual(long.MaxValue, file2.Address); + Assert.IsNull(file3.Address); + } - [Test] - public void Test_Fat32File_Path_CanBeNull() - { - var file1 = new Fat32File { Path = null }; - var file2 = new Fat32File { Path = "test/path" }; + [Test] + public void Test_Fat32File_Size_Long() + { + var file1 = new Fat32File { Size = 0 }; + var file2 = new Fat32File { Size = 1024 * 1024 }; + var file3 = new Fat32File { Size = long.MaxValue }; - Assert.IsNull(file1.Path); - Assert.AreEqual("test/path", file2.Path); - } + Assert.AreEqual(0, file1.Size); + Assert.AreEqual(1024 * 1024, file2.Size); + Assert.AreEqual(long.MaxValue, file3.Size); + } + + [Test] + public void Test_Fat32File_Path_CanBeNull() + { + var file1 = new Fat32File { Path = null }; + var file2 = new Fat32File { Path = "test/path" }; + + Assert.IsNull(file1.Path); + Assert.AreEqual("test/path", file2.Path); } } diff --git a/Duplicati/UnitTest/DiskImage/Fat32/Fat32TableTests.cs b/Duplicati/UnitTest/DiskImage/Fat32/Fat32TableTests.cs index 20d73325f..5e15d88f9 100644 --- a/Duplicati/UnitTest/DiskImage/Fat32/Fat32TableTests.cs +++ b/Duplicati/UnitTest/DiskImage/Fat32/Fat32TableTests.cs @@ -3,349 +3,348 @@ using System; using System.Buffers.Binary; using System.Collections.Generic; -using Duplicati.Proprietary.DiskImage.Filesystem; +using Duplicati.Proprietary.DiskImage.Filesystem.Fat32; using NUnit.Framework; using NUnit.Framework.Legacy; using Assert = NUnit.Framework.Legacy.ClassicAssert; #nullable enable -namespace Duplicati.UnitTest.DiskImage +namespace Duplicati.UnitTest.DiskImage.Fat32; + +/// +/// Unit tests for the FAT table reader. +/// +[TestFixture] +[Category("DiskImageUnit")] +public class Fat32TableTests { /// - /// Unit tests for the FAT table reader. + /// Creates a valid FAT32 boot sector with known values for testing. /// - [TestFixture] - [Category("DiskImageUnit")] - public class Fat32TableTests + private static byte[] CreateValidBootSector() { - /// - /// Creates a valid FAT32 boot sector with known values for testing. - /// - private static byte[] CreateValidBootSector() + var bootSector = new byte[512]; + + // 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; + + // Reserved sector count at offset 0x0E (32 sectors) + BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0E, 2), 32); + + // Number of FATs at offset 0x10 (2 FATs) + bootSector[0x10] = 2; + + // Total sectors 32 at offset 0x20 (1,000,000 sectors = ~512MB) + BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x20, 4), 1_000_000); + + // FAT size 32 at offset 0x24 (244 sectors per FAT) + BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x24, 4), 244); + + // Root cluster at offset 0x2C (cluster 2) + BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x2C, 4), 2); + + // FSInfo sector at offset 0x30 (sector 1) + BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x30, 2), 1); + + // Filesystem type string at offset 0x52 (8 bytes: "FAT32 ") + var fsType = System.Text.Encoding.ASCII.GetBytes("FAT32 "); + fsType.CopyTo(bootSector, 0x52); + + // 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; + } + + /// + /// Creates FAT data with specified entries. + /// + private static byte[] CreateFatData(params uint[] entries) + { + var fatData = new byte[entries.Length * 4]; + for (int i = 0; i < entries.Length; i++) { - var bootSector = new byte[512]; - - // 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; - - // Reserved sector count at offset 0x0E (32 sectors) - BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x0E, 2), 32); - - // Number of FATs at offset 0x10 (2 FATs) - bootSector[0x10] = 2; - - // Total sectors 32 at offset 0x20 (1,000,000 sectors = ~512MB) - BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x20, 4), 1_000_000); - - // FAT size 32 at offset 0x24 (244 sectors per FAT) - BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x24, 4), 244); - - // Root cluster at offset 0x2C (cluster 2) - BinaryPrimitives.WriteUInt32LittleEndian(bootSector.AsSpan(0x2C, 4), 2); - - // FSInfo sector at offset 0x30 (sector 1) - BinaryPrimitives.WriteUInt16LittleEndian(bootSector.AsSpan(0x30, 2), 1); - - // Filesystem type string at offset 0x52 (8 bytes: "FAT32 ") - var fsType = System.Text.Encoding.ASCII.GetBytes("FAT32 "); - fsType.CopyTo(bootSector, 0x52); - - // 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; + BinaryPrimitives.WriteUInt32LittleEndian(fatData.AsSpan(i * 4, 4), entries[i]); } + return fatData; + } - /// - /// Creates FAT data with specified entries. - /// - private static byte[] CreateFatData(params uint[] entries) + [Test] + public void Test_Fat32Table_FreeCluster_NotAllocated() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT with cluster 2 as free + var fatData = CreateFatData(0, 0, Fat32Table.FREE_CLUSTER); + var fatTable = new Fat32Table(bootSector, fatData); + + Assert.IsFalse(fatTable.IsClusterAllocated(2), "Free cluster should not be allocated"); + } + + [Test] + public void Test_Fat32Table_AllocatedCluster_IsAllocated() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT with cluster 2 pointing to cluster 3 (allocated) + var fatData = CreateFatData(0, 0, 3, Fat32Table.EOC_MIN); + var fatTable = new Fat32Table(bootSector, fatData); + + Assert.IsTrue(fatTable.IsClusterAllocated(2), "Cluster with valid next-cluster value should be allocated"); + Assert.IsTrue(fatTable.IsClusterAllocated(3), "Cluster with EOC should be allocated"); + } + + [Test] + public void Test_Fat32Table_EndOfChain_IsAllocated() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Test all EOC values (0x0FFFFFF8 - 0x0FFFFFFF) + for (uint eocValue = Fat32Table.EOC_MIN; eocValue <= Fat32Table.EOC_MAX; eocValue++) { - var fatData = new byte[entries.Length * 4]; - for (int i = 0; i < entries.Length; i++) - { - BinaryPrimitives.WriteUInt32LittleEndian(fatData.AsSpan(i * 4, 4), entries[i]); - } - return fatData; - } - - [Test] - public void Test_Fat32Table_FreeCluster_NotAllocated() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - // Create FAT with cluster 2 as free - var fatData = CreateFatData(0, 0, Fat32Table.FREE_CLUSTER); + var fatData = CreateFatData(0, 0, eocValue); var fatTable = new Fat32Table(bootSector, fatData); - Assert.IsFalse(fatTable.IsClusterAllocated(2), "Free cluster should not be allocated"); + Assert.IsTrue(fatTable.IsClusterAllocated(2), $"Cluster with EOC value 0x{eocValue:X8} should be allocated"); } + } - [Test] - public void Test_Fat32Table_AllocatedCluster_IsAllocated() + [Test] + public void Test_Fat32Table_BadCluster_NotAllocated() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT with cluster 2 as bad + var fatData = CreateFatData(0, 0, Fat32Table.BAD_CLUSTER); + var fatTable = new Fat32Table(bootSector, fatData); + + Assert.IsFalse(fatTable.IsClusterAllocated(2), "Bad cluster should not be allocated"); + } + + [Test] + public void Test_Fat32Table_GetClusterChain_SingleCluster() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT with cluster 2 having EOC immediately + var fatData = CreateFatData(0, 0, Fat32Table.EOC_MIN); + var fatTable = new Fat32Table(bootSector, fatData); + + var chain = fatTable.GetClusterChain(2); + + Assert.AreEqual(1, chain.Count, "Single cluster chain should have 1 entry"); + Assert.AreEqual(2u, chain[0], "Chain should contain the start cluster"); + } + + [Test] + public void Test_Fat32Table_GetClusterChain_MultipleClusters() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT with chain: 2 -> 3 -> 4 -> EOC + var fatData = CreateFatData(0, 0, 3, 4, Fat32Table.EOC_MIN); + var fatTable = new Fat32Table(bootSector, fatData); + + var chain = fatTable.GetClusterChain(2); + + Assert.AreEqual(3, chain.Count, "Chain should have 3 entries"); + Assert.AreEqual(2u, chain[0], "First entry should be cluster 2"); + Assert.AreEqual(3u, chain[1], "Second entry should be cluster 3"); + Assert.AreEqual(4u, chain[2], "Third entry should be cluster 4"); + } + + [Test] + public void Test_Fat32Table_GetClusterChain_CircularDetection() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT with circular chain: 2 -> 3 -> 2 (circular) + var fatData = CreateFatData(0, 0, 3, 2); + var fatTable = new Fat32Table(bootSector, fatData); + + var ex = Assert.Throws(() => fatTable.GetClusterChain(2)); + StringAssert.Contains("Circular", ex!.Message); + } + + [Test] + public void Test_Fat32Table_AllocationBitmap_MatchesFatEntries() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT with mixed entries: + // 0: reserved (typically EOC or 0) + // 1: reserved (typically EOC or 0) + // 2: free + // 3: allocated (points to 4) + // 4: EOC (end of chain) + // 5: bad cluster + var fatData = CreateFatData( + Fat32Table.FREE_CLUSTER, // cluster 0 + Fat32Table.FREE_CLUSTER, // cluster 1 + Fat32Table.FREE_CLUSTER, // cluster 2 (free) + 4, // cluster 3 (allocated, points to 4) + Fat32Table.EOC_MIN, // cluster 4 (end of chain) + Fat32Table.BAD_CLUSTER // cluster 5 (bad) + ); + var fatTable = new Fat32Table(bootSector, fatData); + + // Reserved clusters 0 and 1 - treated as free (not allocated) + Assert.IsFalse(fatTable.IsClusterAllocated(0), "Cluster 0 should not be allocated"); + Assert.IsFalse(fatTable.IsClusterAllocated(1), "Cluster 1 should not be allocated"); + + // Cluster 2 is free + Assert.IsFalse(fatTable.IsClusterAllocated(2), "Cluster 2 (free) should not be allocated"); + + // Cluster 3 is allocated (points to next cluster) + Assert.IsTrue(fatTable.IsClusterAllocated(3), "Cluster 3 (allocated) should be allocated"); + + // Cluster 4 is EOC + Assert.IsTrue(fatTable.IsClusterAllocated(4), "Cluster 4 (EOC) should be allocated"); + + // Cluster 5 is bad + Assert.IsFalse(fatTable.IsClusterAllocated(5), "Cluster 5 (bad) should not be allocated"); + + // Verify counts + Assert.AreEqual(6u, fatTable.TotalClusters, "Total clusters should be 6"); + Assert.AreEqual(2u, fatTable.AllocatedClusters, "Allocated clusters should be 2 (clusters 3 and 4)"); + Assert.AreEqual(4u, fatTable.FreeClusters, "Free clusters should be 4 (clusters 0, 1, 2, and 5 is counted as free since it's bad)"); + } + + [Test] + public void Test_Fat32Table_EntryMask_IgnoresUpperBits() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT with cluster 2 having upper 4 bits set (should be masked) + // 0xF0000003 with mask 0x0FFFFFFF = 0x00000003 (points to cluster 3) + uint entryWithUpperBits = 0xF0000003; + var fatData = CreateFatData(0, 0, entryWithUpperBits, Fat32Table.EOC_MIN); + var fatTable = new Fat32Table(bootSector, fatData); + + var nextCluster = fatTable.GetNextCluster(2); + + Assert.AreEqual(3u, nextCluster, "Upper 4 bits should be masked, leaving value 3"); + Assert.IsTrue(fatTable.IsClusterAllocated(2), "Cluster should be allocated even with upper bits set"); + } + + [Test] + public void Test_Fat32Table_GetNextCluster_ReturnsMaskedValue() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT where cluster 2 points to cluster 5 (0x00000005) + var fatData = CreateFatData(0, 0, 5, Fat32Table.EOC_MIN, Fat32Table.EOC_MIN, Fat32Table.EOC_MIN); + var fatTable = new Fat32Table(bootSector, fatData); + + var nextCluster = fatTable.GetNextCluster(2); + + Assert.AreEqual(5u, nextCluster, "GetNextCluster should return the next cluster number"); + } + + [Test] + public void Test_Fat32Table_ClusterOutOfRange_Throws() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + var fatData = CreateFatData(0, 0, Fat32Table.EOC_MIN); + var fatTable = new Fat32Table(bootSector, fatData); + + // Try to access cluster beyond the FAT size + var ex = Assert.Throws(() => fatTable.IsClusterAllocated(100)); + StringAssert.Contains("out of range", ex!.Message); + } + + [Test] + public void Test_Fat32Table_GetClusterChain_StartClusterOutOfRange_Throws() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + var fatData = CreateFatData(0, 0, Fat32Table.EOC_MIN); + var fatTable = new Fat32Table(bootSector, fatData); + + var ex = Assert.Throws(() => fatTable.GetClusterChain(100)); + StringAssert.Contains("out of range", ex!.Message); + } + + [Test] + public void Test_Fat32Table_GetClusterChain_InvalidClusterInChain_Throws() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT where cluster 2 points to cluster 3 which is free (invalid in a chain) + var fatData = CreateFatData(0, 0, 3, Fat32Table.FREE_CLUSTER); + var fatTable = new Fat32Table(bootSector, fatData); + + var ex = Assert.Throws(() => fatTable.GetClusterChain(2)); + StringAssert.Contains("Invalid cluster", ex!.Message); + } + + [Test] + public void Test_Fat32Table_Constructor_NullFatData_Throws() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + var ex = Assert.Throws(() => new Fat32Table(bootSector, null!)); + Assert.AreEqual("fatData", ex!.ParamName); + } + + [Test] + public void Test_Fat32Table_EmptyClusterChain() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create FAT with cluster 0 having EOC + var fatData = CreateFatData(Fat32Table.EOC_MIN); + var fatTable = new Fat32Table(bootSector, fatData); + + var chain = fatTable.GetClusterChain(0); + + Assert.AreEqual(1, chain.Count, "Single cluster chain should have 1 entry"); + Assert.AreEqual(0u, chain[0], "Chain should contain cluster 0"); + } + + [Test] + public void Test_Fat32Table_LongClusterChain() + { + var bootSectorData = CreateValidBootSector(); + var bootSector = new Fat32BootSector(bootSectorData); + + // Create a chain of 10 clusters: 2 -> 3 -> 4 -> ... -> 11 -> EOC + var entries = new List { 0, 0 }; // clusters 0 and 1 + for (uint i = 2; i <= 10; i++) { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - // Create FAT with cluster 2 pointing to cluster 3 (allocated) - var fatData = CreateFatData(0, 0, 3, Fat32Table.EOC_MIN); - var fatTable = new Fat32Table(bootSector, fatData); - - Assert.IsTrue(fatTable.IsClusterAllocated(2), "Cluster with valid next-cluster value should be allocated"); - Assert.IsTrue(fatTable.IsClusterAllocated(3), "Cluster with EOC should be allocated"); + entries.Add(i + 1); // each cluster points to the next } + entries.Add(Fat32Table.EOC_MIN); // cluster 11 is EOC - [Test] - public void Test_Fat32Table_EndOfChain_IsAllocated() + var fatData = CreateFatData(entries.ToArray()); + var fatTable = new Fat32Table(bootSector, fatData); + + var chain = fatTable.GetClusterChain(2); + + Assert.AreEqual(10, chain.Count, "Chain should have 10 entries"); + for (int i = 0; i < 10; i++) { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - // Test all EOC values (0x0FFFFFF8 - 0x0FFFFFFF) - for (uint eocValue = Fat32Table.EOC_MIN; eocValue <= Fat32Table.EOC_MAX; eocValue++) - { - var fatData = CreateFatData(0, 0, eocValue); - var fatTable = new Fat32Table(bootSector, fatData); - - Assert.IsTrue(fatTable.IsClusterAllocated(2), $"Cluster with EOC value 0x{eocValue:X8} should be allocated"); - } - } - - [Test] - public void Test_Fat32Table_BadCluster_NotAllocated() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - // Create FAT with cluster 2 as bad - var fatData = CreateFatData(0, 0, Fat32Table.BAD_CLUSTER); - var fatTable = new Fat32Table(bootSector, fatData); - - Assert.IsFalse(fatTable.IsClusterAllocated(2), "Bad cluster should not be allocated"); - } - - [Test] - public void Test_Fat32Table_GetClusterChain_SingleCluster() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - // Create FAT with cluster 2 having EOC immediately - var fatData = CreateFatData(0, 0, Fat32Table.EOC_MIN); - var fatTable = new Fat32Table(bootSector, fatData); - - var chain = fatTable.GetClusterChain(2); - - Assert.AreEqual(1, chain.Count, "Single cluster chain should have 1 entry"); - Assert.AreEqual(2u, chain[0], "Chain should contain the start cluster"); - } - - [Test] - public void Test_Fat32Table_GetClusterChain_MultipleClusters() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - // Create FAT with chain: 2 -> 3 -> 4 -> EOC - var fatData = CreateFatData(0, 0, 3, 4, Fat32Table.EOC_MIN); - var fatTable = new Fat32Table(bootSector, fatData); - - var chain = fatTable.GetClusterChain(2); - - Assert.AreEqual(3, chain.Count, "Chain should have 3 entries"); - Assert.AreEqual(2u, chain[0], "First entry should be cluster 2"); - Assert.AreEqual(3u, chain[1], "Second entry should be cluster 3"); - Assert.AreEqual(4u, chain[2], "Third entry should be cluster 4"); - } - - [Test] - public void Test_Fat32Table_GetClusterChain_CircularDetection() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - // Create FAT with circular chain: 2 -> 3 -> 2 (circular) - var fatData = CreateFatData(0, 0, 3, 2); - var fatTable = new Fat32Table(bootSector, fatData); - - var ex = Assert.Throws(() => fatTable.GetClusterChain(2)); - StringAssert.Contains("Circular", ex!.Message); - } - - [Test] - public void Test_Fat32Table_AllocationBitmap_MatchesFatEntries() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - // Create FAT with mixed entries: - // 0: reserved (typically EOC or 0) - // 1: reserved (typically EOC or 0) - // 2: free - // 3: allocated (points to 4) - // 4: EOC (end of chain) - // 5: bad cluster - var fatData = CreateFatData( - Fat32Table.FREE_CLUSTER, // cluster 0 - Fat32Table.FREE_CLUSTER, // cluster 1 - Fat32Table.FREE_CLUSTER, // cluster 2 (free) - 4, // cluster 3 (allocated, points to 4) - Fat32Table.EOC_MIN, // cluster 4 (end of chain) - Fat32Table.BAD_CLUSTER // cluster 5 (bad) - ); - var fatTable = new Fat32Table(bootSector, fatData); - - // Reserved clusters 0 and 1 - treated as free (not allocated) - Assert.IsFalse(fatTable.IsClusterAllocated(0), "Cluster 0 should not be allocated"); - Assert.IsFalse(fatTable.IsClusterAllocated(1), "Cluster 1 should not be allocated"); - - // Cluster 2 is free - Assert.IsFalse(fatTable.IsClusterAllocated(2), "Cluster 2 (free) should not be allocated"); - - // Cluster 3 is allocated (points to next cluster) - Assert.IsTrue(fatTable.IsClusterAllocated(3), "Cluster 3 (allocated) should be allocated"); - - // Cluster 4 is EOC - Assert.IsTrue(fatTable.IsClusterAllocated(4), "Cluster 4 (EOC) should be allocated"); - - // Cluster 5 is bad - Assert.IsFalse(fatTable.IsClusterAllocated(5), "Cluster 5 (bad) should not be allocated"); - - // Verify counts - Assert.AreEqual(6u, fatTable.TotalClusters, "Total clusters should be 6"); - Assert.AreEqual(2u, fatTable.AllocatedClusters, "Allocated clusters should be 2 (clusters 3 and 4)"); - Assert.AreEqual(4u, fatTable.FreeClusters, "Free clusters should be 4 (clusters 0, 1, 2, and 5 is counted as free since it's bad)"); - } - - [Test] - public void Test_Fat32Table_EntryMask_IgnoresUpperBits() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - // Create FAT with cluster 2 having upper 4 bits set (should be masked) - // 0xF0000003 with mask 0x0FFFFFFF = 0x00000003 (points to cluster 3) - uint entryWithUpperBits = 0xF0000003; - var fatData = CreateFatData(0, 0, entryWithUpperBits, Fat32Table.EOC_MIN); - var fatTable = new Fat32Table(bootSector, fatData); - - var nextCluster = fatTable.GetNextCluster(2); - - Assert.AreEqual(3u, nextCluster, "Upper 4 bits should be masked, leaving value 3"); - Assert.IsTrue(fatTable.IsClusterAllocated(2), "Cluster should be allocated even with upper bits set"); - } - - [Test] - public void Test_Fat32Table_GetNextCluster_ReturnsMaskedValue() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - // Create FAT where cluster 2 points to cluster 5 (0x00000005) - var fatData = CreateFatData(0, 0, 5, Fat32Table.EOC_MIN, Fat32Table.EOC_MIN, Fat32Table.EOC_MIN); - var fatTable = new Fat32Table(bootSector, fatData); - - var nextCluster = fatTable.GetNextCluster(2); - - Assert.AreEqual(5u, nextCluster, "GetNextCluster should return the next cluster number"); - } - - [Test] - public void Test_Fat32Table_ClusterOutOfRange_Throws() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - var fatData = CreateFatData(0, 0, Fat32Table.EOC_MIN); - var fatTable = new Fat32Table(bootSector, fatData); - - // Try to access cluster beyond the FAT size - var ex = Assert.Throws(() => fatTable.IsClusterAllocated(100)); - StringAssert.Contains("out of range", ex!.Message); - } - - [Test] - public void Test_Fat32Table_GetClusterChain_StartClusterOutOfRange_Throws() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - var fatData = CreateFatData(0, 0, Fat32Table.EOC_MIN); - var fatTable = new Fat32Table(bootSector, fatData); - - var ex = Assert.Throws(() => fatTable.GetClusterChain(100)); - StringAssert.Contains("out of range", ex!.Message); - } - - [Test] - public void Test_Fat32Table_GetClusterChain_InvalidClusterInChain_Throws() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - // Create FAT where cluster 2 points to cluster 3 which is free (invalid in a chain) - var fatData = CreateFatData(0, 0, 3, Fat32Table.FREE_CLUSTER); - var fatTable = new Fat32Table(bootSector, fatData); - - var ex = Assert.Throws(() => fatTable.GetClusterChain(2)); - StringAssert.Contains("Invalid cluster", ex!.Message); - } - - [Test] - public void Test_Fat32Table_Constructor_NullFatData_Throws() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - var ex = Assert.Throws(() => new Fat32Table(bootSector, null!)); - Assert.AreEqual("fatData", ex!.ParamName); - } - - [Test] - public void Test_Fat32Table_EmptyClusterChain() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - // Create FAT with cluster 0 having EOC - var fatData = CreateFatData(Fat32Table.EOC_MIN); - var fatTable = new Fat32Table(bootSector, fatData); - - var chain = fatTable.GetClusterChain(0); - - Assert.AreEqual(1, chain.Count, "Single cluster chain should have 1 entry"); - Assert.AreEqual(0u, chain[0], "Chain should contain cluster 0"); - } - - [Test] - public void Test_Fat32Table_LongClusterChain() - { - var bootSectorData = CreateValidBootSector(); - var bootSector = new Fat32BootSector(bootSectorData); - - // Create a chain of 10 clusters: 2 -> 3 -> 4 -> ... -> 11 -> EOC - var entries = new List { 0, 0 }; // clusters 0 and 1 - for (uint i = 2; i <= 10; i++) - { - entries.Add(i + 1); // each cluster points to the next - } - entries.Add(Fat32Table.EOC_MIN); // cluster 11 is EOC - - var fatData = CreateFatData(entries.ToArray()); - var fatTable = new Fat32Table(bootSector, fatData); - - var chain = fatTable.GetClusterChain(2); - - Assert.AreEqual(10, chain.Count, "Chain should have 10 entries"); - for (int i = 0; i < 10; i++) - { - Assert.AreEqual((uint)(i + 2), chain[i], $"Chain entry {i} should be cluster {i + 2}"); - } + Assert.AreEqual((uint)(i + 2), chain[i], $"Chain entry {i} should be cluster {i + 2}"); } } } diff --git a/Duplicati/UnitTest/DiskImage/Fat32/Fat32ZeroStreamTests.cs b/Duplicati/UnitTest/DiskImage/Fat32/Fat32ZeroStreamTests.cs index 465f19dfa..1af89407a 100644 --- a/Duplicati/UnitTest/DiskImage/Fat32/Fat32ZeroStreamTests.cs +++ b/Duplicati/UnitTest/DiskImage/Fat32/Fat32ZeroStreamTests.cs @@ -3,262 +3,261 @@ using System; using System.IO; using System.Threading.Tasks; -using Duplicati.Proprietary.DiskImage.Filesystem; +using Duplicati.Proprietary.DiskImage.Filesystem.Fat32; using NUnit.Framework; using NUnit.Framework.Legacy; using Assert = NUnit.Framework.Legacy.ClassicAssert; #nullable enable -namespace Duplicati.UnitTest.DiskImage +namespace Duplicati.UnitTest.DiskImage.Fat32; + +/// +/// Unit tests for the class. +/// +[TestFixture] +[Category("DiskImageUnit")] +public class Fat32ZeroStreamTests { - /// - /// Unit tests for the class. - /// - [TestFixture] - [Category("DiskImageUnit")] - public class Fat32ZeroStreamTests + [Test] + public void Test_Fat32ZeroStream_Read_ReturnsZeros() { - [Test] - public void Test_Fat32ZeroStream_Read_ReturnsZeros() + const int blockSize = 1024 * 1024; // 1MB + using var stream = new Fat32Filesystem.Fat32ZeroStream(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++) { - const int blockSize = 1024 * 1024; // 1MB - using var stream = new Fat32Filesystem.Fat32ZeroStream(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_Fat32ZeroStream_ReadAsync_ReturnsZeros() - { - const int blockSize = 1024 * 1024; // 1MB - using var stream = new Fat32Filesystem.Fat32ZeroStream(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_Fat32ZeroStream_Length_MatchesBlockSize() - { - const int blockSize = 1024 * 1024; // 1MB - using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); - - Assert.AreEqual(blockSize, stream.Length, "Stream length should match block size"); - } - - [Test] - public void Test_Fat32ZeroStream_Seek_Works() - { - const int blockSize = 1024 * 1024; // 1MB - using var stream = new Fat32Filesystem.Fat32ZeroStream(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_Fat32ZeroStream_Write_ThrowsNotSupported() - { - const int blockSize = 1024 * 1024; // 1MB - using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); - - var buffer = new byte[1024]; - Assert.Throws(() => stream.Write(buffer, 0, buffer.Length)); - } - - [Test] - public void Test_Fat32ZeroStream_MultipleInstances_ShareBuffer() - { - const int blockSize = 1024 * 1024; // 1MB - - // Create two streams with the same block size - using var stream1 = new Fat32Filesystem.Fat32ZeroStream(blockSize); - using var stream2 = new Fat32Filesystem.Fat32ZeroStream(blockSize); - - // Both should read zeros - var buffer1 = new byte[1024]; - var buffer2 = new byte[1024]; - - stream1.Read(buffer1, 0, buffer1.Length); - stream2.Read(buffer2, 0, buffer2.Length); - - // Verify both have zeros - for (int i = 0; i < buffer1.Length; i++) - { - Assert.AreEqual(0, buffer1[i], $"Buffer1 byte at position {i} should be zero"); - Assert.AreEqual(0, buffer2[i], $"Buffer2 byte at position {i} should be zero"); - } - } - - [Test] - public void Test_Fat32ZeroStream_ReadPartial_ReturnsCorrectCount() - { - const int blockSize = 1024 * 1024; // 1MB - using var stream = new Fat32Filesystem.Fat32ZeroStream(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_Fat32ZeroStream_ReadPastEnd_ReturnsZero() - { - const int blockSize = 1024 * 1024; // 1MB - using var stream = new Fat32Filesystem.Fat32ZeroStream(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_Fat32ZeroStream_CanRead_CanWrite_CanSeek() - { - const int blockSize = 1024 * 1024; // 1MB - using var stream = new Fat32Filesystem.Fat32ZeroStream(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_Fat32ZeroStream_InvalidBlockSize_Throws() - { - Assert.Throws(() => new Fat32Filesystem.Fat32ZeroStream(0)); - Assert.Throws(() => new Fat32Filesystem.Fat32ZeroStream(-1)); - } - - [Test] - public void Test_Fat32ZeroStream_SetLength_ThrowsNotSupported() - { - const int blockSize = 1024 * 1024; // 1MB - using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); - - Assert.Throws(() => stream.SetLength(1024)); - } - - [Test] - public void Test_Fat32ZeroStream_Flush_DoesNotThrow() - { - const int blockSize = 1024 * 1024; // 1MB - using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); - - Assert.DoesNotThrow(() => stream.Flush()); - } - - [Test] - public void Test_Fat32ZeroStream_Position_Property() - { - const int blockSize = 1024 * 1024; // 1MB - using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); - - Assert.AreEqual(0, stream.Position, "Initial position should be 0"); - - stream.Position = 1024; - Assert.AreEqual(1024, stream.Position, "Position should be settable"); - } - - [Test] - public void Test_Fat32ZeroStream_Position_InvalidValue_Throws() - { - const int blockSize = 1024 * 1024; // 1MB - using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); - - Assert.Throws(() => stream.Position = -1); - Assert.Throws(() => stream.Position = blockSize + 1); - } - - [Test] - public void Test_Fat32ZeroStream_Seek_InvalidOrigin_Throws() - { - const int blockSize = 1024 * 1024; // 1MB - using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); - - Assert.Throws(() => stream.Seek(0, (SeekOrigin)999)); - } - - [Test] - public void Test_Fat32ZeroStream_Seek_OutOfRange_Throws() - { - const int blockSize = 1024 * 1024; // 1MB - using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); - - Assert.Throws(() => stream.Seek(-1, SeekOrigin.Begin)); - Assert.Throws(() => stream.Seek(blockSize + 1, SeekOrigin.Begin)); - } - - [Test] - public void Test_Fat32ZeroStream_Read_NullBuffer_Throws() - { - const int blockSize = 1024 * 1024; // 1MB - using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); - -#pragma warning disable CS8600, CS8625 - Assert.Throws(() => stream.Read(null, 0, 1024)); -#pragma warning restore CS8600, CS8625 - } - - [Test] - public void Test_Fat32ZeroStream_Read_InvalidOffset_Throws() - { - const int blockSize = 1024 * 1024; // 1MB - using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); - - var buffer = new byte[1024]; - Assert.Throws(() => stream.Read(buffer, -1, 1024)); - Assert.Throws(() => stream.Read(buffer, buffer.Length + 1, 1024)); - } - - [Test] - public void Test_Fat32ZeroStream_Read_InvalidCount_Throws() - { - const int blockSize = 1024 * 1024; // 1MB - using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); - - var buffer = new byte[1024]; - Assert.Throws(() => stream.Read(buffer, 0, -1)); - Assert.Throws(() => stream.Read(buffer, 512, 1024)); // Exceeds buffer length + Assert.AreEqual(0, buffer[i], $"Byte at position {i} should be zero"); } } + + [Test] + public async Task Test_Fat32ZeroStream_ReadAsync_ReturnsZeros() + { + const int blockSize = 1024 * 1024; // 1MB + using var stream = new Fat32Filesystem.Fat32ZeroStream(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_Fat32ZeroStream_Length_MatchesBlockSize() + { + const int blockSize = 1024 * 1024; // 1MB + using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); + + Assert.AreEqual(blockSize, stream.Length, "Stream length should match block size"); + } + + [Test] + public void Test_Fat32ZeroStream_Seek_Works() + { + const int blockSize = 1024 * 1024; // 1MB + using var stream = new Fat32Filesystem.Fat32ZeroStream(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_Fat32ZeroStream_Write_ThrowsNotSupported() + { + const int blockSize = 1024 * 1024; // 1MB + using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); + + var buffer = new byte[1024]; + Assert.Throws(() => stream.Write(buffer, 0, buffer.Length)); + } + + [Test] + public void Test_Fat32ZeroStream_MultipleInstances_ShareBuffer() + { + const int blockSize = 1024 * 1024; // 1MB + + // Create two streams with the same block size + using var stream1 = new Fat32Filesystem.Fat32ZeroStream(blockSize); + using var stream2 = new Fat32Filesystem.Fat32ZeroStream(blockSize); + + // Both should read zeros + var buffer1 = new byte[1024]; + var buffer2 = new byte[1024]; + + stream1.Read(buffer1, 0, buffer1.Length); + stream2.Read(buffer2, 0, buffer2.Length); + + // Verify both have zeros + for (int i = 0; i < buffer1.Length; i++) + { + Assert.AreEqual(0, buffer1[i], $"Buffer1 byte at position {i} should be zero"); + Assert.AreEqual(0, buffer2[i], $"Buffer2 byte at position {i} should be zero"); + } + } + + [Test] + public void Test_Fat32ZeroStream_ReadPartial_ReturnsCorrectCount() + { + const int blockSize = 1024 * 1024; // 1MB + using var stream = new Fat32Filesystem.Fat32ZeroStream(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_Fat32ZeroStream_ReadPastEnd_ReturnsZero() + { + const int blockSize = 1024 * 1024; // 1MB + using var stream = new Fat32Filesystem.Fat32ZeroStream(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_Fat32ZeroStream_CanRead_CanWrite_CanSeek() + { + const int blockSize = 1024 * 1024; // 1MB + using var stream = new Fat32Filesystem.Fat32ZeroStream(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_Fat32ZeroStream_InvalidBlockSize_Throws() + { + Assert.Throws(() => new Fat32Filesystem.Fat32ZeroStream(0)); + Assert.Throws(() => new Fat32Filesystem.Fat32ZeroStream(-1)); + } + + [Test] + public void Test_Fat32ZeroStream_SetLength_ThrowsNotSupported() + { + const int blockSize = 1024 * 1024; // 1MB + using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); + + Assert.Throws(() => stream.SetLength(1024)); + } + + [Test] + public void Test_Fat32ZeroStream_Flush_DoesNotThrow() + { + const int blockSize = 1024 * 1024; // 1MB + using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); + + Assert.DoesNotThrow(() => stream.Flush()); + } + + [Test] + public void Test_Fat32ZeroStream_Position_Property() + { + const int blockSize = 1024 * 1024; // 1MB + using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); + + Assert.AreEqual(0, stream.Position, "Initial position should be 0"); + + stream.Position = 1024; + Assert.AreEqual(1024, stream.Position, "Position should be settable"); + } + + [Test] + public void Test_Fat32ZeroStream_Position_InvalidValue_Throws() + { + const int blockSize = 1024 * 1024; // 1MB + using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); + + Assert.Throws(() => stream.Position = -1); + Assert.Throws(() => stream.Position = blockSize + 1); + } + + [Test] + public void Test_Fat32ZeroStream_Seek_InvalidOrigin_Throws() + { + const int blockSize = 1024 * 1024; // 1MB + using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); + + Assert.Throws(() => stream.Seek(0, (SeekOrigin)999)); + } + + [Test] + public void Test_Fat32ZeroStream_Seek_OutOfRange_Throws() + { + const int blockSize = 1024 * 1024; // 1MB + using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); + + Assert.Throws(() => stream.Seek(-1, SeekOrigin.Begin)); + Assert.Throws(() => stream.Seek(blockSize + 1, SeekOrigin.Begin)); + } + + [Test] + public void Test_Fat32ZeroStream_Read_NullBuffer_Throws() + { + const int blockSize = 1024 * 1024; // 1MB + using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); + +#pragma warning disable CS8600, CS8625 + Assert.Throws(() => stream.Read(null, 0, 1024)); +#pragma warning restore CS8600, CS8625 + } + + [Test] + public void Test_Fat32ZeroStream_Read_InvalidOffset_Throws() + { + const int blockSize = 1024 * 1024; // 1MB + using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); + + var buffer = new byte[1024]; + Assert.Throws(() => stream.Read(buffer, -1, 1024)); + Assert.Throws(() => stream.Read(buffer, buffer.Length + 1, 1024)); + } + + [Test] + public void Test_Fat32ZeroStream_Read_InvalidCount_Throws() + { + const int blockSize = 1024 * 1024; // 1MB + using var stream = new Fat32Filesystem.Fat32ZeroStream(blockSize); + + var buffer = new byte[1024]; + Assert.Throws(() => stream.Read(buffer, 0, -1)); + Assert.Throws(() => stream.Read(buffer, 512, 1024)); // Exceeds buffer length + } } diff --git a/Duplicati/UnitTest/DiskImage/Helpers/DiskImageHelperFactory.cs b/Duplicati/UnitTest/DiskImage/Helpers/DiskImageHelperFactory.cs index 1df5df1ff..62b9cff38 100644 --- a/Duplicati/UnitTest/DiskImage/Helpers/DiskImageHelperFactory.cs +++ b/Duplicati/UnitTest/DiskImage/Helpers/DiskImageHelperFactory.cs @@ -24,70 +24,69 @@ using System.Runtime.InteropServices; #nullable enable -namespace Duplicati.UnitTest.DiskImage +namespace Duplicati.UnitTest.DiskImage.Helpers; + +/// +/// Factory class for creating the appropriate implementation +/// based on the host operating system. +/// +public static class DiskImageHelperFactory { /// - /// Factory class for creating the appropriate implementation - /// based on the host operating system. + /// Creates and returns the appropriate disk image helper for the current operating system. /// - public static class DiskImageHelperFactory + /// An implementation for the current OS. + /// Thrown when the current OS is not supported. + public static IDiskImageHelper Create() { - /// - /// Creates and returns the appropriate disk image helper for the current operating system. - /// - /// An implementation for the current OS. - /// Thrown when the current OS is not supported. - public static IDiskImageHelper Create() + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return new WindowsDiskImageHelper(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return new LinuxDiskImageHelper(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return new MacOSDiskImageHelper(); + } + else + { + throw new PlatformNotSupportedException($"Disk image operations are not supported on this operating system: {RuntimeInformation.OSDescription}"); + } + } + + /// + /// Gets a value indicating whether disk image operations are supported on the current platform. + /// + /// true if disk image operations are supported; otherwise, false. + public static bool IsSupported + { + get + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + || RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + } + } + + /// + /// Gets the name of the current platform. + /// + /// The platform name ("Windows", "Linux", "macOS", or "Unknown"). + public static string CurrentPlatformName + { + get { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return new WindowsDiskImageHelper(); - } + return "Windows"; else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - return new LinuxDiskImageHelper(); - } + return "Linux"; else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - return new MacOSDiskImageHelper(); - } + return "macOS"; else - { - throw new PlatformNotSupportedException($"Disk image operations are not supported on this operating system: {RuntimeInformation.OSDescription}"); - } - } - - /// - /// Gets a value indicating whether disk image operations are supported on the current platform. - /// - /// true if disk image operations are supported; otherwise, false. - public static bool IsSupported - { - get - { - return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) - || RuntimeInformation.IsOSPlatform(OSPlatform.OSX); - } - } - - /// - /// Gets the name of the current platform. - /// - /// The platform name ("Windows", "Linux", "macOS", or "Unknown"). - public static string CurrentPlatformName - { - get - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return "Windows"; - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - return "Linux"; - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - return "macOS"; - else - return "Unknown"; - } + return "Unknown"; } } } diff --git a/Duplicati/UnitTest/DiskImage/Helpers/DiskImageTestHelpers.cs b/Duplicati/UnitTest/DiskImage/Helpers/DiskImageTestHelpers.cs index 089b55e48..4a3fa02ca 100644 --- a/Duplicati/UnitTest/DiskImage/Helpers/DiskImageTestHelpers.cs +++ b/Duplicati/UnitTest/DiskImage/Helpers/DiskImageTestHelpers.cs @@ -29,131 +29,129 @@ using Duplicati.Proprietary.DiskImage.General; #nullable enable -namespace Duplicati.UnitTest.DiskImage +namespace Duplicati.UnitTest.DiskImage.Helpers; + +/// +/// Helper methods for disk image unit tests. +/// Provides common operations for creating, initializing, and managing disk images. +/// +internal static class DiskImageTestHelpers { + private const long MiB = 1024 * 1024; + /// - /// Helper methods for disk image unit tests. - /// Provides common operations for creating, initializing, and managing disk images. + /// Creates a raw disk interface for the specified disk identifier based on the current platform. /// - internal static class DiskImageTestHelpers + /// The disk identifier (e.g., \\.\PhysicalDriveN on Windows, /dev/loopN on Linux). + /// An initialized IRawDisk instance for the specified platform. + /// Thrown when the current OS is not supported. + internal static IRawDisk CreateRawDiskForIdentifier(string diskIdentifier) { - private const long MiB = 1024 * 1024; + if (OperatingSystem.IsWindows()) + return new Proprietary.DiskImage.Disk.Windows(diskIdentifier); + else if (OperatingSystem.IsLinux()) + return new Linux(diskIdentifier); + else if (OperatingSystem.IsMacOS()) + return new Mac(diskIdentifier); + else + throw new PlatformNotSupportedException("Unsupported operating system."); + } - /// - /// Creates a raw disk interface for the specified disk identifier based on the current platform. - /// - /// The disk identifier (e.g., \\.\PhysicalDriveN on Windows, /dev/loopN on Linux). - /// An initialized IRawDisk instance for the specified platform. - /// Thrown when the current OS is not supported. - internal static IRawDisk CreateRawDiskForIdentifier(string diskIdentifier) + /// + /// Creates a disk image file with the specified parameters. + /// + /// The disk image helper. + /// The path where the disk image will be created. + /// The size of the disk in bytes. + /// The partition table type (GPT, MBR, etc.). + /// Array of tuples specifying filesystem type and size for each partition. + /// The disk identifier for the created disk. + internal static string CreateDiskWithPartitions( + IDiskImageHelper diskHelper, + string imagePath, + long size, + PartitionTableType tableType, + (FileSystemType, long)[] partitions) + { + var diskIdentifier = diskHelper.CreateDisk(imagePath, size); + diskHelper.InitializeDisk(diskIdentifier, tableType, partitions); + diskHelper.FlushDisk(diskIdentifier); + diskHelper.Unmount(diskIdentifier); + return diskIdentifier; + } + + /// + /// Fills a partition with well-known test data (repeating pattern of 0x00-0xFF). + /// + /// The raw disk interface. + /// The starting offset of the partition in bytes. + /// The size of the partition in bytes. + /// Cancellation token. + internal static async Task FillPartitionWithTestDataAsync( + IRawDisk rawDisk, + long partitionOffset, + long partitionSize, + CancellationToken cancellationToken = default) + { + const int bufferSize = 64 * 1024; // 64KB chunks + var buffer = new byte[bufferSize]; + + long bytesWritten = 0; + long currentOffset = partitionOffset; + + while (bytesWritten < partitionSize) { - if (OperatingSystem.IsWindows()) - return new Duplicati.Proprietary.DiskImage.Disk.Windows(diskIdentifier); - else if (OperatingSystem.IsLinux()) - return new Duplicati.Proprietary.DiskImage.Disk.Linux(diskIdentifier); - else if (OperatingSystem.IsMacOS()) - return new Duplicati.Proprietary.DiskImage.Disk.Mac(diskIdentifier); - else - throw new PlatformNotSupportedException("Unsupported operating system."); - } + cancellationToken.ThrowIfCancellationRequested(); - /// - /// Creates a disk image file with the specified parameters. - /// - /// The disk image helper. - /// The path where the disk image will be created. - /// The size of the disk in bytes. - /// The partition table type (GPT, MBR, etc.). - /// Array of tuples specifying filesystem type and size for each partition. - /// The disk identifier for the created disk. - internal static string CreateDiskWithPartitions( - IDiskImageHelper diskHelper, - string imagePath, - long size, - PartitionTableType tableType, - (FileSystemType, long)[] partitions) - { - var diskIdentifier = diskHelper.CreateDisk(imagePath, size); - diskHelper.InitializeDisk(diskIdentifier, tableType, partitions); - diskHelper.FlushDisk(diskIdentifier); - diskHelper.Unmount(diskIdentifier); - return diskIdentifier; - } - - /// - /// Fills a partition with well-known test data (repeating pattern of 0x00-0xFF). - /// - /// The raw disk interface. - /// The starting offset of the partition in bytes. - /// The size of the partition in bytes. - /// Cancellation token. - internal static async Task FillPartitionWithTestDataAsync( - IRawDisk rawDisk, - long partitionOffset, - long partitionSize, - CancellationToken cancellationToken = default) - { - const int bufferSize = 64 * 1024; // 64KB chunks - var buffer = new byte[bufferSize]; - - long bytesWritten = 0; - long currentOffset = partitionOffset; - - while (bytesWritten < partitionSize) + // Fill buffer with well-known pattern (repeating 0x00-0xFF) + for (int i = 0; i < buffer.Length; i++) { - cancellationToken.ThrowIfCancellationRequested(); - - // Fill buffer with well-known pattern (repeating 0x00-0xFF) - for (int i = 0; i < buffer.Length; i++) - { - buffer[i] = (byte)((bytesWritten + i) & 0xFF); - } - - var remaining = partitionSize - bytesWritten; - var toWrite = (int)Math.Min(bufferSize, remaining); - - await rawDisk.WriteBytesAsync(currentOffset, buffer.AsMemory(0, toWrite), cancellationToken); - - bytesWritten += toWrite; - currentOffset += toWrite; + buffer[i] = (byte)((bytesWritten + i) & 0xFF); } - } - /// - /// Gets the file extension for disk images based on the current platform. - /// - /// The platform-specific disk image extension (vhdx, img, or dmg). - internal static string GetPlatformDiskImageExtension() - { - if (OperatingSystem.IsWindows()) - return "vhdx"; - else if (OperatingSystem.IsLinux()) - return "img"; - else if (OperatingSystem.IsMacOS()) - return "dmg"; - else - throw new PlatformNotSupportedException("Unsupported operating system."); - } + var remaining = partitionSize - bytesWritten; + var toWrite = (int)Math.Min(bufferSize, remaining); - /// - /// Deletes a file if it exists, catching any exceptions. - /// - /// The path to the file to delete. - internal static void SafeDeleteFile(string? filePath) - { - if (string.IsNullOrEmpty(filePath)) - return; + await rawDisk.WriteBytesAsync(currentOffset, buffer.AsMemory(0, toWrite), cancellationToken); - try - { - if (File.Exists(filePath)) - File.Delete(filePath); - } - catch (Exception ex) - { - Console.WriteLine($"Warning: Failed to delete file {filePath}: {ex.Message}"); - } + bytesWritten += toWrite; + currentOffset += toWrite; } } + /// + /// Gets the file extension for disk images based on the current platform. + /// + /// The platform-specific disk image extension (vhdx, img, or dmg). + internal static string GetPlatformDiskImageExtension() + { + if (OperatingSystem.IsWindows()) + return "vhdx"; + else if (OperatingSystem.IsLinux()) + return "img"; + else if (OperatingSystem.IsMacOS()) + return "dmg"; + else + throw new PlatformNotSupportedException("Unsupported operating system."); + } + + /// + /// Deletes a file if it exists, catching any exceptions. + /// + /// The path to the file to delete. + internal static void SafeDeleteFile(string? filePath) + { + if (string.IsNullOrEmpty(filePath)) + return; + + try + { + if (File.Exists(filePath)) + File.Delete(filePath); + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Failed to delete file {filePath}: {ex.Message}"); + } + } } \ No newline at end of file diff --git a/Duplicati/UnitTest/DiskImage/Helpers/IDiskImageHelper.cs b/Duplicati/UnitTest/DiskImage/Helpers/IDiskImageHelper.cs index 954f1e0f0..32ac8d3b3 100644 --- a/Duplicati/UnitTest/DiskImage/Helpers/IDiskImageHelper.cs +++ b/Duplicati/UnitTest/DiskImage/Helpers/IDiskImageHelper.cs @@ -25,89 +25,88 @@ using Duplicati.Proprietary.DiskImage.General; #nullable enable -namespace Duplicati.UnitTest.DiskImage +namespace Duplicati.UnitTest.DiskImage.Helpers; + +/// +/// Interface for disk image helper operations that work across different operating systems. +/// Provides methods for creating, attaching, formatting, and managing virtual disk images +/// for testing disk image backup and restore operations. +/// +public interface IDiskImageHelper : IDisposable { /// - /// Interface for disk image helper operations that work across different operating systems. - /// Provides methods for creating, attaching, formatting, and managing virtual disk images - /// for testing disk image backup and restore operations. + /// Creates a virtual disk file, attaches it to the system, and returns the physical drive path. /// - public interface IDiskImageHelper : IDisposable - { - /// - /// Creates a virtual disk file, attaches it to the system, and returns the physical drive path. - /// - /// The path where the disk image file will be created. - /// The size of the disk image in bytes. - /// The physical drive path (e.g., \\.\PhysicalDriveN on Windows, /dev/loopN on Linux). - string CreateDisk(string imagePath, long sizeB); + /// The path where the disk image file will be created. + /// The size of the disk image in bytes. + /// The physical drive path (e.g., \\.\PhysicalDriveN on Windows, /dev/loopN on Linux). + string CreateDisk(string imagePath, long sizeB); - /// - /// Initializes, partitions, and formats a disk based on the provided parameters - /// - /// The identifier of the disk to initialize. - /// The type of partition table to create (e.g., GPT, MBR). - /// An array of tuples specifying the file system type and size for each partition. If the size is 0, the partition will use the remaining available space. - /// An array of mount points for the created partitions. - string[] InitializeDisk(string diskIdentifier, PartitionTableType tableType, (FileSystemType, long)[] partitions); + /// + /// Initializes, partitions, and formats a disk based on the provided parameters + /// + /// The identifier of the disk to initialize. + /// The type of partition table to create (e.g., GPT, MBR). + /// An array of tuples specifying the file system type and size for each partition. If the size is 0, the partition will use the remaining available space. + /// An array of mount points for the created partitions. + string[] InitializeDisk(string diskIdentifier, PartitionTableType tableType, (FileSystemType, long)[] partitions); - /// - /// Mounts all of the partitions on the specified disk and returns their mount points. - /// - /// The identifier of the disk to mount. - /// Optional base path for mounting the partitions. - /// Indicates whether the partitions should be mounted as read-only. - /// Optional array of file system types for the partitions. - /// An array of mount points for the mounted partitions. - string[] Mount(string diskIdentifier, string? baseMountPath = null, bool readOnly = false, FileSystemType[]? fileSystemTypes = null); + /// + /// Mounts all of the partitions on the specified disk and returns their mount points. + /// + /// The identifier of the disk to mount. + /// Optional base path for mounting the partitions. + /// Indicates whether the partitions should be mounted as read-only. + /// Optional array of file system types for the partitions. + /// An array of mount points for the mounted partitions. + string[] Mount(string diskIdentifier, string? baseMountPath = null, bool readOnly = false, FileSystemType[]? fileSystemTypes = null); - /// - /// Unmounts all of the partitions on the specified disk, ensuring they are safely detached from the system. The disk will still be attached, but will be pulled offline. After this call, the disk device can be written to. - /// - /// The identifier of the disk to unmount. - void Unmount(string diskIdentifier); + /// + /// Unmounts all of the partitions on the specified disk, ensuring they are safely detached from the system. The disk will still be attached, but will be pulled offline. After this call, the disk device can be written to. + /// + /// The identifier of the disk to unmount. + void Unmount(string diskIdentifier); - /// - /// Cleans up and deletes a disk image file, ensuring it is detached first. - /// - /// The path to the disk image file. - /// The identifier of the disk to clean up. If null, the method will attempt to determine the disk based on the image path. - void CleanupDisk(string imagePath, string? diskIdentifier = null); + /// + /// Cleans up and deletes a disk image file, ensuring it is detached first. + /// + /// The path to the disk image file. + /// The identifier of the disk to clean up. If null, the method will attempt to determine the disk based on the image path. + void CleanupDisk(string imagePath, string? diskIdentifier = null); - /// - /// Checks if the current process has the necessary privileges to perform disk operations. - /// - /// true if running with sufficient privileges; otherwise, false. - bool HasRequiredPrivileges(); + /// + /// Checks if the current process has the necessary privileges to perform disk operations. + /// + /// true if running with sufficient privileges; otherwise, false. + bool HasRequiredPrivileges(); - /// - /// Retrieves the partition table information for a given disk identifier. - /// - /// The identifier of the disk. - /// The partition table geometry of the disk. - PartitionTableGeometry GetPartitionTable(string diskIdentifier); + /// + /// Retrieves the partition table information for a given disk identifier. + /// + /// The identifier of the disk. + /// The partition table geometry of the disk. + PartitionTableGeometry GetPartitionTable(string diskIdentifier); - /// - /// Retrieves the partition information for a given disk identifier. - /// - /// The identifier of the disk. - /// An array of partition geometries for the disk. - PartitionGeometry[] GetPartitions(string diskIdentifier); + /// + /// Retrieves the partition information for a given disk identifier. + /// + /// The identifier of the disk. + /// An array of partition geometries for the disk. + PartitionGeometry[] GetPartitions(string diskIdentifier); - /// - /// Flushes the specified disk. Important prior to reading the disk raw. - /// - /// The identifier of the disk to flush. - void FlushDisk(string diskIdentifier); + /// + /// Flushes the specified disk. Important prior to reading the disk raw. + /// + /// The identifier of the disk to flush. + void FlushDisk(string diskIdentifier); - /// - /// Re-attaches the disk, optionally as read-only. This is useful for ensuring the disk is in a clean state after unmounting, and can also be used to force a read-only re-attachment for testing read-only scenarios. - /// - /// The path to the disk image file. - /// The identifier of the disk to re-attach. - /// The type of partition table on the disk. - /// 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); - } + /// + /// Re-attaches the disk, optionally as read-only. This is useful for ensuring the disk is in a clean state after unmounting, and can also be used to force a read-only re-attachment for testing read-only scenarios. + /// + /// The path to the disk image file. + /// The identifier of the disk to re-attach. + /// The type of partition table on the disk. + /// 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); } diff --git a/Duplicati/UnitTest/DiskImage/Helpers/LinuxDiskImageHelper.cs b/Duplicati/UnitTest/DiskImage/Helpers/LinuxDiskImageHelper.cs index 3aefa4421..09cac98ef 100644 --- a/Duplicati/UnitTest/DiskImage/Helpers/LinuxDiskImageHelper.cs +++ b/Duplicati/UnitTest/DiskImage/Helpers/LinuxDiskImageHelper.cs @@ -32,728 +32,727 @@ using Duplicati.Proprietary.DiskImage.General; #nullable enable -namespace Duplicati.UnitTest.DiskImage +namespace Duplicati.UnitTest.DiskImage.Helpers; + +/// +/// Linux implementation of using loop devices and standard Linux tools. +/// +internal class LinuxDiskImageHelper : IDiskImageHelper { /// - /// Linux implementation of using loop devices and standard Linux tools. + /// Tracks loop devices created by this helper for cleanup. /// - internal class LinuxDiskImageHelper : IDiskImageHelper + private readonly List _trackedLoopDevices = new(); + + /// + /// Maps image paths to their associated loop devices. + /// + private readonly Dictionary _imageToLoopDevice = new(); + + /// + /// Tracks any mount directories that were automatically created for cleanup. + /// + private readonly List _autoCreatedMntDirectories = new(); + + /// + public string CreateDisk(string imagePath, long sizeB) { - /// - /// Tracks loop devices created by this helper for cleanup. - /// - private readonly List _trackedLoopDevices = new(); + // Ensure the directory exists + var directory = Path.GetDirectoryName(imagePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + Directory.CreateDirectory(directory); - /// - /// Maps image paths to their associated loop devices. - /// - private readonly Dictionary _imageToLoopDevice = new(); - - /// - /// Tracks any mount directories that were automatically created for cleanup. - /// - private readonly List _autoCreatedMntDirectories = new(); - - /// - public string CreateDisk(string imagePath, long sizeB) + // Delete existing image if it exists + if (File.Exists(imagePath)) { - // Ensure the directory exists - var directory = Path.GetDirectoryName(imagePath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - Directory.CreateDirectory(directory); + try + { + CleanupDisk(imagePath); + } + catch + { + // Ignore errors during cleanup + } + } - // Delete existing image if it exists - if (File.Exists(imagePath)) + // Create a sparse disk image file using truncate + RunProcess("truncate", $"-s {sizeB} \"{imagePath}\""); + + // Attach it as a loop device + var loopDevice = RunProcess("losetup", $"--find --show --partscan \"{imagePath}\"").Trim(); + + if (string.IsNullOrEmpty(loopDevice) || !loopDevice.StartsWith("/dev/loop")) + throw new InvalidOperationException($"Failed to attach loop device for {imagePath}"); + + // Track the loop device + _trackedLoopDevices.Add(loopDevice); + _imageToLoopDevice[imagePath] = loopDevice; + + return loopDevice; + } + + /// + public string[] InitializeDisk(string diskIdentifier, PartitionTableType tableType, (FileSystemType, long)[] partitions) + { + if (tableType == PartitionTableType.Unknown) + return []; // No partition table, so nothing to initialize + + // Create partition table using parted + var label = tableType == PartitionTableType.GPT ? "gpt" : "msdos"; + RunProcess("parted", $"-s {diskIdentifier} mklabel {label}"); + + // Get disk size for calculating partition sizes + var diskSize = GetDiskSize(diskIdentifier); + var sectorSize = GetSectorSize(diskIdentifier); + var mbrSize = 1 * sectorSize; // MBR requires 1 sector for the partition table + var gptSize = mbrSize + 33 * sectorSize; // GPT requires 34 sectors reserved at the beginning (1 for protective MBR + 33 for GPT header and partition entries) + long nextFreeSector = tableType == PartitionTableType.GPT ? gptSize : mbrSize; + + // Create partitions + for (int i = 0; i < partitions.Length; i++) + { + var (fsType, size) = partitions[i]; + var partitionNumber = i + 1; + + // Calculate partition size + long endOffset; + if (size <= 0) + { + if (i != partitions.Length - 1) + throw new InvalidOperationException("Only the last partition can be sized to fill the remaining space"); + endOffset = diskSize - sectorSize; + } + else + endOffset = nextFreeSector + size - sectorSize; + + // Check if we should clamp + if (tableType == PartitionTableType.GPT && endOffset > diskSize - gptSize) + if (size <= 0) + endOffset = diskSize - gptSize; + else + throw new InvalidOperationException($"Partition {partitionNumber} exceeds available space. Max size for this partition is {diskSize - gptSize - nextFreeSector} bytes."); + + // Create partition + RunProcess("parted", $"-s {diskIdentifier} mkpart primary {nextFreeSector}B {endOffset}B"); + + // For MBR, set the boot flag on the first partition (some filesystems need this) + if (tableType == PartitionTableType.MBR && i == 0) { try { - CleanupDisk(imagePath); + RunProcess("parted", $"-s {diskIdentifier} set {partitionNumber} boot on"); } catch { - // Ignore errors during cleanup + // Ignore errors setting boot flag } } - // Create a sparse disk image file using truncate - RunProcess("truncate", $"-s {sizeB} \"{imagePath}\""); - - // Attach it as a loop device - var loopDevice = RunProcess("losetup", $"--find --show --partscan \"{imagePath}\"").Trim(); - - if (string.IsNullOrEmpty(loopDevice) || !loopDevice.StartsWith("/dev/loop")) - throw new InvalidOperationException($"Failed to attach loop device for {imagePath}"); - - // Track the loop device - _trackedLoopDevices.Add(loopDevice); - _imageToLoopDevice[imagePath] = loopDevice; - - return loopDevice; + nextFreeSector = endOffset + sectorSize; } - /// - public string[] InitializeDisk(string diskIdentifier, PartitionTableType tableType, (FileSystemType, long)[] partitions) + // Re-read partition table + RunProcess("partprobe", $"{diskIdentifier}"); + + // Small delay to allow kernel to create partition devices + System.Threading.Thread.Sleep(200); + + // Format each partition + for (int i = 0; i < partitions.Length; i++) { - if (tableType == PartitionTableType.Unknown) - return []; // No partition table, so nothing to initialize + var (fsType, _) = partitions[i]; + var partitionNumber = i + 1; + var partitionDevice = GetPartitionDevice(diskIdentifier, partitionNumber); - // Create partition table using parted - var label = tableType == PartitionTableType.GPT ? "gpt" : "msdos"; - RunProcess("parted", $"-s {diskIdentifier} mklabel {label}"); - - // Get disk size for calculating partition sizes - var diskSize = GetDiskSize(diskIdentifier); - var sectorSize = GetSectorSize(diskIdentifier); - var mbrSize = 1 * sectorSize; // MBR requires 1 sector for the partition table - var gptSize = mbrSize + 33 * sectorSize; // GPT requires 34 sectors reserved at the beginning (1 for protective MBR + 33 for GPT header and partition entries) - long nextFreeSector = tableType == PartitionTableType.GPT ? gptSize : mbrSize; - - // Create partitions - for (int i = 0; i < partitions.Length; i++) + // Wait for partition device to exist + var retryCount = 0; + while (!File.Exists(partitionDevice) && retryCount < 10) { - var (fsType, size) = partitions[i]; - var partitionNumber = i + 1; - - // Calculate partition size - long endOffset; - if (size <= 0) - { - if (i != partitions.Length - 1) - throw new InvalidOperationException("Only the last partition can be sized to fill the remaining space"); - endOffset = diskSize - sectorSize; - } - else - endOffset = nextFreeSector + size - sectorSize; - - // Check if we should clamp - if (tableType == PartitionTableType.GPT && endOffset > diskSize - gptSize) - if (size <= 0) - endOffset = diskSize - gptSize; - else - throw new InvalidOperationException($"Partition {partitionNumber} exceeds available space. Max size for this partition is {diskSize - gptSize - nextFreeSector} bytes."); - - // Create partition - RunProcess("parted", $"-s {diskIdentifier} mkpart primary {nextFreeSector}B {endOffset}B"); - - // For MBR, set the boot flag on the first partition (some filesystems need this) - if (tableType == PartitionTableType.MBR && i == 0) - { - try - { - RunProcess("parted", $"-s {diskIdentifier} set {partitionNumber} boot on"); - } - catch - { - // Ignore errors setting boot flag - } - } - - nextFreeSector = endOffset + sectorSize; + System.Threading.Thread.Sleep(100); + retryCount++; } - // Re-read partition table - RunProcess("partprobe", $"{diskIdentifier}"); + if (!File.Exists(partitionDevice)) + throw new InvalidOperationException($"Partition device {partitionDevice} did not appear after creation"); - // Small delay to allow kernel to create partition devices - System.Threading.Thread.Sleep(200); - - // Format each partition - for (int i = 0; i < partitions.Length; i++) - { - var (fsType, _) = partitions[i]; - var partitionNumber = i + 1; - var partitionDevice = GetPartitionDevice(diskIdentifier, partitionNumber); - - // Wait for partition device to exist - var retryCount = 0; - while (!File.Exists(partitionDevice) && retryCount < 10) - { - System.Threading.Thread.Sleep(100); - retryCount++; - } - - if (!File.Exists(partitionDevice)) - throw new InvalidOperationException($"Partition device {partitionDevice} did not appear after creation"); - - // Format the partition - FormatPartition(partitionDevice, fsType); - } - - return partitions.Length == 0 ? [] : Mount(diskIdentifier, null, false, null); + // Format the partition + FormatPartition(partitionDevice, fsType); } - /// - public string[] Mount(string diskIdentifier, string? baseMountPath = null, bool readOnly = false, FileSystemType[]? fileSystemTypes = null) + return partitions.Length == 0 ? [] : Mount(diskIdentifier, null, false, null); + } + + /// + public string[] Mount(string diskIdentifier, string? baseMountPath = null, bool readOnly = false, FileSystemType[]? fileSystemTypes = null) + { + var mountPoints = new List(); + var partitions = GetPartitionNames(diskIdentifier); + + if (partitions.Count == 0) + throw new InvalidOperationException($"No partitions found for disk {diskIdentifier}"); + if (fileSystemTypes is not null && fileSystemTypes.Length != partitions.Count) + throw new ArgumentException($"Length of fileSystemTypes array ({fileSystemTypes.Length}) must match the number of partitions ({partitions.Count})"); + + for (int i = 0; i < partitions.Count; i++) { - var mountPoints = new List(); - var partitions = GetPartitionNames(diskIdentifier); + var partitionDevice = partitions[i]; - if (partitions.Count == 0) - throw new InvalidOperationException($"No partitions found for disk {diskIdentifier}"); - if (fileSystemTypes is not null && fileSystemTypes.Length != partitions.Count) - throw new ArgumentException($"Length of fileSystemTypes array ({fileSystemTypes.Length}) must match the number of partitions ({partitions.Count})"); + // Get filesystem type to handle special cases + FileSystemType? fsType = null; + if (fileSystemTypes is not null) + fsType = fileSystemTypes[i]; - for (int i = 0; i < partitions.Count; i++) + // Create mount point + string mountPoint; + if (!string.IsNullOrEmpty(baseMountPath)) { - var partitionDevice = partitions[i]; - - // Get filesystem type to handle special cases - FileSystemType? fsType = null; - if (fileSystemTypes is not null) - fsType = fileSystemTypes[i]; - - // Create mount point - string mountPoint; - if (!string.IsNullOrEmpty(baseMountPath)) - { - var partitionName = Path.GetFileName(partitionDevice); - mountPoint = Path.Combine(baseMountPath, partitionName); - } - else - { - mountPoint = $"/mnt/duplicati_{Path.GetFileName(partitionDevice)}_{Guid.NewGuid():N}"; - _autoCreatedMntDirectories.Add(mountPoint); - } - - Directory.CreateDirectory(mountPoint); - - // Mount the partition - List options = []; - if (readOnly) - options.Add("ro"); - if (fsType == FileSystemType.XFS) - options.Add("nouuid"); // XFS does not allow mounting with duplicate UUIDs, so we ignore UUIDs for testing - var optionsArg = options.Count > 0 ? $"-o {string.Join(",", options)}" : ""; - var mountArgs = $"{optionsArg} {partitionDevice} \"{mountPoint}\"".Trim(); - RunProcess("mount", mountArgs); - - mountPoints.Add(mountPoint); + var partitionName = Path.GetFileName(partitionDevice); + mountPoint = Path.Combine(baseMountPath, partitionName); + } + else + { + mountPoint = $"/mnt/duplicati_{Path.GetFileName(partitionDevice)}_{Guid.NewGuid():N}"; + _autoCreatedMntDirectories.Add(mountPoint); } - return mountPoints.ToArray(); + Directory.CreateDirectory(mountPoint); + + // Mount the partition + List options = []; + if (readOnly) + options.Add("ro"); + if (fsType == FileSystemType.XFS) + options.Add("nouuid"); // XFS does not allow mounting with duplicate UUIDs, so we ignore UUIDs for testing + var optionsArg = options.Count > 0 ? $"-o {string.Join(",", options)}" : ""; + var mountArgs = $"{optionsArg} {partitionDevice} \"{mountPoint}\"".Trim(); + RunProcess("mount", mountArgs); + + mountPoints.Add(mountPoint); } - /// - public void Unmount(string diskIdentifier) - { - // Find all mounted partitions of this device - var partitions = GetPartitionNames(diskIdentifier); + return mountPoints.ToArray(); + } - foreach (var partitionDevice in partitions) + /// + public void Unmount(string diskIdentifier) + { + // Find all mounted partitions of this device + var partitions = GetPartitionNames(diskIdentifier); + + foreach (var partitionDevice in partitions) + { + // Check if mounted + var mountInfo = GetMountPoint(partitionDevice); + if (!string.IsNullOrEmpty(mountInfo)) { - // Check if mounted - var mountInfo = GetMountPoint(partitionDevice); - if (!string.IsNullOrEmpty(mountInfo)) - { - RunProcess("umount", partitionDevice); - } + RunProcess("umount", partitionDevice); } } + } - /// - public void CleanupDisk(string imagePath, string? diskIdentifier = null) + /// + public void CleanupDisk(string imagePath, string? diskIdentifier = null) + { + // Determine the loop device if not provided + if (string.IsNullOrEmpty(diskIdentifier)) { - // Determine the loop device if not provided - if (string.IsNullOrEmpty(diskIdentifier)) + if (_imageToLoopDevice.TryGetValue(imagePath, out var trackedDevice)) { - if (_imageToLoopDevice.TryGetValue(imagePath, out var trackedDevice)) - { - diskIdentifier = trackedDevice; - } - else - { - // Try to find the loop device using losetup -j - try - { - var output = RunProcess("losetup", $"-j \"{imagePath}\""); - if (!string.IsNullOrEmpty(output)) - { - // Output format: /dev/loop0: [0005]:123456 (/path/to/image) - var match = Regex.Match(output, @"^(\S+):"); - if (match.Success) - diskIdentifier = match.Groups[1].Value; - } - } - catch - { - // Ignore errors - } - } + diskIdentifier = trackedDevice; } - - // Unmount partitions - if (!string.IsNullOrEmpty(diskIdentifier)) + else { - bool unmounted = false; - for (int i = 0; i < 5; i++) - { - try - { - Unmount(diskIdentifier); - unmounted = true; - break; - } - catch - { - // Try to wait - Thread.Sleep(1000); - } - } - if (!unmounted) - { - TestContext.Progress.WriteLine($"Warning: Failed to unmount partitions for {diskIdentifier} after multiple attempts."); - } - - // Detach loop device + // Try to find the loop device using losetup -j try { - RunProcess("losetup", $"-d {diskIdentifier}"); + var output = RunProcess("losetup", $"-j \"{imagePath}\""); + if (!string.IsNullOrEmpty(output)) + { + // Output format: /dev/loop0: [0005]:123456 (/path/to/image) + var match = Regex.Match(output, @"^(\S+):"); + if (match.Success) + diskIdentifier = match.Groups[1].Value; + } } - catch (Exception ex) + catch { - TestContext.Progress.WriteLine($"Warning: Failed to detach loop device {diskIdentifier}: {ex.Message}"); + // Ignore errors } - - _trackedLoopDevices.Remove(diskIdentifier); } + } - // Delete the image file - if (File.Exists(imagePath)) + // Unmount partitions + if (!string.IsNullOrEmpty(diskIdentifier)) + { + bool unmounted = false; + for (int i = 0; i < 5; i++) { try { - File.Delete(imagePath); + Unmount(diskIdentifier); + unmounted = true; + break; } - catch (Exception ex) + catch { - TestContext.Progress.WriteLine($"Warning: Failed to delete image file {imagePath}: {ex.Message}"); + // Try to wait + Thread.Sleep(1000); } } - - _imageToLoopDevice.Remove(imagePath); - - // Clean up any auto-created mount directories - foreach (var mntDir in _autoCreatedMntDirectories) + if (!unmounted) { - try - { - if (Directory.Exists(mntDir)) - Directory.Delete(mntDir); - } - catch (Exception ex) - { - TestContext.Progress.WriteLine($"Warning: Failed to delete mount directory {mntDir}: {ex.Message}"); - } - } - _autoCreatedMntDirectories.Clear(); - } - - /// - public bool HasRequiredPrivileges() - { - // Check if running as root (uid 0) - try - { - var uid = RunProcess("id", "-u").Trim(); - return uid == "0"; - } - catch - { - return false; - } - } - - /// - public PartitionTableGeometry GetPartitionTable(string diskIdentifier) - { - var deviceName = Path.GetFileName(diskIdentifier); - var tableType = PartitionTableType.Unknown; - - // Use blkid to get partition table type - try - { - var output = RunProcess("blkid", $"-p -o value -s PTTYPE {diskIdentifier}").Trim(); - tableType = output.ToLower() switch - { - "gpt" => PartitionTableType.GPT, - "dos" => PartitionTableType.MBR, - "msdos" => PartitionTableType.MBR, - _ => PartitionTableType.Unknown - }; - } - catch - { - // Ignore errors + TestContext.Progress.WriteLine($"Warning: Failed to unmount partitions for {diskIdentifier} after multiple attempts."); } - var size = GetDiskSize(diskIdentifier); - var sectorSize = GetSectorSize(diskIdentifier); - - return new PartitionTableGeometry - { - Type = tableType, - Size = size, - SectorSize = sectorSize - }; - } - - /// - public PartitionGeometry[] GetPartitions(string diskIdentifier) - { - var partitions = new List(); - var partitionNames = GetPartitionNames(diskIdentifier); - - for (int i = 0; i < partitionNames.Count; i++) - { - var partitionDevice = partitionNames[i]; - var partitionNumber = i + 1; - - try - { - // Get partition info using blockdev - var start = 0L; - var size = 0L; - - try - { - var devName = Path.GetFileName(partitionDevice); - var startFile = $"/sys/class/block/{devName}/start"; - if (File.Exists(startFile)) - { - var startStr = File.ReadAllText(startFile).Trim(); - if (long.TryParse(startStr, out var startSector)) - { - start = startSector * 512; - } - } - else - { - var startStr = RunProcess("blockdev", $"--getstart {partitionDevice}").Trim(); - if (long.TryParse(startStr, out var startSector)) - { - // blockdev --getstart always returns the start sector in 512-byte units - start = startSector * 512; - } - } - } - catch (Exception ex) - { - TestContext.Progress.WriteLine($"Warning: Failed to get start for {partitionDevice}: {ex.Message}"); - } - - try - { - size = GetDiskSize(partitionDevice); - } - catch { } - - // Get filesystem type - var fsType = FileSystemType.Unknown; - try - { - var fsStr = RunProcess("blkid", $"-o value -s TYPE {partitionDevice}").Trim(); - fsType = ParseFilesystemType(fsStr); - } - catch { } - - partitions.Add(new PartitionGeometry - { - Number = partitionNumber, - Type = PartitionType.Unknown, - StartOffset = start, - Size = size, - FilesystemType = fsType, - TableType = PartitionTableType.Unknown - }); - } - catch (Exception ex) - { - TestContext.Progress.WriteLine($"Warning: Failed to get info for partition {partitionDevice}: {ex.Message}"); - } - } - - return partitions.ToArray(); - } - - /// - public void FlushDisk(string diskIdentifier) - { - // Sync all filesystems - RunProcess("sync", ""); - - // Flush buffers for the specific device - try - { - RunProcess("blockdev", $"--flushbufs {diskIdentifier}"); - } - catch - { - // Ignore errors - } - } - - /// - public string ReAttach(string imagePath, string diskIdentifier, PartitionTableType tableType, bool readOnly = false) - { - // Unmount and detach - try - { - Unmount(diskIdentifier); - } - catch { } - + // Detach loop device try { RunProcess("losetup", $"-d {diskIdentifier}"); } - catch { } + catch (Exception ex) + { + TestContext.Progress.WriteLine($"Warning: Failed to detach loop device {diskIdentifier}: {ex.Message}"); + } - // Re-attach the loop device - var readOnlyArg = readOnly ? "--read-only" : ""; - var newLoopDevice = RunProcess("losetup", $"--find --show --partscan {readOnlyArg} \"{imagePath}\"").Trim(); - - if (string.IsNullOrEmpty(newLoopDevice) || !newLoopDevice.StartsWith("/dev/loop")) - throw new InvalidOperationException($"Failed to re-attach loop device for {imagePath}"); - - // Update tracking _trackedLoopDevices.Remove(diskIdentifier); - _trackedLoopDevices.Add(newLoopDevice); - _imageToLoopDevice[imagePath] = newLoopDevice; - - // Allow time for partition discovery - System.Threading.Thread.Sleep(200); - - return newLoopDevice; } - /// - public void Dispose() + // Delete the image file + if (File.Exists(imagePath)) { - // Clean up any remaining tracked loop devices - foreach (var loopDevice in _trackedLoopDevices.ToList()) + try { + File.Delete(imagePath); + } + catch (Exception ex) + { + TestContext.Progress.WriteLine($"Warning: Failed to delete image file {imagePath}: {ex.Message}"); + } + } + + _imageToLoopDevice.Remove(imagePath); + + // Clean up any auto-created mount directories + foreach (var mntDir in _autoCreatedMntDirectories) + { + try + { + if (Directory.Exists(mntDir)) + Directory.Delete(mntDir); + } + catch (Exception ex) + { + TestContext.Progress.WriteLine($"Warning: Failed to delete mount directory {mntDir}: {ex.Message}"); + } + } + _autoCreatedMntDirectories.Clear(); + } + + /// + public bool HasRequiredPrivileges() + { + // Check if running as root (uid 0) + try + { + var uid = RunProcess("id", "-u").Trim(); + return uid == "0"; + } + catch + { + return false; + } + } + + /// + public PartitionTableGeometry GetPartitionTable(string diskIdentifier) + { + var deviceName = Path.GetFileName(diskIdentifier); + var tableType = PartitionTableType.Unknown; + + // Use blkid to get partition table type + try + { + var output = RunProcess("blkid", $"-p -o value -s PTTYPE {diskIdentifier}").Trim(); + tableType = output.ToLower() switch + { + "gpt" => PartitionTableType.GPT, + "dos" => PartitionTableType.MBR, + "msdos" => PartitionTableType.MBR, + _ => PartitionTableType.Unknown + }; + } + catch + { + // Ignore errors + } + + var size = GetDiskSize(diskIdentifier); + var sectorSize = GetSectorSize(diskIdentifier); + + return new PartitionTableGeometry + { + Type = tableType, + Size = size, + SectorSize = sectorSize + }; + } + + /// + public PartitionGeometry[] GetPartitions(string diskIdentifier) + { + var partitions = new List(); + var partitionNames = GetPartitionNames(diskIdentifier); + + for (int i = 0; i < partitionNames.Count; i++) + { + var partitionDevice = partitionNames[i]; + var partitionNumber = i + 1; + + try + { + // Get partition info using blockdev + var start = 0L; + var size = 0L; + try { - // Find associated image - var imagePath = _imageToLoopDevice.FirstOrDefault(x => x.Value == loopDevice).Key; - if (!string.IsNullOrEmpty(imagePath)) + var devName = Path.GetFileName(partitionDevice); + var startFile = $"/sys/class/block/{devName}/start"; + if (File.Exists(startFile)) { - CleanupDisk(imagePath, loopDevice); + var startStr = File.ReadAllText(startFile).Trim(); + if (long.TryParse(startStr, out var startSector)) + { + start = startSector * 512; + } } else { - // Just try to detach the loop device - RunProcess("losetup", $"-d {loopDevice}"); + var startStr = RunProcess("blockdev", $"--getstart {partitionDevice}").Trim(); + if (long.TryParse(startStr, out var startSector)) + { + // blockdev --getstart always returns the start sector in 512-byte units + start = startSector * 512; + } } } catch (Exception ex) { - TestContext.Progress.WriteLine($"Warning: Failed to dispose loop device {loopDevice}: {ex.Message}"); + TestContext.Progress.WriteLine($"Warning: Failed to get start for {partitionDevice}: {ex.Message}"); } - } - _trackedLoopDevices.Clear(); - _imageToLoopDevice.Clear(); - GC.SuppressFinalize(this); - } - - #region Helper Methods - - /// - /// Runs a process and returns the output. - /// - private static string RunProcess(string fileName, string arguments) - { - var psi = new ProcessStartInfo - { - FileName = fileName, - Arguments = arguments, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = new Process { StartInfo = psi }; - process.Start(); - string output = process.StandardOutput.ReadToEnd(); - string error = process.StandardError.ReadToEnd(); - process.WaitForExit(); - - if (process.ExitCode != 0) - { - throw new InvalidOperationException($"Process {fileName} exited with code {process.ExitCode}: {error}"); - } - - return output; - } - - /// - /// Gets the partition device path for a given disk and partition number. - /// - private static string GetPartitionDevice(string diskIdentifier, int partitionNumber) - { - // For loop devices, partitions are named /dev/loop0p1, /dev/loop0p2, etc. - if (diskIdentifier.StartsWith("/dev/loop")) - { - return $"{diskIdentifier}p{partitionNumber}"; - } - - // For NVMe devices, partitions are named /dev/nvme0n1p1, etc. - if (diskIdentifier.Contains("nvme")) - { - return $"{diskIdentifier}p{partitionNumber}"; - } - - // For standard SCSI/SATA devices, partitions are named /dev/sda1, /dev/sda2, etc. - return $"{diskIdentifier}{partitionNumber}"; - } - - /// - /// Gets the list of partition device paths for a disk. - /// - private static List GetPartitionNames(string diskIdentifier) - { - var partitions = new List(); - var deviceName = Path.GetFileName(diskIdentifier); - - // Check for partition devices - if (diskIdentifier.StartsWith("/dev/loop")) - { - // Loop device partitions: /dev/loop0p1, /dev/loop0p2, etc. - for (int i = 1; i <= 16; i++) + try { - var partitionDevice = $"{diskIdentifier}p{i}"; - if (File.Exists(partitionDevice)) - partitions.Add(partitionDevice); - else if (i > 1) - break; // Stop after first missing partition + size = GetDiskSize(partitionDevice); } - } - else if (diskIdentifier.Contains("nvme")) - { - // NVMe partitions: /dev/nvme0n1p1, etc. - for (int i = 1; i <= 16; i++) + catch { } + + // Get filesystem type + var fsType = FileSystemType.Unknown; + try { - var partitionDevice = $"{diskIdentifier}p{i}"; - if (File.Exists(partitionDevice)) - partitions.Add(partitionDevice); - else if (i > 1) - break; + var fsStr = RunProcess("blkid", $"-o value -s TYPE {partitionDevice}").Trim(); + fsType = ParseFilesystemType(fsStr); } - } - else - { - // Standard partitions: /dev/sda1, /dev/sda2, etc. - for (int i = 1; i <= 16; i++) + catch { } + + partitions.Add(new PartitionGeometry { - var partitionDevice = $"{diskIdentifier}{i}"; - if (File.Exists(partitionDevice)) - partitions.Add(partitionDevice); - else if (i > 1) - break; - } + Number = partitionNumber, + Type = PartitionType.Unknown, + StartOffset = start, + Size = size, + FilesystemType = fsType, + TableType = PartitionTableType.Unknown + }); } - - return partitions; - } - - /// - /// Gets the mount point for a partition device. - /// - private static string? GetMountPoint(string partitionDevice) - { - try + catch (Exception ex) { - var output = RunProcess("findmnt", $"--raw --noheadings -o TARGET {partitionDevice}").Trim(); - return string.IsNullOrEmpty(output) ? null : output; - } - catch - { - return null; + TestContext.Progress.WriteLine($"Warning: Failed to get info for partition {partitionDevice}: {ex.Message}"); } } - /// - /// Gets the filesystem type of a partition. - /// - private static string GetFilesystemType(string partitionDevice) - { - try - { - return RunProcess("blkid", $"-o value -s TYPE {partitionDevice}").Trim(); - } - catch - { - return string.Empty; - } - } - - /// - /// Formats a partition with the specified filesystem type. - /// - private static void FormatPartition(string partitionDevice, FileSystemType fsType) - { - var (command, args) = fsType switch - { - FileSystemType.FAT12 => ("mkfs.vfat", $"-F 12 {partitionDevice}"), - FileSystemType.FAT16 => ("mkfs.vfat", $"-F 16 {partitionDevice}"), - FileSystemType.FAT32 => ("mkfs.vfat", $"-F 32 {partitionDevice}"), - FileSystemType.ExFAT => ("mkfs.exfat", $"{partitionDevice}"), - FileSystemType.Ext2 => ("mkfs.ext2", $"-F {partitionDevice}"), - FileSystemType.Ext3 => ("mkfs.ext3", $"-F {partitionDevice}"), - FileSystemType.Ext4 => ("mkfs.ext4", $"-F {partitionDevice}"), - FileSystemType.XFS => ("mkfs.xfs", $"-f {partitionDevice}"), - FileSystemType.Btrfs => ("mkfs.btrfs", $"-f {partitionDevice}"), - FileSystemType.NTFS => ("mkfs.ntfs", $"-F {partitionDevice}"), - FileSystemType.ZFS => throw new NotSupportedException("ZFS is not supported for testing. ZFS requires pool creation which is incompatible with standard partition-based testing."), - _ => throw new NotSupportedException($"Unsupported filesystem type on Linux: {fsType}") - }; - - RunProcess(command, args); - } - - /// - /// Gets the size of a disk or partition in bytes. - /// - private static long GetDiskSize(string devicePath) - { - var output = RunProcess("blockdev", $"--getsize64 {devicePath}").Trim(); - return long.TryParse(output, out var size) ? size : 0; - } - - /// - /// Gets the sector size of a disk. - /// - private static int GetSectorSize(string devicePath) - { - var output = RunProcess("blockdev", $"--getss {devicePath}").Trim(); - return int.TryParse(output, out var size) ? size : 512; - } - - /// - /// Parses a filesystem type string to the FileSystemType enum. - /// - private static FileSystemType ParseFilesystemType(string fsType) - { - return fsType.ToLower() switch - { - "ntfs" => FileSystemType.NTFS, - "vfat" or "fat" => FileSystemType.FAT32, // Could be FAT12/16/32 - "exfat" => FileSystemType.ExFAT, - "hfsplus" or "hfs+" => FileSystemType.HFSPlus, - "apfs" => FileSystemType.APFS, - "ext2" => FileSystemType.Ext2, - "ext3" => FileSystemType.Ext3, - "ext4" => FileSystemType.Ext4, - "xfs" => FileSystemType.XFS, - "btrfs" => FileSystemType.Btrfs, - "zfs" => FileSystemType.ZFS, - "refs" => FileSystemType.ReFS, - _ => FileSystemType.Unknown - }; - } - - #endregion + return partitions.ToArray(); } + + /// + public void FlushDisk(string diskIdentifier) + { + // Sync all filesystems + RunProcess("sync", ""); + + // Flush buffers for the specific device + try + { + RunProcess("blockdev", $"--flushbufs {diskIdentifier}"); + } + catch + { + // Ignore errors + } + } + + /// + public string ReAttach(string imagePath, string diskIdentifier, PartitionTableType tableType, bool readOnly = false) + { + // Unmount and detach + try + { + Unmount(diskIdentifier); + } + catch { } + + try + { + RunProcess("losetup", $"-d {diskIdentifier}"); + } + catch { } + + // Re-attach the loop device + var readOnlyArg = readOnly ? "--read-only" : ""; + var newLoopDevice = RunProcess("losetup", $"--find --show --partscan {readOnlyArg} \"{imagePath}\"").Trim(); + + if (string.IsNullOrEmpty(newLoopDevice) || !newLoopDevice.StartsWith("/dev/loop")) + throw new InvalidOperationException($"Failed to re-attach loop device for {imagePath}"); + + // Update tracking + _trackedLoopDevices.Remove(diskIdentifier); + _trackedLoopDevices.Add(newLoopDevice); + _imageToLoopDevice[imagePath] = newLoopDevice; + + // Allow time for partition discovery + System.Threading.Thread.Sleep(200); + + return newLoopDevice; + } + + /// + public void Dispose() + { + // Clean up any remaining tracked loop devices + foreach (var loopDevice in _trackedLoopDevices.ToList()) + { + try + { + // Find associated image + var imagePath = _imageToLoopDevice.FirstOrDefault(x => x.Value == loopDevice).Key; + if (!string.IsNullOrEmpty(imagePath)) + { + CleanupDisk(imagePath, loopDevice); + } + else + { + // Just try to detach the loop device + RunProcess("losetup", $"-d {loopDevice}"); + } + } + catch (Exception ex) + { + TestContext.Progress.WriteLine($"Warning: Failed to dispose loop device {loopDevice}: {ex.Message}"); + } + } + + _trackedLoopDevices.Clear(); + _imageToLoopDevice.Clear(); + GC.SuppressFinalize(this); + } + + #region Helper Methods + + /// + /// Runs a process and returns the output. + /// + private static string RunProcess(string fileName, string arguments) + { + var psi = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = new Process { StartInfo = psi }; + process.Start(); + string output = process.StandardOutput.ReadToEnd(); + string error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException($"Process {fileName} exited with code {process.ExitCode}: {error}"); + } + + return output; + } + + /// + /// Gets the partition device path for a given disk and partition number. + /// + private static string GetPartitionDevice(string diskIdentifier, int partitionNumber) + { + // For loop devices, partitions are named /dev/loop0p1, /dev/loop0p2, etc. + if (diskIdentifier.StartsWith("/dev/loop")) + { + return $"{diskIdentifier}p{partitionNumber}"; + } + + // For NVMe devices, partitions are named /dev/nvme0n1p1, etc. + if (diskIdentifier.Contains("nvme")) + { + return $"{diskIdentifier}p{partitionNumber}"; + } + + // For standard SCSI/SATA devices, partitions are named /dev/sda1, /dev/sda2, etc. + return $"{diskIdentifier}{partitionNumber}"; + } + + /// + /// Gets the list of partition device paths for a disk. + /// + private static List GetPartitionNames(string diskIdentifier) + { + var partitions = new List(); + var deviceName = Path.GetFileName(diskIdentifier); + + // Check for partition devices + if (diskIdentifier.StartsWith("/dev/loop")) + { + // Loop device partitions: /dev/loop0p1, /dev/loop0p2, etc. + for (int i = 1; i <= 16; i++) + { + var partitionDevice = $"{diskIdentifier}p{i}"; + if (File.Exists(partitionDevice)) + partitions.Add(partitionDevice); + else if (i > 1) + break; // Stop after first missing partition + } + } + else if (diskIdentifier.Contains("nvme")) + { + // NVMe partitions: /dev/nvme0n1p1, etc. + for (int i = 1; i <= 16; i++) + { + var partitionDevice = $"{diskIdentifier}p{i}"; + if (File.Exists(partitionDevice)) + partitions.Add(partitionDevice); + else if (i > 1) + break; + } + } + else + { + // Standard partitions: /dev/sda1, /dev/sda2, etc. + for (int i = 1; i <= 16; i++) + { + var partitionDevice = $"{diskIdentifier}{i}"; + if (File.Exists(partitionDevice)) + partitions.Add(partitionDevice); + else if (i > 1) + break; + } + } + + return partitions; + } + + /// + /// Gets the mount point for a partition device. + /// + private static string? GetMountPoint(string partitionDevice) + { + try + { + var output = RunProcess("findmnt", $"--raw --noheadings -o TARGET {partitionDevice}").Trim(); + return string.IsNullOrEmpty(output) ? null : output; + } + catch + { + return null; + } + } + + /// + /// Gets the filesystem type of a partition. + /// + private static string GetFilesystemType(string partitionDevice) + { + try + { + return RunProcess("blkid", $"-o value -s TYPE {partitionDevice}").Trim(); + } + catch + { + return string.Empty; + } + } + + /// + /// Formats a partition with the specified filesystem type. + /// + private static void FormatPartition(string partitionDevice, FileSystemType fsType) + { + var (command, args) = fsType switch + { + FileSystemType.FAT12 => ("mkfs.vfat", $"-F 12 {partitionDevice}"), + FileSystemType.FAT16 => ("mkfs.vfat", $"-F 16 {partitionDevice}"), + FileSystemType.FAT32 => ("mkfs.vfat", $"-F 32 {partitionDevice}"), + FileSystemType.ExFAT => ("mkfs.exfat", $"{partitionDevice}"), + FileSystemType.Ext2 => ("mkfs.ext2", $"-F {partitionDevice}"), + FileSystemType.Ext3 => ("mkfs.ext3", $"-F {partitionDevice}"), + FileSystemType.Ext4 => ("mkfs.ext4", $"-F {partitionDevice}"), + FileSystemType.XFS => ("mkfs.xfs", $"-f {partitionDevice}"), + FileSystemType.Btrfs => ("mkfs.btrfs", $"-f {partitionDevice}"), + FileSystemType.NTFS => ("mkfs.ntfs", $"-F {partitionDevice}"), + FileSystemType.ZFS => throw new NotSupportedException("ZFS is not supported for testing. ZFS requires pool creation which is incompatible with standard partition-based testing."), + _ => throw new NotSupportedException($"Unsupported filesystem type on Linux: {fsType}") + }; + + RunProcess(command, args); + } + + /// + /// Gets the size of a disk or partition in bytes. + /// + private static long GetDiskSize(string devicePath) + { + var output = RunProcess("blockdev", $"--getsize64 {devicePath}").Trim(); + return long.TryParse(output, out var size) ? size : 0; + } + + /// + /// Gets the sector size of a disk. + /// + private static int GetSectorSize(string devicePath) + { + var output = RunProcess("blockdev", $"--getss {devicePath}").Trim(); + return int.TryParse(output, out var size) ? size : 512; + } + + /// + /// Parses a filesystem type string to the FileSystemType enum. + /// + private static FileSystemType ParseFilesystemType(string fsType) + { + return fsType.ToLower() switch + { + "ntfs" => FileSystemType.NTFS, + "vfat" or "fat" => FileSystemType.FAT32, // Could be FAT12/16/32 + "exfat" => FileSystemType.ExFAT, + "hfsplus" or "hfs+" => FileSystemType.HFSPlus, + "apfs" => FileSystemType.APFS, + "ext2" => FileSystemType.Ext2, + "ext3" => FileSystemType.Ext3, + "ext4" => FileSystemType.Ext4, + "xfs" => FileSystemType.XFS, + "btrfs" => FileSystemType.Btrfs, + "zfs" => FileSystemType.ZFS, + "refs" => FileSystemType.ReFS, + _ => FileSystemType.Unknown + }; + } + + #endregion } diff --git a/Duplicati/UnitTest/DiskImage/Helpers/MacOSDiskImageHelper.cs b/Duplicati/UnitTest/DiskImage/Helpers/MacOSDiskImageHelper.cs index 8cf6b9e1e..1ad564f08 100644 --- a/Duplicati/UnitTest/DiskImage/Helpers/MacOSDiskImageHelper.cs +++ b/Duplicati/UnitTest/DiskImage/Helpers/MacOSDiskImageHelper.cs @@ -33,643 +33,642 @@ using NUnit.Framework; #nullable enable -namespace Duplicati.UnitTest.DiskImage +namespace Duplicati.UnitTest.DiskImage.Helpers; + +/// +/// macOS implementation of using hdiutil and standard macOS tools. +/// +internal class MacOSDiskImageHelper : IDiskImageHelper { - /// - /// macOS implementation of using hdiutil and standard macOS tools. - /// - internal class MacOSDiskImageHelper : IDiskImageHelper + /// + public string CreateDisk(string imagePath, long sizeB) { - /// - public string CreateDisk(string imagePath, long sizeB) - { - // Ensure the directory exists - var directory = Path.GetDirectoryName(imagePath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - Directory.CreateDirectory(directory); + // Ensure the directory exists + var directory = Path.GetDirectoryName(imagePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + Directory.CreateDirectory(directory); - // Delete existing image if it exists - if (File.Exists(imagePath)) - try - { - CleanupDisk(imagePath); - } - catch - { - // Ignore errors during cleanup - } - - // Create a raw disk image using hdiutil - // Use UDIF format with "Free Space" filesystem (unformatted) - RunProcess("hdiutil", $"create -size {sizeB} -type UDIF -layout NONE -o \"{imagePath}\""); - - var output = RunProcess("hdiutil", $"attach -nomount -noautofsck \"{imagePath}\""); - - // Parse the disk device from output (e.g., "/dev/disk4") - var diskDevice = output.Trim().Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim() - ?? throw new InvalidOperationException("Failed to get disk device from hdiutil attach output"); - - // Return the raw device path for direct I/O - return diskDevice.Replace("/dev/disk", "/dev/rdisk"); - } - - private string[] GetPartitionNames(string diskIdentifier) - { - var output = RunProcess("diskutil", $"list -plist {diskIdentifier}"); - - var plist = XDocument.Parse(output); - var rootDict = plist.Element("plist")?.Element("dict"); - if (rootDict == null) - return []; - - // Find the disk in AllDisksAndPartitions - var allDisksAndPartitions = PlistHelper.GetArrayElement(rootDict, "AllDisksAndPartitions"); - if (allDisksAndPartitions == null) - return []; - - // Normalize disk identifier - plist stores just "disk4" but we might have "/dev/disk4" or "/dev/rdisk4" - var normalizedId = diskIdentifier.Replace("/dev/rdisk", "disk").Replace("/dev/disk", "disk"); - - var diskElement = allDisksAndPartitions.Elements("dict") - .FirstOrDefault(d => PlistHelper.GetStringValue(d, "DeviceIdentifier") == normalizedId); - - if (diskElement == null) - return []; - - var partitions = new List(); - - // Check for regular partitions - var partitionsArray = PlistHelper.GetArrayElement(diskElement, "Partitions"); - if (partitionsArray != null) - { - foreach (var partition in partitionsArray.Elements("dict")) - { - var deviceIdentifier = PlistHelper.GetStringValue(partition, "DeviceIdentifier"); - if (!string.IsNullOrWhiteSpace(deviceIdentifier)) - partitions.Add(deviceIdentifier); - } - } - - // Check for APFS volumes - var apfsVolumes = PlistHelper.GetArrayElement(diskElement, "APFSVolumes"); - if (apfsVolumes != null) - { - foreach (var volume in apfsVolumes.Elements("dict")) - { - var deviceIdentifier = PlistHelper.GetStringValue(volume, "DeviceIdentifier"); - if (!string.IsNullOrWhiteSpace(deviceIdentifier)) - partitions.Add(deviceIdentifier); - } - } - - return [.. partitions]; - } - - private string[] GetMountPoints(string diskIdentifier) - { - var partitions = GetPartitionNames(diskIdentifier); - var mountPoints = new System.Collections.Generic.List(); - - foreach (var partition in partitions) - { - var output = RunProcess("diskutil", $"info -plist /dev/{partition}"); - var plist = XDocument.Parse(output); - var dict = plist.Element("plist")?.Element("dict"); - if (dict == null) - continue; - - // Check if this is an APFS Physical Store - var contentType = PlistHelper.GetStringValue(dict, "Content"); - var isApfsPhysicalStore = contentType == "Apple_APFS"; - - if (isApfsPhysicalStore) - { - // For APFS, get the container and then get volumes from it - var containerId = PlistHelper.GetStringValue(dict, "APFSContainerReference"); - if (!string.IsNullOrEmpty(containerId)) - { - // Get volumes from the container (they are like disk5s1, disk5s2, etc.) - var containerVolumes = GetApfsContainerVolumes(containerId); - foreach (var volume in containerVolumes) - { - var volumeOutput = RunProcess("diskutil", $"info -plist {volume}"); - var volumePlist = XDocument.Parse(volumeOutput); - var volumeDict = volumePlist.Element("plist")?.Element("dict"); - if (volumeDict != null) - { - var mountPoint = PlistHelper.GetStringValue(volumeDict, "MountPoint"); - if (!string.IsNullOrEmpty(mountPoint)) - mountPoints.Add(mountPoint); - } - } - } - } - else - { - // Regular partition - extract mount point directly - var mountPoint = PlistHelper.GetStringValue(dict, "MountPoint"); - if (!string.IsNullOrEmpty(mountPoint)) - mountPoints.Add(mountPoint); - } - } - - return [.. mountPoints]; - } - - private string[] GetApfsContainerVolumes(string containerId) - { - var output = RunProcess("diskutil", $"list -plist {containerId}"); - - var plist = XDocument.Parse(output); - var rootDict = plist.Element("plist")?.Element("dict"); - if (rootDict == null) - return []; - - var allDisksAndPartitions = PlistHelper.GetArrayElement(rootDict, "AllDisksAndPartitions"); - if (allDisksAndPartitions == null) - return []; - - // Normalize container id - plist stores just "disk4" but we might have "/dev/disk4" or "/dev/rdisk4" - var normalizedId = containerId.Replace("/dev/rdisk", "disk").Replace("/dev/disk", "disk"); - - var container = allDisksAndPartitions.Elements("dict") - .FirstOrDefault(d => PlistHelper.GetStringValue(d, "DeviceIdentifier") == normalizedId); - - if (container == null) - return []; - - var volumes = new List(); - var apfsVolumes = PlistHelper.GetArrayElement(container, "APFSVolumes"); - if (apfsVolumes != null) - { - foreach (var volume in apfsVolumes.Elements("dict")) - { - var deviceIdentifier = PlistHelper.GetStringValue(volume, "DeviceIdentifier"); - if (!string.IsNullOrWhiteSpace(deviceIdentifier)) - volumes.Add(deviceIdentifier); - } - } - - return [.. volumes]; - } - - /// - public string[] InitializeDisk(string diskIdentifier, PartitionTableType tableType, (FileSystemType, long)[] partitions) - { - if (tableType == PartitionTableType.Unknown) - return []; // No partition table, so nothing to initialize - - // Use diskutil to partition the disk with the specified scheme - // "Free Space" means no filesystem, just the partition table - var partitionStrings = partitions.Length > 0 ? partitions.Select((p, i) => - { - var fsType = p.Item1 switch - { - FileSystemType.NTFS => throw new NotSupportedException("NTFS is not natively supported on macOS for creating partitions. Use ExFAT or FAT32 instead."), - FileSystemType.FAT32 => "\"MS-DOS FAT32\"", - FileSystemType.ExFAT => "ExFAT", - FileSystemType.HFSPlus => "HFS+", - FileSystemType.APFS => "APFS", - _ => throw new ArgumentException($"Unsupported filesystem type on macOS: {p.Item1}", nameof(partitions)) - }; - - var sizeArg = p.Item2 > 0 ? $"{p.Item2}" : "0"; - return $"{fsType} \"Partition {i + 1}\" {sizeArg}"; - }) - : ["\"Free Space\" \"\" 0"]; - var partitionArgs = string.Join(" ", partitionStrings); - - // Normalize disk identifier for diskutil command (it expects "disk4", not "/dev/rdisk4") - var normalizedDiskId = diskIdentifier.Replace("/dev/rdisk", "disk").Replace("/dev/disk", "disk"); - RunProcess("diskutil", $"partitionDisk {normalizedDiskId} {tableType.ToString().ToUpperInvariant()} {partitionArgs}"); - - if (partitions.Length == 0) - return []; - - return GetMountPoints(diskIdentifier); - } - - /// - public string[] Mount(string diskIdentifier, string? baseMountPath = null, bool readOnly = false, FileSystemType[]? fileSystemTypes = null) - { - // Check if already mounted + // Delete existing image if it exists + if (File.Exists(imagePath)) try { - var existingMountPoints = GetMountPoints(diskIdentifier); - if (existingMountPoints.Length > 0 && existingMountPoints.All(mp => mp.StartsWith(baseMountPath ?? "/Volumes/"))) - { - return existingMountPoints; - } + CleanupDisk(imagePath); } catch { - // Ignore errors and try mounting + // Ignore errors during cleanup } - // Get the list of partitions first to ensure we target the correct disk - var partitions = GetPartitionNames(diskIdentifier); + // Create a raw disk image using hdiutil + // Use UDIF format with "Free Space" filesystem (unformatted) + RunProcess("hdiutil", $"create -size {sizeB} -type UDIF -layout NONE -o \"{imagePath}\""); - List mountPoints = []; - foreach (var partition in partitions) + var output = RunProcess("hdiutil", $"attach -nomount -noautofsck \"{imagePath}\""); + + // Parse the disk device from output (e.g., "/dev/disk4") + var diskDevice = output.Trim().Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim() + ?? throw new InvalidOperationException("Failed to get disk device from hdiutil attach output"); + + // Return the raw device path for direct I/O + return diskDevice.Replace("/dev/disk", "/dev/rdisk"); + } + + private string[] GetPartitionNames(string diskIdentifier) + { + var output = RunProcess("diskutil", $"list -plist {diskIdentifier}"); + + var plist = XDocument.Parse(output); + var rootDict = plist.Element("plist")?.Element("dict"); + if (rootDict == null) + return []; + + // Find the disk in AllDisksAndPartitions + var allDisksAndPartitions = PlistHelper.GetArrayElement(rootDict, "AllDisksAndPartitions"); + if (allDisksAndPartitions == null) + return []; + + // Normalize disk identifier - plist stores just "disk4" but we might have "/dev/disk4" or "/dev/rdisk4" + var normalizedId = diskIdentifier.Replace("/dev/rdisk", "disk").Replace("/dev/disk", "disk"); + + var diskElement = allDisksAndPartitions.Elements("dict") + .FirstOrDefault(d => PlistHelper.GetStringValue(d, "DeviceIdentifier") == normalizedId); + + if (diskElement == null) + return []; + + var partitions = new List(); + + // Check for regular partitions + var partitionsArray = PlistHelper.GetArrayElement(diskElement, "Partitions"); + if (partitionsArray != null) + { + foreach (var partition in partitionsArray.Elements("dict")) { - var output = RunProcess("diskutil", $"info -plist /dev/{partition}"); - var plist = XDocument.Parse(output); - var dict = plist.Element("plist")?.Element("dict"); - if (dict == null) - continue; - - // Check if this is an APFS Physical Store - var contentType = PlistHelper.GetStringValue(dict, "Content"); - var isApfsPhysicalStore = contentType == "Apple_APFS"; - - if (isApfsPhysicalStore) - { - // For APFS, get the container and mount volumes from it - var containerId = PlistHelper.GetStringValue(dict, "APFSContainerReference"); - if (!string.IsNullOrEmpty(containerId)) - { - // Get volumes from the container (they are like disk5s1, disk5s2, etc.) - var containerVolumes = GetApfsContainerVolumes(containerId); - foreach (var volume in containerVolumes) - { - MountVolume(volume, baseMountPath, mountPoints, readOnly); - } - } - } - else - { - // Regular partition - mount directly - MountVolume(partition, baseMountPath, mountPoints, readOnly); - } + var deviceIdentifier = PlistHelper.GetStringValue(partition, "DeviceIdentifier"); + if (!string.IsNullOrWhiteSpace(deviceIdentifier)) + partitions.Add(deviceIdentifier); } - - return mountPoints.Count == 0 ? WaitForMountPoints(diskIdentifier) : [.. mountPoints]; } - /// - /// Mounts a single volume/partition with optional custom mount point. - /// - /// The volume identifier (e.g., "disk4s1"). - /// Optional base path for mounting. - /// List to collect mount points. - /// Indicates whether the volume should be mounted as read-only. - private void MountVolume(string volume, string? baseMountPath, List mountPoints, bool readOnly) + // Check for APFS volumes + var apfsVolumes = PlistHelper.GetArrayElement(diskElement, "APFSVolumes"); + if (apfsVolumes != null) { - var dir = ""; - var mountArgs = readOnly ? "-readOnly" : ""; - if (baseMountPath is not null) + foreach (var volume in apfsVolumes.Elements("dict")) { - dir = Path.Combine(baseMountPath, volume); - Directory.CreateDirectory(dir); // diskutil will mount to this directory instead of /Volumes/ if it exists, so create it ahead of time - var readOnlyArg = readOnly ? " -readOnly" : ""; - mountArgs = $"{readOnlyArg} -mountPoint \"{dir}\""; + var deviceIdentifier = PlistHelper.GetStringValue(volume, "DeviceIdentifier"); + if (!string.IsNullOrWhiteSpace(deviceIdentifier)) + partitions.Add(deviceIdentifier); } + } - // Unmount first in case it's already mounted somewhere else, to ensure it gets mounted to the correct location - var oldMp = GetMountPointFromPlist(RunProcess("diskutil", $"info -plist /dev/{volume}")); - if (oldMp is not null && !oldMp.StartsWith(baseMountPath ?? "/Volumes/")) - RunProcess("diskutil", $"unmount force /dev/{volume}"); + return [.. partitions]; + } - RunProcess("diskutil", $"mount -nobrowse {mountArgs} /dev/{volume}"); + private string[] GetMountPoints(string diskIdentifier) + { + var partitions = GetPartitionNames(diskIdentifier); + var mountPoints = new System.Collections.Generic.List(); - // Verify the mount - var mp = RunProcess("diskutil", $"info -plist /dev/{volume}"); - var mountPoint = GetMountPointFromPlist(mp); - if (!string.IsNullOrEmpty(mountPoint)) + foreach (var partition in partitions) + { + var output = RunProcess("diskutil", $"info -plist /dev/{partition}"); + var plist = XDocument.Parse(output); + var dict = plist.Element("plist")?.Element("dict"); + if (dict == null) + continue; + + // Check if this is an APFS Physical Store + var contentType = PlistHelper.GetStringValue(dict, "Content"); + var isApfsPhysicalStore = contentType == "Apple_APFS"; + + if (isApfsPhysicalStore) { - mountPoints.Add(mountPoint); - - if (!mountPoint.StartsWith(baseMountPath ?? "/Volumes/")) + // For APFS, get the container and then get volumes from it + var containerId = PlistHelper.GetStringValue(dict, "APFSContainerReference"); + if (!string.IsNullOrEmpty(containerId)) { - Console.WriteLine($"Warning: Volume /dev/{volume} did not mount to expected location. Output: {mp}"); + // Get volumes from the container (they are like disk5s1, disk5s2, etc.) + var containerVolumes = GetApfsContainerVolumes(containerId); + foreach (var volume in containerVolumes) + { + var volumeOutput = RunProcess("diskutil", $"info -plist {volume}"); + var volumePlist = XDocument.Parse(volumeOutput); + var volumeDict = volumePlist.Element("plist")?.Element("dict"); + if (volumeDict != null) + { + var mountPoint = PlistHelper.GetStringValue(volumeDict, "MountPoint"); + if (!string.IsNullOrEmpty(mountPoint)) + mountPoints.Add(mountPoint); + } + } } } else { - Console.WriteLine($"Warning: Volume /dev/{volume} did not report a mount point after mounting. Output: {mp}"); + // Regular partition - extract mount point directly + var mountPoint = PlistHelper.GetStringValue(dict, "MountPoint"); + if (!string.IsNullOrEmpty(mountPoint)) + mountPoints.Add(mountPoint); } } - /// - /// Extracts the mount point from diskutil info -plist output. - /// - private string? GetMountPointFromPlist(string plistOutput) + return [.. mountPoints]; + } + + private string[] GetApfsContainerVolumes(string containerId) + { + var output = RunProcess("diskutil", $"list -plist {containerId}"); + + var plist = XDocument.Parse(output); + var rootDict = plist.Element("plist")?.Element("dict"); + if (rootDict == null) + return []; + + var allDisksAndPartitions = PlistHelper.GetArrayElement(rootDict, "AllDisksAndPartitions"); + if (allDisksAndPartitions == null) + return []; + + // Normalize container id - plist stores just "disk4" but we might have "/dev/disk4" or "/dev/rdisk4" + var normalizedId = containerId.Replace("/dev/rdisk", "disk").Replace("/dev/disk", "disk"); + + var container = allDisksAndPartitions.Elements("dict") + .FirstOrDefault(d => PlistHelper.GetStringValue(d, "DeviceIdentifier") == normalizedId); + + if (container == null) + return []; + + var volumes = new List(); + var apfsVolumes = PlistHelper.GetArrayElement(container, "APFSVolumes"); + if (apfsVolumes != null) + { + foreach (var volume in apfsVolumes.Elements("dict")) + { + var deviceIdentifier = PlistHelper.GetStringValue(volume, "DeviceIdentifier"); + if (!string.IsNullOrWhiteSpace(deviceIdentifier)) + volumes.Add(deviceIdentifier); + } + } + + return [.. volumes]; + } + + /// + public string[] InitializeDisk(string diskIdentifier, PartitionTableType tableType, (FileSystemType, long)[] partitions) + { + if (tableType == PartitionTableType.Unknown) + return []; // No partition table, so nothing to initialize + + // Use diskutil to partition the disk with the specified scheme + // "Free Space" means no filesystem, just the partition table + var partitionStrings = partitions.Length > 0 ? partitions.Select((p, i) => + { + var fsType = p.Item1 switch + { + FileSystemType.NTFS => throw new NotSupportedException("NTFS is not natively supported on macOS for creating partitions. Use ExFAT or FAT32 instead."), + FileSystemType.FAT32 => "\"MS-DOS FAT32\"", + FileSystemType.ExFAT => "ExFAT", + FileSystemType.HFSPlus => "HFS+", + FileSystemType.APFS => "APFS", + _ => throw new ArgumentException($"Unsupported filesystem type on macOS: {p.Item1}", nameof(partitions)) + }; + + var sizeArg = p.Item2 > 0 ? $"{p.Item2}" : "0"; + return $"{fsType} \"Partition {i + 1}\" {sizeArg}"; + }) + : ["\"Free Space\" \"\" 0"]; + var partitionArgs = string.Join(" ", partitionStrings); + + // Normalize disk identifier for diskutil command (it expects "disk4", not "/dev/rdisk4") + var normalizedDiskId = diskIdentifier.Replace("/dev/rdisk", "disk").Replace("/dev/disk", "disk"); + RunProcess("diskutil", $"partitionDisk {normalizedDiskId} {tableType.ToString().ToUpperInvariant()} {partitionArgs}"); + + if (partitions.Length == 0) + return []; + + return GetMountPoints(diskIdentifier); + } + + /// + public string[] Mount(string diskIdentifier, string? baseMountPath = null, bool readOnly = false, FileSystemType[]? fileSystemTypes = null) + { + // Check if already mounted + try + { + var existingMountPoints = GetMountPoints(diskIdentifier); + if (existingMountPoints.Length > 0 && existingMountPoints.All(mp => mp.StartsWith(baseMountPath ?? "/Volumes/"))) + { + return existingMountPoints; + } + } + catch + { + // Ignore errors and try mounting + } + + // Get the list of partitions first to ensure we target the correct disk + var partitions = GetPartitionNames(diskIdentifier); + + List mountPoints = []; + foreach (var partition in partitions) + { + var output = RunProcess("diskutil", $"info -plist /dev/{partition}"); + var plist = XDocument.Parse(output); + var dict = plist.Element("plist")?.Element("dict"); + if (dict == null) + continue; + + // Check if this is an APFS Physical Store + var contentType = PlistHelper.GetStringValue(dict, "Content"); + var isApfsPhysicalStore = contentType == "Apple_APFS"; + + if (isApfsPhysicalStore) + { + // For APFS, get the container and mount volumes from it + var containerId = PlistHelper.GetStringValue(dict, "APFSContainerReference"); + if (!string.IsNullOrEmpty(containerId)) + { + // Get volumes from the container (they are like disk5s1, disk5s2, etc.) + var containerVolumes = GetApfsContainerVolumes(containerId); + foreach (var volume in containerVolumes) + { + MountVolume(volume, baseMountPath, mountPoints, readOnly); + } + } + } + else + { + // Regular partition - mount directly + MountVolume(partition, baseMountPath, mountPoints, readOnly); + } + } + + return mountPoints.Count == 0 ? WaitForMountPoints(diskIdentifier) : [.. mountPoints]; + } + + /// + /// Mounts a single volume/partition with optional custom mount point. + /// + /// The volume identifier (e.g., "disk4s1"). + /// Optional base path for mounting. + /// List to collect mount points. + /// Indicates whether the volume should be mounted as read-only. + private void MountVolume(string volume, string? baseMountPath, List mountPoints, bool readOnly) + { + var dir = ""; + var mountArgs = readOnly ? "-readOnly" : ""; + if (baseMountPath is not null) + { + dir = Path.Combine(baseMountPath, volume); + Directory.CreateDirectory(dir); // diskutil will mount to this directory instead of /Volumes/ if it exists, so create it ahead of time + var readOnlyArg = readOnly ? " -readOnly" : ""; + mountArgs = $"{readOnlyArg} -mountPoint \"{dir}\""; + } + + // Unmount first in case it's already mounted somewhere else, to ensure it gets mounted to the correct location + var oldMp = GetMountPointFromPlist(RunProcess("diskutil", $"info -plist /dev/{volume}")); + if (oldMp is not null && !oldMp.StartsWith(baseMountPath ?? "/Volumes/")) + RunProcess("diskutil", $"unmount force /dev/{volume}"); + + RunProcess("diskutil", $"mount -nobrowse {mountArgs} /dev/{volume}"); + + // Verify the mount + var mp = RunProcess("diskutil", $"info -plist /dev/{volume}"); + var mountPoint = GetMountPointFromPlist(mp); + if (!string.IsNullOrEmpty(mountPoint)) + { + mountPoints.Add(mountPoint); + + if (!mountPoint.StartsWith(baseMountPath ?? "/Volumes/")) + { + Console.WriteLine($"Warning: Volume /dev/{volume} did not mount to expected location. Output: {mp}"); + } + } + else + { + Console.WriteLine($"Warning: Volume /dev/{volume} did not report a mount point after mounting. Output: {mp}"); + } + } + + /// + /// Extracts the mount point from diskutil info -plist output. + /// + private string? GetMountPointFromPlist(string plistOutput) + { + try + { + var plist = XDocument.Parse(plistOutput); + var dict = plist.Element("plist")?.Element("dict"); + if (dict == null) + return null; + + var mountPoint = PlistHelper.GetStringValue(dict, "MountPoint"); + // Return null if not mounted (empty string indicates not mounted) + return string.IsNullOrWhiteSpace(mountPoint) ? null : mountPoint; + } + catch + { + return null; + } + } + + /// + /// Waits for mount points to be fully established after a mount operation. + /// Uses exponential backoff starting from a short delay to minimize wait time + /// in the common case while still handling slow mounts. + /// + /// After mounting (especially after raw partition table writes during restore), + /// macOS may need time to fully recognize filesystems and establish mount points + /// in /Volumes/. Without this retry loop, mount points may be: + /// - Not yet available (empty array) + /// - In a transient location like /private/var/ instead of /Volumes/ + /// - Mounted but with empty directories (filesystem not yet recognized) + /// + /// The disk identifier to check mount points for. + /// An array of mount point paths. + private string[] WaitForMountPoints(string diskIdentifier) + { + const int maxRetries = 10; + const int initialDelayMs = 100; + const int maxDelayMs = 1000; + + var delayMs = initialDelayMs; + for (int i = 0; i < maxRetries; i++) + { + var mountPoints = GetMountPoints(diskIdentifier); + + if (mountPoints.Length > 0) + { + // Check if any mount points are in a transient location (not under /Volumes/) + if (mountPoints.Any(mp => !mp.StartsWith("/Volumes/"))) + { + Thread.Sleep(delayMs); + delayMs = Math.Min(delayMs * 2, maxDelayMs); + continue; + } + + // Check if all mount points have filesystem entries (not empty) + var allNonEmpty = mountPoints.All(mp => + { + try { return Directory.GetFileSystemEntries(mp).Length > 0; } + catch { return false; } + }); + + if (allNonEmpty || i == maxRetries - 1) + return mountPoints; + } + + Thread.Sleep(delayMs); + delayMs = Math.Min(delayMs * 2, maxDelayMs); + } + + // Final attempt - return whatever we have + return GetMountPoints(diskIdentifier); + } + + public void Unmount(string diskIdentifier) + { + int retryCount = 3; + for (int i = 0; i < retryCount; i++) { try { - var plist = XDocument.Parse(plistOutput); - var dict = plist.Element("plist")?.Element("dict"); - if (dict == null) - return null; - - var mountPoint = PlistHelper.GetStringValue(dict, "MountPoint"); - // Return null if not mounted (empty string indicates not mounted) - return string.IsNullOrWhiteSpace(mountPoint) ? null : mountPoint; + // Unmount all volumes on the disk + RunProcess("diskutil", $"unmountDisk {diskIdentifier}"); } catch { - return null; + // Sometimes an unmount is issued too fast after writing data, which makes diskutil throw an errory. + Thread.Sleep(500); } } + } - /// - /// Waits for mount points to be fully established after a mount operation. - /// Uses exponential backoff starting from a short delay to minimize wait time - /// in the common case while still handling slow mounts. - /// - /// After mounting (especially after raw partition table writes during restore), - /// macOS may need time to fully recognize filesystems and establish mount points - /// in /Volumes/. Without this retry loop, mount points may be: - /// - Not yet available (empty array) - /// - In a transient location like /private/var/ instead of /Volumes/ - /// - Mounted but with empty directories (filesystem not yet recognized) - /// - /// The disk identifier to check mount points for. - /// An array of mount point paths. - private string[] WaitForMountPoints(string diskIdentifier) + /// + public void FlushDisk(string diskIdentifier) + { + // On macOS, use sync to flush all filesystem buffers + RunProcess("sync", ""); + } + + /// + public void CleanupDisk(string imagePath, string? diskIdentifier = null) + { + if (diskIdentifier is null) { - const int maxRetries = 10; - const int initialDelayMs = 100; - const int maxDelayMs = 1000; + var output = RunProcess("hdiutil", $"info"); - var delayMs = initialDelayMs; - for (int i = 0; i < maxRetries; i++) - { - var mountPoints = GetMountPoints(diskIdentifier); - - if (mountPoints.Length > 0) + diskIdentifier = output + .Split("================================================") + .Skip(1) // Skip the header + .Select(d => { - // Check if any mount points are in a transient location (not under /Volumes/) - if (mountPoints.Any(mp => !mp.StartsWith("/Volumes/"))) - { - Thread.Sleep(delayMs); - delayMs = Math.Min(delayMs * 2, maxDelayMs); - continue; - } + var lines = d + .Split('\n') + .Select(l => l.Trim()) + .Where(l => l.Length > 0) + .ToList(); + var imagePathLine = lines.FirstOrDefault(l => l.StartsWith("image-path")); + if (imagePathLine == null) + return null; - // Check if all mount points have filesystem entries (not empty) - var allNonEmpty = mountPoints.All(mp => - { - try { return Directory.GetFileSystemEntries(mp).Length > 0; } - catch { return false; } - }); + var path = imagePathLine.Split([':'], 2)[1].Trim(); + if (!string.Equals(path, imagePath, StringComparison.OrdinalIgnoreCase)) + return null; - if (allNonEmpty || i == maxRetries - 1) - return mountPoints; - } + var deviceLine = lines.FirstOrDefault(l => l.StartsWith("/dev/")); + if (deviceLine == null) + return null; - Thread.Sleep(delayMs); - delayMs = Math.Min(delayMs * 2, maxDelayMs); - } + var device = deviceLine + .Split(['\t'], 2)[0] + .Trim(); - // Final attempt - return whatever we have - return GetMountPoints(diskIdentifier); + return device; + }) + .FirstOrDefault(d => d != null); } - public void Unmount(string diskIdentifier) + if (diskIdentifier is null) + throw new InvalidOperationException($"Failed to determine disk identifier for {imagePath} during cleanup"); + + try { - int retryCount = 3; - for (int i = 0; i < retryCount; i++) - { - try - { - // Unmount all volumes on the disk - RunProcess("diskutil", $"unmountDisk {diskIdentifier}"); - } - catch - { - // Sometimes an unmount is issued too fast after writing data, which makes diskutil throw an errory. - Thread.Sleep(500); - } - } + RunProcess("diskutil", $"eject {diskIdentifier}"); + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Failed to detach disk image: {ex.Message}"); } - /// - public void FlushDisk(string diskIdentifier) + try { - // On macOS, use sync to flush all filesystem buffers - RunProcess("sync", ""); + if (File.Exists(imagePath)) + File.Delete(imagePath); } - - /// - public void CleanupDisk(string imagePath, string? diskIdentifier = null) + catch (Exception ex) { - if (diskIdentifier is null) - { - var output = RunProcess("hdiutil", $"info"); - - diskIdentifier = output - .Split("================================================") - .Skip(1) // Skip the header - .Select(d => - { - var lines = d - .Split('\n') - .Select(l => l.Trim()) - .Where(l => l.Length > 0) - .ToList(); - var imagePathLine = lines.FirstOrDefault(l => l.StartsWith("image-path")); - if (imagePathLine == null) - return null; - - var path = imagePathLine.Split([':'], 2)[1].Trim(); - if (!string.Equals(path, imagePath, StringComparison.OrdinalIgnoreCase)) - return null; - - var deviceLine = lines.FirstOrDefault(l => l.StartsWith("/dev/")); - if (deviceLine == null) - return null; - - var device = deviceLine - .Split(['\t'], 2)[0] - .Trim(); - - return device; - }) - .FirstOrDefault(d => d != null); - } - - if (diskIdentifier is null) - throw new InvalidOperationException($"Failed to determine disk identifier for {imagePath} during cleanup"); - - try - { - RunProcess("diskutil", $"eject {diskIdentifier}"); - } - catch (Exception ex) - { - Console.WriteLine($"Warning: Failed to detach disk image: {ex.Message}"); - } - - try - { - if (File.Exists(imagePath)) - File.Delete(imagePath); - } - catch (Exception ex) - { - Console.WriteLine($"Warning: Failed to delete disk image file: {ex.Message}"); - } + Console.WriteLine($"Warning: Failed to delete disk image file: {ex.Message}"); } + } - /// - public bool HasRequiredPrivileges() - { - // Root is not required for virtual disks. - return true; - } + /// + public bool HasRequiredPrivileges() + { + // Root is not required for virtual disks. + return true; + } - /// - public PartitionTableGeometry GetPartitionTable(string diskIdentifier) + /// + public PartitionTableGeometry GetPartitionTable(string diskIdentifier) + { + var output = RunProcess("diskutil", $"info -plist {diskIdentifier}"); + + var plist = XDocument.Parse(output); + var dict = plist.Element("plist")?.Element("dict"); + if (dict == null) + throw new InvalidOperationException($"Failed to parse diskutil info plist for {diskIdentifier}"); + + // Verify disk identifier + var deviceIdentifier = PlistHelper.GetStringValue(dict, "DeviceIdentifier"); + if (!diskIdentifier.EndsWith(deviceIdentifier ?? "", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Disk identifier mismatch in diskutil info output: {diskIdentifier} doesn't end with {deviceIdentifier}"); + + // Determine partition table type + var contentType = PlistHelper.GetStringValue(dict, "Content"); + var tableType = contentType switch { - var output = RunProcess("diskutil", $"info -plist {diskIdentifier}"); + "GUID_partition_scheme" => PartitionTableType.GPT, + "FDisk_partition_scheme" => PartitionTableType.MBR, + _ => PartitionTableType.Unknown + }; + + // Get size and sector size + var size = PlistHelper.GetLongValue(dict, "TotalSize"); + var sectorSize = (int)PlistHelper.GetLongValue(dict, "DeviceBlockSize"); + + if (size == 0 || sectorSize == 0) + throw new InvalidOperationException($"Failed to retrieve partition table information for disk {diskIdentifier}"); + + return new PartitionTableGeometry + { + Type = tableType, + Size = size, + SectorSize = sectorSize + // Ignore the rest for now. + }; + } + + /// + public PartitionGeometry[] GetPartitions(string diskIdentifier) + { + var names = GetPartitionNames(diskIdentifier); + + var partitions = new List(); + foreach (var name in names) + { + var output = RunProcess("diskutil", $"info -plist /dev/{name}"); var plist = XDocument.Parse(output); var dict = plist.Element("plist")?.Element("dict"); if (dict == null) - throw new InvalidOperationException($"Failed to parse diskutil info plist for {diskIdentifier}"); + continue; - // Verify disk identifier - var deviceIdentifier = PlistHelper.GetStringValue(dict, "DeviceIdentifier"); - if (!diskIdentifier.EndsWith(deviceIdentifier ?? "", StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"Disk identifier mismatch in diskutil info output: {diskIdentifier} doesn't end with {deviceIdentifier}"); + // Verify device node + var deviceNode = PlistHelper.GetStringValue(dict, "DeviceNode"); + if (!string.Equals(deviceNode, $"/dev/{name}", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Partition identifier mismatch in diskutil info output: expected /dev/{name}, got {deviceNode}"); - // Determine partition table type - var contentType = PlistHelper.GetStringValue(dict, "Content"); - var tableType = contentType switch + // Extract partition number from name (e.g., "disk4s1" -> 1) + var partitionNumber = int.TryParse( + name.LastIndexOf('s') >= 0 ? name[(name.LastIndexOf('s') + 1)..] : "", + out int num) ? num : -1; + + // Parse filesystem type + var fsPersonality = PlistHelper.GetStringValue(dict, "FilesystemPersonality"); + var filesystemType = fsPersonality switch { - "GUID_partition_scheme" => PartitionTableType.GPT, - "FDisk_partition_scheme" => PartitionTableType.MBR, - _ => PartitionTableType.Unknown + "MS-DOS FAT32" => FileSystemType.FAT32, + "ExFAT" => FileSystemType.ExFAT, + "HFS+" => FileSystemType.HFSPlus, + "APFS" => FileSystemType.APFS, + _ => FileSystemType.Unknown }; - // Get size and sector size - var size = PlistHelper.GetLongValue(dict, "TotalSize"); - var sectorSize = (int)PlistHelper.GetLongValue(dict, "DeviceBlockSize"); + // Parse volume UUID + Guid? volumeGuid = null; + var volumeUuid = PlistHelper.GetStringValue(dict, "VolumeUUID"); + if (Guid.TryParse(volumeUuid, out Guid parsedGuid)) + volumeGuid = parsedGuid; - if (size == 0 || sectorSize == 0) - throw new InvalidOperationException($"Failed to retrieve partition table information for disk {diskIdentifier}"); - - return new PartitionTableGeometry + PartitionGeometry partition = new() { - Type = tableType, - Size = size, - SectorSize = sectorSize - // Ignore the rest for now. + Number = partitionNumber, + Type = PartitionType.Unknown, + StartOffset = PlistHelper.GetLongValue(dict, "PartitionMapPartitionOffset"), + Size = PlistHelper.GetLongValue(dict, "TotalSize"), + Name = PlistHelper.GetStringValue(dict, "VolumeName"), + FilesystemType = filesystemType, + VolumeGuid = volumeGuid, + TableType = PartitionTableType.Unknown }; + + partitions.Add(partition); } - /// - public PartitionGeometry[] GetPartitions(string diskIdentifier) - { - var names = GetPartitionNames(diskIdentifier); - - var partitions = new List(); - foreach (var name in names) - { - var output = RunProcess("diskutil", $"info -plist /dev/{name}"); - - var plist = XDocument.Parse(output); - var dict = plist.Element("plist")?.Element("dict"); - if (dict == null) - continue; - - // Verify device node - var deviceNode = PlistHelper.GetStringValue(dict, "DeviceNode"); - if (!string.Equals(deviceNode, $"/dev/{name}", StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"Partition identifier mismatch in diskutil info output: expected /dev/{name}, got {deviceNode}"); - - // Extract partition number from name (e.g., "disk4s1" -> 1) - var partitionNumber = int.TryParse( - name.LastIndexOf('s') >= 0 ? name[(name.LastIndexOf('s') + 1)..] : "", - out int num) ? num : -1; - - // Parse filesystem type - var fsPersonality = PlistHelper.GetStringValue(dict, "FilesystemPersonality"); - var filesystemType = fsPersonality switch - { - "MS-DOS FAT32" => FileSystemType.FAT32, - "ExFAT" => FileSystemType.ExFAT, - "HFS+" => FileSystemType.HFSPlus, - "APFS" => FileSystemType.APFS, - _ => FileSystemType.Unknown - }; - - // Parse volume UUID - Guid? volumeGuid = null; - var volumeUuid = PlistHelper.GetStringValue(dict, "VolumeUUID"); - if (Guid.TryParse(volumeUuid, out Guid parsedGuid)) - volumeGuid = parsedGuid; - - PartitionGeometry partition = new() - { - Number = partitionNumber, - Type = PartitionType.Unknown, - StartOffset = PlistHelper.GetLongValue(dict, "PartitionMapPartitionOffset"), - Size = PlistHelper.GetLongValue(dict, "TotalSize"), - Name = PlistHelper.GetStringValue(dict, "VolumeName"), - FilesystemType = filesystemType, - VolumeGuid = volumeGuid, - TableType = PartitionTableType.Unknown - }; - - partitions.Add(partition); - } - - return partitions.ToArray(); - } - - public string ReAttach(string imagePath, string diskIdentifier, PartitionTableType tableType, bool readOnly = false) - { - // Eject can fail if issued too fast after writing data, which makes diskutil throw an error. Retry a few times with a delay to mitigate this. - RunProcess("diskutil", $"eject {diskIdentifier}", retryCount: 3); - var readOnlyArg = readOnly ? " -readonly" : ""; - var reattachoutput = RunProcess("hdiutil", $"attach -nomount -noautofsck{readOnlyArg} \"{imagePath}\""); - if (tableType == PartitionTableType.Unknown) - return reattachoutput.Split('\n').First().Trim(); - else - return reattachoutput - .Split('\n') - .Select(x => x.Trim() - .Split(' ') - .Where(y => !string.IsNullOrEmpty(y)) - .Select(y => y.Trim()) - .ToArray() - ) - .Where(x => x.Length > 0 && x[^1].EndsWith("_partition_scheme")) - .First().First(); - } - - public static string RunProcess(string fileName, string arguments, int retryCount = 0) - { - for (int i = 0; i <= retryCount; i++) - { - var result = ProcessRunner.RunProcessAsync(fileName, arguments, 30_000).Await(); - - - if (result.ExitCode != 0) - if (i < retryCount) - { - Console.WriteLine($"Warning: Process {fileName} with arguments {arguments} exited with code {result.ExitCode}. Retrying... Attempt {i + 1} of {retryCount}"); - Thread.Sleep(500); - continue; - } - else - throw new InvalidOperationException($"Process {fileName} with arguments {arguments} exited with code {result.ExitCode}: {result.Error}. Output: {result.Output}"); - - return result.Output; - } - - // This should never be hit due to the retry loop logic, but is required to satisfy the compiler. - throw new InvalidOperationException($"Failed to run process {fileName} with arguments {arguments} after retries. This should not happen."); - } - - /// - public void Dispose() - { - // Nothing to dispose - GC.SuppressFinalize(this); - } - + return partitions.ToArray(); } + + public string ReAttach(string imagePath, string diskIdentifier, PartitionTableType tableType, bool readOnly = false) + { + // Eject can fail if issued too fast after writing data, which makes diskutil throw an error. Retry a few times with a delay to mitigate this. + RunProcess("diskutil", $"eject {diskIdentifier}", retryCount: 3); + var readOnlyArg = readOnly ? " -readonly" : ""; + var reattachoutput = RunProcess("hdiutil", $"attach -nomount -noautofsck{readOnlyArg} \"{imagePath}\""); + if (tableType == PartitionTableType.Unknown) + return reattachoutput.Split('\n').First().Trim(); + else + return reattachoutput + .Split('\n') + .Select(x => x.Trim() + .Split(' ') + .Where(y => !string.IsNullOrEmpty(y)) + .Select(y => y.Trim()) + .ToArray() + ) + .Where(x => x.Length > 0 && x[^1].EndsWith("_partition_scheme")) + .First().First(); + } + + public static string RunProcess(string fileName, string arguments, int retryCount = 0) + { + for (int i = 0; i <= retryCount; i++) + { + var result = ProcessRunner.RunProcessAsync(fileName, arguments, 30_000).Await(); + + + if (result.ExitCode != 0) + if (i < retryCount) + { + Console.WriteLine($"Warning: Process {fileName} with arguments {arguments} exited with code {result.ExitCode}. Retrying... Attempt {i + 1} of {retryCount}"); + Thread.Sleep(500); + continue; + } + else + throw new InvalidOperationException($"Process {fileName} with arguments {arguments} exited with code {result.ExitCode}: {result.Error}. Output: {result.Output}"); + + return result.Output; + } + + // This should never be hit due to the retry loop logic, but is required to satisfy the compiler. + throw new InvalidOperationException($"Failed to run process {fileName} with arguments {arguments} after retries. This should not happen."); + } + + /// + public void Dispose() + { + // Nothing to dispose + GC.SuppressFinalize(this); + } + } diff --git a/Duplicati/UnitTest/DiskImage/Helpers/WindowsDiskImageHelper.cs b/Duplicati/UnitTest/DiskImage/Helpers/WindowsDiskImageHelper.cs index 06d11270d..2dd756969 100644 --- a/Duplicati/UnitTest/DiskImage/Helpers/WindowsDiskImageHelper.cs +++ b/Duplicati/UnitTest/DiskImage/Helpers/WindowsDiskImageHelper.cs @@ -33,433 +33,433 @@ using NUnit.Framework; #nullable enable -namespace Duplicati.UnitTest.DiskImage +namespace Duplicati.UnitTest.DiskImage.Helpers; + +/// +/// Manages a persistent PowerShell session for executing commands. +/// This avoids the overhead of starting a new PowerShell process for each command. +/// +internal sealed class PowerShellSession : IDisposable { + private Process? _process; + private readonly object _lock = new(); + private bool _disposed; + private readonly StringBuilder _outputBuffer = new(); + private readonly StringBuilder _errorBuffer = new(); + private readonly ManualResetEventSlim _outputAvailable = new(false); + /// - /// Manages a persistent PowerShell session for executing commands. - /// This avoids the overhead of starting a new PowerShell process for each command. + /// Gets a value indicating whether the PowerShell session is running. /// - internal sealed class PowerShellSession : IDisposable + /// true if the session is running; otherwise, false. + public bool IsRunning => _process != null && !_process.HasExited; + + /// + /// Ensures the PowerShell session is started. + /// + /// Thrown if the session has been disposed. + public void EnsureStarted() { - private Process? _process; - private readonly object _lock = new(); - private bool _disposed; - private readonly StringBuilder _outputBuffer = new(); - private readonly StringBuilder _errorBuffer = new(); - private readonly ManualResetEventSlim _outputAvailable = new(false); - - /// - /// Gets a value indicating whether the PowerShell session is running. - /// - /// true if the session is running; otherwise, false. - public bool IsRunning => _process != null && !_process.HasExited; - - /// - /// Ensures the PowerShell session is started. - /// - /// Thrown if the session has been disposed. - public void EnsureStarted() + lock (_lock) { - lock (_lock) + if (_disposed) { - if (_disposed) - { - throw new ObjectDisposedException(nameof(PowerShellSession)); - } - - if (_process != null && !_process.HasExited) - { - return; - } - - StartProcess(); - } - } - - /// - /// Starts the PowerShell process with elevated privileges. - /// - /// Thrown if the PowerShell process cannot be started. - private void StartProcess() - { - var startInfo = new ProcessStartInfo - { - FileName = "powershell.exe", - Arguments = "-NoProfile -ExecutionPolicy Bypass -Command \"& { while ($true) { $cmd = Read-Host; if ($cmd -eq 'EXIT_SESSION') { break }; try { $output = Invoke-Expression $cmd 2>&1; $output | Out-String -Stream; Write-Output \"___ENDOFCOMMAND___\" } catch { $_.Exception.Message; Write-Output \"___ENDOFCOMMAND___\" } } }\"", - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - Verb = "runas" // Request elevation - }; - - _process = Process.Start(startInfo); - if (_process == null) - { - throw new InvalidOperationException("Failed to start PowerShell process"); + throw new ObjectDisposedException(nameof(PowerShellSession)); } - // Clear any startup output - _outputBuffer.Clear(); - _errorBuffer.Clear(); - _outputAvailable.Reset(); - - // Attach event handlers for async reading - _process.OutputDataReceived += OnOutputDataReceived; - _process.ErrorDataReceived += OnErrorDataReceived; - - _process.BeginOutputReadLine(); - _process.BeginErrorReadLine(); - } - - /// - /// Handles the output data received from the PowerShell process. - /// - /// The source of the event. - /// The event data. - private void OnOutputDataReceived(object sender, DataReceivedEventArgs e) - { - if (e.Data != null) + if (_process != null && !_process.HasExited) { - lock (_outputBuffer) - { - _outputBuffer.AppendLine(e.Data); - if (e.Data.Contains("___ENDOFCOMMAND___")) - { - _outputAvailable.Set(); - } - } + return; } + + StartProcess(); + } + } + + /// + /// Starts the PowerShell process with elevated privileges. + /// + /// Thrown if the PowerShell process cannot be started. + private void StartProcess() + { + var startInfo = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = "-NoProfile -ExecutionPolicy Bypass -Command \"& { while ($true) { $cmd = Read-Host; if ($cmd -eq 'EXIT_SESSION') { break }; try { $output = Invoke-Expression $cmd 2>&1; $output | Out-String -Stream; Write-Output \"___ENDOFCOMMAND___\" } catch { $_.Exception.Message; Write-Output \"___ENDOFCOMMAND___\" } } }\"", + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + Verb = "runas" // Request elevation + }; + + _process = Process.Start(startInfo); + if (_process == null) + { + throw new InvalidOperationException("Failed to start PowerShell process"); } - /// - /// Handles the error data received from the PowerShell process. - /// - /// The source of the event. - /// The event data. - private void OnErrorDataReceived(object sender, DataReceivedEventArgs e) + // Clear any startup output + _outputBuffer.Clear(); + _errorBuffer.Clear(); + _outputAvailable.Reset(); + + // Attach event handlers for async reading + _process.OutputDataReceived += OnOutputDataReceived; + _process.ErrorDataReceived += OnErrorDataReceived; + + _process.BeginOutputReadLine(); + _process.BeginErrorReadLine(); + } + + /// + /// Handles the output data received from the PowerShell process. + /// + /// The source of the event. + /// The event data. + private void OnOutputDataReceived(object sender, DataReceivedEventArgs e) + { + if (e.Data != null) { - if (e.Data != null) + lock (_outputBuffer) { - lock (_errorBuffer) + _outputBuffer.AppendLine(e.Data); + if (e.Data.Contains("___ENDOFCOMMAND___")) { - _errorBuffer.AppendLine(e.Data); + _outputAvailable.Set(); } } } - - /// - /// Executes a PowerShell script and returns the output. - /// - /// The PowerShell script to execute. - /// The timeout in seconds for the script execution. - /// The output from PowerShell. - /// Thrown if the PowerShell process is not running. - /// Thrown if the command times out. - public string ExecuteScript(string script, int timeoutSeconds) - { - lock (_lock) - { - EnsureStarted(); - - if (_process == null || _process.HasExited) - { - throw new InvalidOperationException("PowerShell process is not running"); - } - - // Clear previous output and reset the event - lock (_outputBuffer) - { - _outputBuffer.Clear(); - } - lock (_errorBuffer) - { - _errorBuffer.Clear(); - } - _outputAvailable.Reset(); - - // Write the script to stdin using base64 encoding to handle special characters - var encodedScript = Convert.ToBase64String(Encoding.UTF8.GetBytes(script)); - var command = $"[System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encodedScript}')) | Invoke-Expression"; - - _process.StandardInput.WriteLine(command); - - // Wait for output with a timeout - var timeout = TimeSpan.FromSeconds(timeoutSeconds); - if (!_outputAvailable.Wait(timeout)) - { - throw new TimeoutException($"PowerShell command timed out after {timeout.TotalSeconds} seconds.\nScript:\n{script}"); - } - - // Give a bit more time for all output to arrive - Thread.Sleep(50); - - lock (_outputBuffer) - { - var rawOutput = _outputBuffer.ToString(); - _outputBuffer.Clear(); - - // Remove the end marker and any lines that contain our base64 command - var lines = rawOutput.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); - var filteredLines = lines - .Where(line => !line.Contains("___ENDOFCOMMAND___")) - .Where(line => !line.Contains("FromBase64String")) - .Where(line => !line.Contains(encodedScript.Substring(0, Math.Min(20, encodedScript.Length)))) - .ToList(); - - var output = string.Join(Environment.NewLine, filteredLines).Trim(); - - lock (_errorBuffer) - { - var error = _errorBuffer.ToString().Trim(); - _errorBuffer.Clear(); - - if (!string.IsNullOrEmpty(error)) - { - throw new InvalidOperationException($"PowerShell error: {error}\nScript:\n{script}"); - } - } - - return output; - } - } - } - - /// - /// Disposes the PowerShell session. - /// - public void Dispose() - { - lock (_lock) - { - if (_disposed) - { - return; - } - - _disposed = true; - - if (_process != null) - { - // Unregister event handlers - _process.OutputDataReceived -= OnOutputDataReceived; - _process.ErrorDataReceived -= OnErrorDataReceived; - - if (!_process.HasExited) - { - try - { - _process.StandardInput.WriteLine("EXIT_SESSION"); - if (!_process.WaitForExit(5000)) - { - _process.Kill(); - } - } - catch - { - // Ignore errors during cleanup - try { _process.Kill(); } catch { } - } - } - - _process.Dispose(); - _process = null; - } - - _outputAvailable.Dispose(); - } - } } /// - /// Windows implementation of using PowerShell and VHD files. + /// Handles the error data received from the PowerShell process. /// - internal class WindowsDiskImageHelper : IDiskImageHelper + /// The source of the event. + /// The event data. + private void OnErrorDataReceived(object sender, DataReceivedEventArgs e) { - // Static PowerShell session that persists across method calls - private static PowerShellSession? _session; - private static readonly object _sessionLock = new(); - - /// - /// Gets the shared PowerShell session, creating it if necessary. - /// - /// The shared instance. - private static PowerShellSession GetSession() + if (e.Data != null) { - lock (_sessionLock) + lock (_errorBuffer) { - if (_session == null) - { - _session = new PowerShellSession(); - } - return _session; + _errorBuffer.AppendLine(e.Data); } } + } - /// - /// Runs a PowerShell script using the persistent session. - /// - /// The PowerShell script to execute. - /// The timeout in seconds for the script execution. - /// The output from PowerShell. - private static string RunPowerShell(string script, int timeoutSeconds = 30) + /// + /// Executes a PowerShell script and returns the output. + /// + /// The PowerShell script to execute. + /// The timeout in seconds for the script execution. + /// The output from PowerShell. + /// Thrown if the PowerShell process is not running. + /// Thrown if the command times out. + public string ExecuteScript(string script, int timeoutSeconds) + { + lock (_lock) { - var session = GetSession(); - return session.ExecuteScript(script, timeoutSeconds); - } + EnsureStarted(); - /// - /// Extracts the disk number from a disk identifier string (e.g., "\\.\PhysicalDrive2" -> 2). - /// - /// The disk identifier string. - /// The disk number. - private static int ParseDiskNumber(string diskIdentifier) - { - const string prefix = @"\\.\PhysicalDrive"; - if (diskIdentifier.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) - && int.TryParse(diskIdentifier.Substring(prefix.Length), out int num)) - return num; - - throw new ArgumentException($"Invalid disk identifier: {diskIdentifier}. Expected format: \\\\.\\PhysicalDriveN", nameof(diskIdentifier)); - } - - /// - /// Gets the disk number for a VHD image file. - /// - /// The path to the VHD file. - /// The disk number, or -1 if not found. - private static int GetDiskNumber(string imagePath) - { - try + if (_process == null || _process.HasExited) { - var script = $@" + throw new InvalidOperationException("PowerShell process is not running"); + } + + // Clear previous output and reset the event + lock (_outputBuffer) + { + _outputBuffer.Clear(); + } + lock (_errorBuffer) + { + _errorBuffer.Clear(); + } + _outputAvailable.Reset(); + + // Write the script to stdin using base64 encoding to handle special characters + var encodedScript = Convert.ToBase64String(Encoding.UTF8.GetBytes(script)); + var command = $"[System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encodedScript}')) | Invoke-Expression"; + + _process.StandardInput.WriteLine(command); + + // Wait for output with a timeout + var timeout = TimeSpan.FromSeconds(timeoutSeconds); + if (!_outputAvailable.Wait(timeout)) + { + throw new TimeoutException($"PowerShell command timed out after {timeout.TotalSeconds} seconds.\nScript:\n{script}"); + } + + // Give a bit more time for all output to arrive + Thread.Sleep(50); + + lock (_outputBuffer) + { + var rawOutput = _outputBuffer.ToString(); + _outputBuffer.Clear(); + + // Remove the end marker and any lines that contain our base64 command + var lines = rawOutput.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + var filteredLines = lines + .Where(line => !line.Contains("___ENDOFCOMMAND___")) + .Where(line => !line.Contains("FromBase64String")) + .Where(line => !line.Contains(encodedScript.Substring(0, Math.Min(20, encodedScript.Length)))) + .ToList(); + + var output = string.Join(Environment.NewLine, filteredLines).Trim(); + + lock (_errorBuffer) + { + var error = _errorBuffer.ToString().Trim(); + _errorBuffer.Clear(); + + if (!string.IsNullOrEmpty(error)) + { + throw new InvalidOperationException($"PowerShell error: {error}\nScript:\n{script}"); + } + } + + return output; + } + } + } + + /// + /// Disposes the PowerShell session. + /// + public void Dispose() + { + lock (_lock) + { + if (_disposed) + { + return; + } + + _disposed = true; + + if (_process != null) + { + // Unregister event handlers + _process.OutputDataReceived -= OnOutputDataReceived; + _process.ErrorDataReceived -= OnErrorDataReceived; + + if (!_process.HasExited) + { + try + { + _process.StandardInput.WriteLine("EXIT_SESSION"); + if (!_process.WaitForExit(5000)) + { + _process.Kill(); + } + } + catch + { + // Ignore errors during cleanup + try { _process.Kill(); } catch { } + } + } + + _process.Dispose(); + _process = null; + } + + _outputAvailable.Dispose(); + } + } +} + +/// +/// Windows implementation of using PowerShell and VHD files. +/// +internal class WindowsDiskImageHelper : IDiskImageHelper +{ + // Static PowerShell session that persists across method calls + private static PowerShellSession? _session; + private static readonly object _sessionLock = new(); + + /// + /// Gets the shared PowerShell session, creating it if necessary. + /// + /// The shared instance. + private static PowerShellSession GetSession() + { + lock (_sessionLock) + { + if (_session == null) + { + _session = new PowerShellSession(); + } + return _session; + } + } + + /// + /// Runs a PowerShell script using the persistent session. + /// + /// The PowerShell script to execute. + /// The timeout in seconds for the script execution. + /// The output from PowerShell. + private static string RunPowerShell(string script, int timeoutSeconds = 30) + { + var session = GetSession(); + return session.ExecuteScript(script, timeoutSeconds); + } + + /// + /// Extracts the disk number from a disk identifier string (e.g., "\\.\PhysicalDrive2" -> 2). + /// + /// The disk identifier string. + /// The disk number. + private static int ParseDiskNumber(string diskIdentifier) + { + const string prefix = @"\\.\PhysicalDrive"; + if (diskIdentifier.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + && int.TryParse(diskIdentifier.Substring(prefix.Length), out int num)) + return num; + + throw new ArgumentException($"Invalid disk identifier: {diskIdentifier}. Expected format: \\\\.\\PhysicalDriveN", nameof(diskIdentifier)); + } + + /// + /// Gets the disk number for a VHD image file. + /// + /// The path to the VHD file. + /// The disk number, or -1 if not found. + private static int GetDiskNumber(string imagePath) + { + try + { + var script = $@" $image = Get-DiskImage -ImagePath '{imagePath}' if ($image -and $image.Attached) {{ $image | Get-Disk | Select-Object -ExpandProperty Number }} "; - var result = RunPowerShell(script)?.Trim(); + var result = RunPowerShell(script)?.Trim(); - if (!string.IsNullOrEmpty(result) && int.TryParse(result, out int diskNumber)) - return diskNumber; - } - catch (Exception ex) - { - TestContext.Progress.WriteLine($"Warning: PowerShell GetDiskNumber failed: {ex.Message}"); - } - - return -1; + if (!string.IsNullOrEmpty(result) && int.TryParse(result, out int diskNumber)) + return diskNumber; + } + catch (Exception ex) + { + TestContext.Progress.WriteLine($"Warning: PowerShell GetDiskNumber failed: {ex.Message}"); } - /// - /// Gets the drive letters (mount points) for all partitions on a disk. - /// - /// The disk number. - /// An array of mount point paths (e.g., "E:\"). - private static string[] GetMountPoints(int diskNumber) - { - var script = $@" + return -1; + } + + /// + /// Gets the drive letters (mount points) for all partitions on a disk. + /// + /// The disk number. + /// An array of mount point paths (e.g., "E:\"). + private static string[] GetMountPoints(int diskNumber) + { + var script = $@" Get-Partition -DiskNumber {diskNumber} | Where-Object {{ $_.DriveLetter -and $_.DriveLetter -ne [char]0 }} | ForEach-Object {{ $_.DriveLetter }} "; - var result = RunPowerShell(script)?.Trim(); + var result = RunPowerShell(script)?.Trim(); - if (string.IsNullOrEmpty(result)) - return []; + if (string.IsNullOrEmpty(result)) + return []; - return result - .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) - .Select(l => l.Trim()) - .Where(l => l.Length == 1 && char.IsLetter(l[0])) - .Select(l => $"{char.ToUpperInvariant(l[0])}:\\") - .ToArray(); - } + return result + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Select(l => l.Trim()) + .Where(l => l.Length == 1 && char.IsLetter(l[0])) + .Select(l => $"{char.ToUpperInvariant(l[0])}:\\") + .ToArray(); + } - /// - public string CreateDisk(string imagePath, long sizeB) - { - // Ensure the directory exists - var directory = Path.GetDirectoryName(imagePath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - Directory.CreateDirectory(directory); + /// + public string CreateDisk(string imagePath, long sizeB) + { + // Ensure the directory exists + var directory = Path.GetDirectoryName(imagePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + Directory.CreateDirectory(directory); - // Delete existing VHD if it exists - if (File.Exists(imagePath)) - try - { - CleanupDisk(imagePath); - } - catch - { - // Ignore errors during cleanup - } - - // Create the VHD using PowerShell - // Windows GPT initialization adds 16 MB of overhead, which means that the partitions may not have enough disk towards the end. - var overheadBytes = 16 * 1024 * 1024; - var script = $@"New-VHD -Path '{imagePath}' -SizeBytes {sizeB + overheadBytes} -Fixed | Out-Null; Mount-DiskImage -ImagePath '{imagePath}' | Out-Null"; - RunPowerShell(script); - - // Wait for the disk to be attached and get the disk number - int diskNumber = WaitForDiskAttachment(imagePath, TimeSpan.FromSeconds(5)); - if (diskNumber < 0) - throw new InvalidOperationException($"Failed to get disk number for VHD: {imagePath}"); - - return $@"\\.\PhysicalDrive{diskNumber}"; - } - - /// - /// Waits for a VHD to be attached and returns its disk number. - /// - /// The path to the VHD file. - /// Maximum time to wait. - /// The disk number, or -1 if not found within the timeout. - private static int WaitForDiskAttachment(string imagePath, TimeSpan timeout) - { - var startTime = DateTime.UtcNow; - while (DateTime.UtcNow - startTime < timeout) + // Delete existing VHD if it exists + if (File.Exists(imagePath)) + try { - var diskNumber = GetDiskNumber(imagePath); - if (diskNumber >= 0) - return diskNumber; - Thread.Sleep(100); + CleanupDisk(imagePath); + } + catch + { + // Ignore errors during cleanup } - return -1; - } - /// - public string[] InitializeDisk(string diskIdentifier, PartitionTableType tableType, (FileSystemType, long)[] partitions) + // Create the VHD using PowerShell + // Windows GPT initialization adds 16 MB of overhead, which means that the partitions may not have enough disk towards the end. + var overheadBytes = 16 * 1024 * 1024; + var script = $@"New-VHD -Path '{imagePath}' -SizeBytes {sizeB + overheadBytes} -Fixed | Out-Null; Mount-DiskImage -ImagePath '{imagePath}' | Out-Null"; + RunPowerShell(script); + + // Wait for the disk to be attached and get the disk number + int diskNumber = WaitForDiskAttachment(imagePath, TimeSpan.FromSeconds(5)); + if (diskNumber < 0) + throw new InvalidOperationException($"Failed to get disk number for VHD: {imagePath}"); + + return $@"\\.\PhysicalDrive{diskNumber}"; + } + + /// + /// Waits for a VHD to be attached and returns its disk number. + /// + /// The path to the VHD file. + /// Maximum time to wait. + /// The disk number, or -1 if not found within the timeout. + private static int WaitForDiskAttachment(string imagePath, TimeSpan timeout) + { + var startTime = DateTime.UtcNow; + while (DateTime.UtcNow - startTime < timeout) { - if (tableType == PartitionTableType.Unknown) - return []; // No partition table, so nothing to initialize + var diskNumber = GetDiskNumber(imagePath); + if (diskNumber >= 0) + return diskNumber; + Thread.Sleep(100); + } + return -1; + } - var diskNumber = ParseDiskNumber(diskIdentifier); + /// + public string[] InitializeDisk(string diskIdentifier, PartitionTableType tableType, (FileSystemType, long)[] partitions) + { + if (tableType == PartitionTableType.Unknown) + return []; // No partition table, so nothing to initialize - // Initialize the disk with the specified partition style - var script = $@" + var diskNumber = ParseDiskNumber(diskIdentifier); + + // Initialize the disk with the specified partition style + var script = $@" $disk = Get-Disk -Number {diskNumber} if ($disk.PartitionStyle -ne 'RAW') {{ Clear-Disk -Number {diskNumber} -RemoveData -Confirm:$false }} Initialize-Disk -Number {diskNumber} -PartitionStyle {tableType.ToString().ToUpperInvariant()} -PassThru | Out-Null "; - RunPowerShell(script); + RunPowerShell(script); - // Wait for the initialization to complete - WaitForDiskInitialization(diskNumber, TimeSpan.FromSeconds(5)); + // Wait for the initialization to complete + WaitForDiskInitialization(diskNumber, TimeSpan.FromSeconds(5)); - if (partitions.Length == 0) - return []; + if (partitions.Length == 0) + return []; - // Create and format each partition - foreach (var (fsType, sizeB) in partitions) - { - var fsTypeStr = fsType.ToString().ToUpperInvariant(); - var sizeParam = sizeB > 0 ? $"-Size {sizeB}" : "-UseMaximumSize"; + // Create and format each partition + foreach (var (fsType, sizeB) in partitions) + { + var fsTypeStr = fsType.ToString().ToUpperInvariant(); + var sizeParam = sizeB > 0 ? $"-Size {sizeB}" : "-UseMaximumSize"; - script = $@" + script = $@" $partition = New-Partition -DiskNumber {diskNumber} {sizeParam} -AssignDriveLetter # Wait for the partition to be ready $timeout = (Get-Date).AddSeconds(30) @@ -471,51 +471,51 @@ namespace Duplicati.UnitTest.DiskImage # Format the volume Format-Volume -Partition $partition -FileSystem {fsTypeStr} -NewFileSystemLabel 'TestVol' -Confirm:$false | Out-Null "; - RunPowerShell(script); - } - - return GetMountPoints(diskNumber); + RunPowerShell(script); } - /// - /// Waits for a disk to be initialized. - /// - /// The disk number. - /// Maximum time to wait. - /// Thrown if initialization times out. - private static void WaitForDiskInitialization(int diskNumber, TimeSpan timeout) + return GetMountPoints(diskNumber); + } + + /// + /// Waits for a disk to be initialized. + /// + /// The disk number. + /// Maximum time to wait. + /// Thrown if initialization times out. + private static void WaitForDiskInitialization(int diskNumber, TimeSpan timeout) + { + var startTime = DateTime.UtcNow; + while (DateTime.UtcNow - startTime < timeout) { - var startTime = DateTime.UtcNow; - while (DateTime.UtcNow - startTime < timeout) - { - var script = $"(Get-Disk -Number {diskNumber}).PartitionStyle"; - var result = RunPowerShell(script)?.Trim(); - if (result == "GPT" || result == "MBR") - return; - Thread.Sleep(100); - } - throw new TimeoutException($"Disk {diskNumber} initialization timed out"); + var script = $"(Get-Disk -Number {diskNumber}).PartitionStyle"; + var result = RunPowerShell(script)?.Trim(); + if (result == "GPT" || result == "MBR") + return; + Thread.Sleep(100); + } + throw new TimeoutException($"Disk {diskNumber} initialization timed out"); + } + + /// + public string[] Mount(string diskIdentifier, string? baseMountPath = null, bool readOnly = false, FileSystemType[]? fileSystemTypes = null) + { + var diskNumber = ParseDiskNumber(diskIdentifier); + + // Check if already mounted + try + { + var mountPoints = GetMountPoints(diskNumber); + if (mountPoints.Length > 0) + return mountPoints; + } + catch + { + // Ignore errors and try mounting } - /// - public string[] Mount(string diskIdentifier, string? baseMountPath = null, bool readOnly = false, FileSystemType[]? fileSystemTypes = null) - { - var diskNumber = ParseDiskNumber(diskIdentifier); - - // Check if already mounted - try - { - var mountPoints = GetMountPoints(diskNumber); - if (mountPoints.Length > 0) - return mountPoints; - } - catch - { - // Ignore errors and try mounting - } - - // Bring the disk online and assign drive letters - var script = $@" + // Bring the disk online and assign drive letters + var script = $@" Set-Disk -Number {diskNumber} -IsOffline $false Update-Disk -Number {diskNumber} Update-HostStorageCache @@ -523,51 +523,51 @@ namespace Duplicati.UnitTest.DiskImage $_ | Add-PartitionAccessPath -AssignDriveLetter }} "; - RunPowerShell(script); + RunPowerShell(script); - return WaitForMountPoints(diskNumber); - } + return WaitForMountPoints(diskNumber); + } - /// - /// Waits for mount points to be fully established after a mount operation. - /// Uses exponential backoff starting from a short delay to minimize wait time - /// in the common case while still handling slow mounts. - /// - /// The disk number to check mount points for. - /// An array of mount point paths. - private static string[] WaitForMountPoints(int diskNumber) + /// + /// Waits for mount points to be fully established after a mount operation. + /// Uses exponential backoff starting from a short delay to minimize wait time + /// in the common case while still handling slow mounts. + /// + /// The disk number to check mount points for. + /// An array of mount point paths. + private static string[] WaitForMountPoints(int diskNumber) + { + const int maxRetries = 2; + + for (int i = 0; i < maxRetries; i++) { - const int maxRetries = 2; + var mountPoints = GetMountPoints(diskNumber); - for (int i = 0; i < maxRetries; i++) + if (mountPoints.Length > 0) { - var mountPoints = GetMountPoints(diskNumber); - - if (mountPoints.Length > 0) + // Check if all mount points have filesystem entries (not empty) + var allNonEmpty = mountPoints.All(mp => { - // Check if all mount points have filesystem entries (not empty) - var allNonEmpty = mountPoints.All(mp => - { - try { return Directory.GetFileSystemEntries(mp).Length > 0; } - catch { return false; } - }); + try { return Directory.GetFileSystemEntries(mp).Length > 0; } + catch { return false; } + }); - if (allNonEmpty || i == maxRetries - 1) - return mountPoints; - } + if (allNonEmpty || i == maxRetries - 1) + return mountPoints; } - - // Final attempt - return whatever we have - return GetMountPoints(diskNumber); } - /// - public void Unmount(string diskIdentifier) - { - var diskNumber = ParseDiskNumber(diskIdentifier); + // Final attempt - return whatever we have + return GetMountPoints(diskNumber); + } - // Remove drive letters from all partitions - var script = $@" + /// + public void Unmount(string diskIdentifier) + { + var diskNumber = ParseDiskNumber(diskIdentifier); + + // Remove drive letters from all partitions + var script = $@" Get-Partition -DiskNumber {diskNumber} | Where-Object {{ $_.DriveLetter -and $_.DriveLetter -ne [char]0 }} | ForEach-Object {{ @@ -575,145 +575,145 @@ namespace Duplicati.UnitTest.DiskImage Remove-PartitionAccessPath -DiskNumber {diskNumber} -PartitionNumber $_.PartitionNumber -AccessPath ""$($letter):\"" }} "; - RunPowerShell(script); + RunPowerShell(script); - // Pull the disk offline to ensure it's not in use - script = $@" + // Pull the disk offline to ensure it's not in use + script = $@" Set-Disk -Number {diskNumber} -IsOffline $true Set-Disk -Number {diskNumber} -IsReadOnly $false "; - RunPowerShell(script); - } + RunPowerShell(script); + } - /// - public void FlushDisk(string diskIdentifier) + /// + public void FlushDisk(string diskIdentifier) + { + var diskNumber = ParseDiskNumber(diskIdentifier); + try { - var diskNumber = ParseDiskNumber(diskIdentifier); - try - { - // Flush all volumes on the disk - var script = $@" + // Flush all volumes on the disk + var script = $@" Get-Partition -DiskNumber {diskNumber} | Where-Object {{ $_.DriveLetter -and $_.DriveLetter -ne [char]0 }} | ForEach-Object {{ Write-VolumeCache -DriveLetter $_.DriveLetter }} "; - RunPowerShell(script); - } - catch (Exception ex) - { - TestContext.Progress.WriteLine($"Warning: Failed to flush volume cache: {ex.Message}"); - } + RunPowerShell(script); } - - /// - public void CleanupDisk(string imagePath, string? diskIdentifier = null) + catch (Exception ex) { - try - { - var script = $@" + TestContext.Progress.WriteLine($"Warning: Failed to flush volume cache: {ex.Message}"); + } + } + + /// + public void CleanupDisk(string imagePath, string? diskIdentifier = null) + { + try + { + var script = $@" $image = Get-DiskImage -ImagePath '{imagePath}' if ($image -and $image.Attached) {{ Dismount-DiskImage -ImagePath '{imagePath}' }} "; - RunPowerShell(script); - } - catch (Exception ex) - { - TestContext.Progress.WriteLine($"Warning: Failed to detach disk image: {ex.Message}"); - } - - try - { - if (File.Exists(imagePath)) - File.Delete(imagePath); - } - catch (Exception ex) - { - TestContext.Progress.WriteLine($"Warning: Failed to delete disk image file: {ex.Message}"); - } + RunPowerShell(script); + } + catch (Exception ex) + { + TestContext.Progress.WriteLine($"Warning: Failed to detach disk image: {ex.Message}"); } - /// - public bool HasRequiredPrivileges() + try { - if (!OperatingSystem.IsWindows()) - return false; - - try - { - using var identity = WindowsIdentity.GetCurrent(); - var principal = new WindowsPrincipal(identity); - return principal.IsInRole(WindowsBuiltInRole.Administrator); - } - catch - { - return false; - } + if (File.Exists(imagePath)) + File.Delete(imagePath); } - - /// - public PartitionTableGeometry GetPartitionTable(string diskIdentifier) + catch (Exception ex) { - var diskNumber = ParseDiskNumber(diskIdentifier); + TestContext.Progress.WriteLine($"Warning: Failed to delete disk image file: {ex.Message}"); + } + } - var script = $@" + /// + public bool HasRequiredPrivileges() + { + if (!OperatingSystem.IsWindows()) + return false; + + try + { + using var identity = WindowsIdentity.GetCurrent(); + var principal = new WindowsPrincipal(identity); + return principal.IsInRole(WindowsBuiltInRole.Administrator); + } + catch + { + return false; + } + } + + /// + public PartitionTableGeometry GetPartitionTable(string diskIdentifier) + { + var diskNumber = ParseDiskNumber(diskIdentifier); + + var script = $@" $disk = Get-Disk -Number {diskNumber} Write-Output ""PartitionStyle:$($disk.PartitionStyle)"" Write-Output ""Size:$($disk.Size)"" Write-Output ""LogicalSectorSize:$($disk.LogicalSectorSize)"" "; - var output = RunPowerShell(script); + var output = RunPowerShell(script); - PartitionTableType tableType = PartitionTableType.Unknown; - long size = -1; - int sectorSize = -1; + PartitionTableType tableType = PartitionTableType.Unknown; + long size = -1; + int sectorSize = -1; - foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + var parts = line.Split([':'], 2); + if (parts.Length != 2) continue; + + switch (parts[0].Trim()) { - var parts = line.Split([':'], 2); - if (parts.Length != 2) continue; - - switch (parts[0].Trim()) - { - case "PartitionStyle": - tableType = parts[1].Trim() switch - { - "GPT" => PartitionTableType.GPT, - "MBR" => PartitionTableType.MBR, - _ => PartitionTableType.Unknown - }; - break; - case "Size": - if (long.TryParse(parts[1].Trim(), out long parsedSize)) - size = parsedSize; - break; - case "LogicalSectorSize": - if (int.TryParse(parts[1].Trim(), out int parsedSectorSize)) - sectorSize = parsedSectorSize; - break; - } + case "PartitionStyle": + tableType = parts[1].Trim() switch + { + "GPT" => PartitionTableType.GPT, + "MBR" => PartitionTableType.MBR, + _ => PartitionTableType.Unknown + }; + break; + case "Size": + if (long.TryParse(parts[1].Trim(), out long parsedSize)) + size = parsedSize; + break; + case "LogicalSectorSize": + if (int.TryParse(parts[1].Trim(), out int parsedSectorSize)) + sectorSize = parsedSectorSize; + break; } - - if (size == -1 || sectorSize == -1) - throw new InvalidOperationException($"Failed to retrieve partition table information for disk {diskIdentifier}"); - - return new PartitionTableGeometry - { - Type = tableType, - Size = size, - SectorSize = sectorSize - }; } - /// - public PartitionGeometry[] GetPartitions(string diskIdentifier) - { - var diskNumber = ParseDiskNumber(diskIdentifier); + if (size == -1 || sectorSize == -1) + throw new InvalidOperationException($"Failed to retrieve partition table information for disk {diskIdentifier}"); - var script = $@" + return new PartitionTableGeometry + { + Type = tableType, + Size = size, + SectorSize = sectorSize + }; + } + + /// + public PartitionGeometry[] GetPartitions(string diskIdentifier) + { + var diskNumber = ParseDiskNumber(diskIdentifier); + + var script = $@" Get-Partition -DiskNumber {diskNumber} | Where-Object {{ $_.Type -ne 'Reserved' -and $_.Type -ne 'System' }} | ForEach-Object {{ @@ -731,96 +731,96 @@ namespace Duplicati.UnitTest.DiskImage }} }} "; - var output = RunPowerShell(script); + var output = RunPowerShell(script); - var partitions = new List(); - PartitionGeometry? current = null; + var partitions = new List(); + PartitionGeometry? current = null; - foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (line.Trim() == "---PARTITION---") { - if (line.Trim() == "---PARTITION---") + if (current != null) + partitions.Add(current); + current = new PartitionGeometry { - if (current != null) - partitions.Add(current); - current = new PartitionGeometry - { - Number = -1, - Type = PartitionType.Unknown, - StartOffset = -1, - Size = -1, - Name = null, - FilesystemType = FileSystemType.Unknown, - VolumeGuid = null, - TableType = PartitionTableType.Unknown - }; - continue; - } - - if (current == null) continue; - - var parts = line.Split([':'], 2); - if (parts.Length != 2) continue; - - switch (parts[0].Trim()) - { - case "Number": - if (int.TryParse(parts[1].Trim(), out int num)) - current.Number = num; - break; - case "Type": - current.Type = parts[1].Trim() switch - { - "Basic" => PartitionType.Primary, - "IFS" => PartitionType.Primary, - _ => PartitionType.Unknown - }; - break; - case "Offset": - if (long.TryParse(parts[1].Trim(), out long offset)) - current.StartOffset = offset; - break; - case "Size": - if (long.TryParse(parts[1].Trim(), out long size)) - current.Size = size; - break; - case "Guid": - if (Guid.TryParse(parts[1].Trim(), out Guid guid)) - current.VolumeGuid = guid; - break; - case "FileSystem": - current.FilesystemType = parts[1].Trim() switch - { - "NTFS" => FileSystemType.NTFS, - "FAT32" => FileSystemType.FAT32, - "exFAT" => FileSystemType.ExFAT, - "FAT" => FileSystemType.FAT32, - "ReFS" => FileSystemType.ReFS, - _ => FileSystemType.Unknown - }; - break; - case "FileSystemLabel": - current.Name = parts[1].Trim(); - break; - } + Number = -1, + Type = PartitionType.Unknown, + StartOffset = -1, + Size = -1, + Name = null, + FilesystemType = FileSystemType.Unknown, + VolumeGuid = null, + TableType = PartitionTableType.Unknown + }; + continue; } - if (current != null) - partitions.Add(current); + if (current == null) continue; - return partitions.ToArray(); + var parts = line.Split([':'], 2); + if (parts.Length != 2) continue; + + switch (parts[0].Trim()) + { + case "Number": + if (int.TryParse(parts[1].Trim(), out int num)) + current.Number = num; + break; + case "Type": + current.Type = parts[1].Trim() switch + { + "Basic" => PartitionType.Primary, + "IFS" => PartitionType.Primary, + _ => PartitionType.Unknown + }; + break; + case "Offset": + if (long.TryParse(parts[1].Trim(), out long offset)) + current.StartOffset = offset; + break; + case "Size": + if (long.TryParse(parts[1].Trim(), out long size)) + current.Size = size; + break; + case "Guid": + if (Guid.TryParse(parts[1].Trim(), out Guid guid)) + current.VolumeGuid = guid; + break; + case "FileSystem": + current.FilesystemType = parts[1].Trim() switch + { + "NTFS" => FileSystemType.NTFS, + "FAT32" => FileSystemType.FAT32, + "exFAT" => FileSystemType.ExFAT, + "FAT" => FileSystemType.FAT32, + "ReFS" => FileSystemType.ReFS, + _ => FileSystemType.Unknown + }; + break; + case "FileSystemLabel": + current.Name = parts[1].Trim(); + break; + } } - /// - public string ReAttach(string imagePath, string diskIdentifier, PartitionTableType tableType, bool readOnly = false) - { - // Unmount the disk first to ensure it's in a clean state - Unmount(diskIdentifier); + if (current != null) + partitions.Add(current); - // Flush any pending writes and pull the disk offline - FlushDisk(diskIdentifier); + return partitions.ToArray(); + } - // Re-attach the disk image with the desired read-only state - var script = $@" + /// + public string ReAttach(string imagePath, string diskIdentifier, PartitionTableType tableType, bool readOnly = false) + { + // Unmount the disk first to ensure it's in a clean state + Unmount(diskIdentifier); + + // Flush any pending writes and pull the disk offline + FlushDisk(diskIdentifier); + + // Re-attach the disk image with the desired read-only state + var script = $@" $image = Get-DiskImage -ImagePath '{imagePath}' if ($image) {{ $disk = $image | Get-Disk @@ -836,24 +836,23 @@ namespace Duplicati.UnitTest.DiskImage throw 'Disk image not found: {imagePath}' }} "; - var output = RunPowerShell(script, 300); + var output = RunPowerShell(script, 300); - // Wait for the disk to be attached and get the new disk number - int newDiskNumber = WaitForDiskAttachment(imagePath, TimeSpan.FromSeconds(5)); - if (newDiskNumber < 0) - throw new InvalidOperationException($"Failed to re-attach VHD: {imagePath}"); + // Wait for the disk to be attached and get the new disk number + int newDiskNumber = WaitForDiskAttachment(imagePath, TimeSpan.FromSeconds(5)); + if (newDiskNumber < 0) + throw new InvalidOperationException($"Failed to re-attach VHD: {imagePath}"); - return $@"\\.\PhysicalDrive{newDiskNumber}"; - } + return $@"\\.\PhysicalDrive{newDiskNumber}"; + } - /// - public void Dispose() + /// + public void Dispose() + { + lock (_sessionLock) { - lock (_sessionLock) - { - _session?.Dispose(); - _session = null; - } + _session?.Dispose(); + _session = null; } } } diff --git a/proprietary/DiskImage/Filesystem/Fat32/Fat32.cs b/proprietary/DiskImage/Filesystem/Fat32/Fat32.cs index fb4587ec4..34edcbf67 100644 --- a/proprietary/DiskImage/Filesystem/Fat32/Fat32.cs +++ b/proprietary/DiskImage/Filesystem/Fat32/Fat32.cs @@ -12,7 +12,7 @@ using Duplicati.Proprietary.DiskImage.Disk; using Duplicati.Proprietary.DiskImage.General; using Duplicati.Proprietary.DiskImage.Partition; -namespace Duplicati.Proprietary.DiskImage.Filesystem; +namespace Duplicati.Proprietary.DiskImage.Filesystem.Fat32; /// /// Metadata for FAT32 filesystems, storing cluster and block size information. diff --git a/proprietary/DiskImage/Filesystem/Fat32/Fat32BootSector.cs b/proprietary/DiskImage/Filesystem/Fat32/Fat32BootSector.cs index 0c336f1c7..c0f5f0aa7 100644 --- a/proprietary/DiskImage/Filesystem/Fat32/Fat32BootSector.cs +++ b/proprietary/DiskImage/Filesystem/Fat32/Fat32BootSector.cs @@ -4,7 +4,7 @@ using System; using System.Buffers.Binary; using System.Text; -namespace Duplicati.Proprietary.DiskImage.Filesystem; +namespace Duplicati.Proprietary.DiskImage.Filesystem.Fat32; /// /// Represents a parsed FAT32 Boot Sector (BIOS Parameter Block). diff --git a/proprietary/DiskImage/Filesystem/Fat32/Fat32DirectoryWalker.cs b/proprietary/DiskImage/Filesystem/Fat32/Fat32DirectoryWalker.cs index c4779b4da..a33ea423f 100644 --- a/proprietary/DiskImage/Filesystem/Fat32/Fat32DirectoryWalker.cs +++ b/proprietary/DiskImage/Filesystem/Fat32/Fat32DirectoryWalker.cs @@ -8,7 +8,7 @@ using System.Threading; using System.Threading.Tasks; using Duplicati.Proprietary.DiskImage.Partition; -namespace Duplicati.Proprietary.DiskImage.Filesystem; +namespace Duplicati.Proprietary.DiskImage.Filesystem.Fat32; /// /// Walks the FAT32 directory tree to extract file metadata and build a cluster-to-timestamp map. diff --git a/proprietary/DiskImage/Filesystem/Fat32/Fat32Table.cs b/proprietary/DiskImage/Filesystem/Fat32/Fat32Table.cs index c3f4aa6d1..a5f2a4e5e 100644 --- a/proprietary/DiskImage/Filesystem/Fat32/Fat32Table.cs +++ b/proprietary/DiskImage/Filesystem/Fat32/Fat32Table.cs @@ -9,7 +9,7 @@ using System.Threading; using System.Threading.Tasks; using Duplicati.Proprietary.DiskImage.Partition; -namespace Duplicati.Proprietary.DiskImage.Filesystem; +namespace Duplicati.Proprietary.DiskImage.Filesystem.Fat32; /// /// Reads and provides access to the FAT32 File Allocation Table (FAT). diff --git a/proprietary/DiskImage/General/Crc32.cs b/proprietary/DiskImage/General/Crc32.cs index 60b5261c6..1739b35d9 100644 --- a/proprietary/DiskImage/General/Crc32.cs +++ b/proprietary/DiskImage/General/Crc32.cs @@ -1,6 +1,6 @@ // Copyright (c) 2026 Duplicati Inc. All rights reserved. -namespace Duplicati.Proprietary.DiskImage.Partition; +namespace Duplicati.Proprietary.DiskImage.General; /// /// Provides CRC32 calculation utilities for partition table operations. diff --git a/proprietary/DiskImage/General/PooledBuffer.cs b/proprietary/DiskImage/General/PooledBuffer.cs index 8c41df663..fdf660341 100644 --- a/proprietary/DiskImage/General/PooledBuffer.cs +++ b/proprietary/DiskImage/General/PooledBuffer.cs @@ -1,7 +1,7 @@ using System; using System.Buffers; -namespace Duplicati.Proprietary.DiskImage.Disk; +namespace Duplicati.Proprietary.DiskImage.General; /// /// A disposable struct that rents a byte array from the shared ArrayPool and returns it when disposed. diff --git a/proprietary/DiskImage/General/PooledMemoryStream.cs b/proprietary/DiskImage/General/PooledMemoryStream.cs index 0f7dbd306..7e8b80270 100644 --- a/proprietary/DiskImage/General/PooledMemoryStream.cs +++ b/proprietary/DiskImage/General/PooledMemoryStream.cs @@ -2,7 +2,7 @@ using System; using System.Buffers; using System.IO; -namespace Duplicati.Proprietary.DiskImage.Disk; +namespace Duplicati.Proprietary.DiskImage.General; /// /// A read-only stream that wraps a pooled byte array and returns it to the pool when disposed. diff --git a/proprietary/DiskImage/Partition/GPT.cs b/proprietary/DiskImage/Partition/GPT.cs index 9d46bb836..961f1a662 100644 --- a/proprietary/DiskImage/Partition/GPT.cs +++ b/proprietary/DiskImage/Partition/GPT.cs @@ -7,7 +7,7 @@ using System.Threading; using System.Threading.Tasks; using Duplicati.Library.Utility; using Duplicati.Proprietary.DiskImage.Disk; -using Duplicati.Proprietary.DiskImage.Filesystem; +using Duplicati.Proprietary.DiskImage.Filesystem.Fat32; using Duplicati.Proprietary.DiskImage.General; namespace Duplicati.Proprietary.DiskImage.Partition; diff --git a/proprietary/DiskImage/Partition/MBR.cs b/proprietary/DiskImage/Partition/MBR.cs index 6fd2ad194..a118dd035 100644 --- a/proprietary/DiskImage/Partition/MBR.cs +++ b/proprietary/DiskImage/Partition/MBR.cs @@ -6,7 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Duplicati.Library.Utility; using Duplicati.Proprietary.DiskImage.Disk; -using Duplicati.Proprietary.DiskImage.Filesystem; +using Duplicati.Proprietary.DiskImage.Filesystem.Fat32; using Duplicati.Proprietary.DiskImage.General; namespace Duplicati.Proprietary.DiskImage.Partition; diff --git a/proprietary/DiskImage/RestoreProvider.cs b/proprietary/DiskImage/RestoreProvider.cs index db8b50162..e43beacec 100644 --- a/proprietary/DiskImage/RestoreProvider.cs +++ b/proprietary/DiskImage/RestoreProvider.cs @@ -12,6 +12,7 @@ using Duplicati.Library.Logging; using Duplicati.Library.Utility; using Duplicati.Proprietary.DiskImage.Disk; using Duplicati.Proprietary.DiskImage.Filesystem; +using Duplicati.Proprietary.DiskImage.Filesystem.Fat32; using Duplicati.Proprietary.DiskImage.General; using Duplicati.Proprietary.DiskImage.Partition; diff --git a/proprietary/DiskImage/SourceItems/FilesystemSourceEntry.cs b/proprietary/DiskImage/SourceItems/FilesystemSourceEntry.cs index a7f8dd406..1a194f05c 100644 --- a/proprietary/DiskImage/SourceItems/FilesystemSourceEntry.cs +++ b/proprietary/DiskImage/SourceItems/FilesystemSourceEntry.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using Duplicati.Library.Interface; using Duplicati.Proprietary.DiskImage.Filesystem; +using Duplicati.Proprietary.DiskImage.Filesystem.Fat32; using Duplicati.Proprietary.DiskImage.General; namespace Duplicati.Proprietary.DiskImage.SourceItems; diff --git a/proprietary/DiskImage/SourceItems/PartitionSourceEntry.cs b/proprietary/DiskImage/SourceItems/PartitionSourceEntry.cs index bd0c5cea7..01fab7965 100644 --- a/proprietary/DiskImage/SourceItems/PartitionSourceEntry.cs +++ b/proprietary/DiskImage/SourceItems/PartitionSourceEntry.cs @@ -11,6 +11,7 @@ using Duplicati.Proprietary.DiskImage.Partition; using Duplicati.Proprietary.DiskImage.Filesystem; using Duplicati.Library.Utility; using Duplicati.Proprietary.DiskImage.General; +using Duplicati.Proprietary.DiskImage.Filesystem.Fat32; namespace Duplicati.Proprietary.DiskImage.SourceItems;