// Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Duplicati.Library.Interface; using Duplicati.Library.Main; using Duplicati.Proprietary.DiskImage; using NUnit.Framework; using Assert = NUnit.Framework.Legacy.ClassicAssert; #nullable enable namespace Duplicati.UnitTest { /// /// Unit tests for the DiskImage backup and restore functionality. /// These tests use VHD files managed via diskpart to test the full backup and restore flow. /// [TestFixture] [Category("DiskImage")] [Platform("Win")] public class DiskImageTests : BasicSetupHelper { private string _sourceVhdPath = null!; private string _restoreVhdPath = null!; private int _sourceDiskNumber = -1; private int _restoreDiskNumber = -1; private char _sourceDriveLetter; private DiskImage.IDiskImageHelper _diskHelper = null!; /// /// Sets up the test environment before each test. /// Creates the disk helper and temporary VHD paths. /// [SetUp] public void DiskImageSetUp() { // 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 VHD paths var tempPath = Path.GetTempPath(); _sourceVhdPath = Path.Combine(tempPath, $"duplicati_test_source_{Guid.NewGuid()}.vhdx"); _restoreVhdPath = Path.Combine(tempPath, $"duplicati_test_restore_{Guid.NewGuid()}.vhdx"); } /// /// Cleans up the test environment after each test. /// Detaches and deletes VHD files. /// [TearDown] public void DiskImageTearDown() { // Cleanup VHDs try { _diskHelper.CleanupDisk(_sourceVhdPath); } catch (Exception ex) { TestContext.Progress.WriteLine($"Warning: Failed to cleanup source VHD: {ex.Message}"); } try { _diskHelper.CleanupDisk(_restoreVhdPath); } catch (Exception ex) { TestContext.Progress.WriteLine($"Warning: Failed to cleanup restore VHD: {ex.Message}"); } try { _diskHelper.Dispose(); } catch (Exception ex) { TestContext.Progress.WriteLine($"Warning: Failed to dispose disk helper: {ex.Message}"); } } /// /// Test Scenario 1: GPT + NTFS - Single Partition Backup and Restore /// [Test] [Category("DiskImage")] public void Test_GptNtfs_SinglePartition_BackupAndRestore() { TestContext.Progress.WriteLine("Test: GPT + NTFS Single Partition Backup and Restore"); // Setup: Create 100MB VHD, GPT, single NTFS partition with test data var physicalDrivePath = SetupSourceVhd(100, PartitionTableType.GPT, FileSystemType.NTFS); TestContext.Progress.WriteLine($"Source VHD created at: {_sourceVhdPath}"); TestContext.Progress.WriteLine($"Physical drive path: {physicalDrivePath}"); // Backup via Controller var backupResults = RunBackup(physicalDrivePath); TestContext.Progress.WriteLine($"Backup completed. Files examined: {backupResults.ExaminedFiles}, Size: {backupResults.SizeOfExaminedFiles}"); // Assert backup completed with no errors/warnings TestUtils.AssertResults(backupResults); // Verify backup created expected files var remoteFiles = Directory.GetFiles(TARGETFOLDER); Assert.That(remoteFiles.Length, Is.GreaterThan(0), "Backup should create remote files"); TestContext.Progress.WriteLine($"Remote files created: {remoteFiles.Length}"); // Setup restore target VHD var restoreDrivePath = SetupRestoreVhd(100, PartitionTableType.GPT, FileSystemType.NTFS); TestContext.Progress.WriteLine($"Restore VHD created at: {_restoreVhdPath}"); // Restore to target VHD var restoreResults = RunRestore(restoreDrivePath); TestContext.Progress.WriteLine($"Restore completed. Files restored: {restoreResults.RestoredFiles}, Size: {restoreResults.SizeOfRestoredFiles}"); // Assert restore completed with no errors/warnings TestUtils.AssertResults(restoreResults); // Verify restored data matches original VerifyRestoredData(_sourceDriveLetter); } /// /// Test Scenario 2: MBR + FAT32 - Single Partition Backup and Restore /// [Test] [Category("DiskImage")] public void Test_MbrFat32_SinglePartition_BackupAndRestore() { TestContext.Progress.WriteLine("Test: MBR + FAT32 Single Partition Backup and Restore"); // Setup: Create 50MB VHD, MBR, single FAT32 partition with test data var physicalDrivePath = SetupSourceVhd(50, PartitionTableType.MBR, FileSystemType.FAT32); TestContext.Progress.WriteLine($"Source VHD created at: {_sourceVhdPath}"); TestContext.Progress.WriteLine($"Physical drive path: {physicalDrivePath}"); // Backup via Controller var backupResults = RunBackup(physicalDrivePath); TestContext.Progress.WriteLine($"Backup completed. Files examined: {backupResults.ExaminedFiles}"); // Assert backup completed with no errors/warnings TestUtils.AssertResults(backupResults); // Setup restore target VHD var restoreDrivePath = SetupRestoreVhd(50, PartitionTableType.MBR, FileSystemType.FAT32); TestContext.Progress.WriteLine($"Restore VHD created at: {_restoreVhdPath}"); // Restore to target VHD var restoreResults = RunRestore(restoreDrivePath); TestContext.Progress.WriteLine($"Restore completed. Files restored: {restoreResults.RestoredFiles}"); // Assert restore completed with no errors/warnings TestUtils.AssertResults(restoreResults); // Verify restored data matches original VerifyRestoredData(_sourceDriveLetter); } /// /// Test Scenario 3: GPT + Multiple Partitions - NTFS + FAT32 /// [Test] [Category("DiskImage")] public void Test_Gpt_MultiplePartitions_Backup() { TestContext.Progress.WriteLine("Test: GPT + Multiple Partitions (NTFS + FAT32) Backup"); // Setup: Create 200MB VHD, GPT, two partitions var physicalDrivePath = SetupSourceVhdMultiplePartitions(200, PartitionTableType.GPT, new[] { (FileSystemType.NTFS, 100), (FileSystemType.FAT32, 50) }); TestContext.Progress.WriteLine($"Source VHD created at: {_sourceVhdPath}"); TestContext.Progress.WriteLine($"Physical drive path: {physicalDrivePath}"); // Backup via Controller var backupResults = RunBackup(physicalDrivePath); TestContext.Progress.WriteLine($"Backup completed. Files examined: {backupResults.ExaminedFiles}"); // Assert backup completed with no errors/warnings TestUtils.AssertResults(backupResults); // Verify geometry metadata contains both partitions VerifyGeometryMetadata(); } /// /// Test Scenario 4: Full Round-Trip - Backup + Restore to Second VHD /// [Test] [Category("DiskImage")] public void Test_FullRoundTrip_BackupAndRestore() { TestContext.Progress.WriteLine("Test: Full Round-Trip Backup + Restore"); // Setup source VHD: 100MB, GPT, single NTFS partition var sourceDrivePath = SetupSourceVhd(100, PartitionTableType.GPT, FileSystemType.NTFS); TestContext.Progress.WriteLine($"Source VHD created at: {_sourceVhdPath}"); // Backup var backupResults = RunBackup(sourceDrivePath); TestUtils.AssertResults(backupResults); TestContext.Progress.WriteLine($"Backup completed successfully"); // Setup restore target VHD with same geometry var restoreDrivePath = SetupRestoreVhd(100, PartitionTableType.GPT, FileSystemType.NTFS); TestContext.Progress.WriteLine($"Restore VHD created at: {_restoreVhdPath}"); // Restore var restoreResults = RunRestore(restoreDrivePath); TestUtils.AssertResults(restoreResults); TestContext.Progress.WriteLine($"Restore completed successfully"); // Verify partition table matches VerifyPartitionTableMatches(); // Verify data matches byte-for-byte VerifyRestoredData(_sourceDriveLetter); } /// /// Test Scenario 5: Geometry Metadata Verification /// [Test] [Category("DiskImage")] public void Test_GeometryMetadata_Verification() { TestContext.Progress.WriteLine("Test: Geometry Metadata Verification"); // Setup: Create GPT disk with 2 NTFS partitions var physicalDrivePath = SetupSourceVhdMultiplePartitions(200, PartitionTableType.GPT, new[] { (FileSystemType.NTFS, 80), (FileSystemType.NTFS, 60) }); TestContext.Progress.WriteLine($"Source VHD created at: {_sourceVhdPath}"); // Backup var backupResults = RunBackup(physicalDrivePath); 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(); } /// /// Test Scenario 6: SourceProvider Enumeration /// [Test] [Category("DiskImage")] public async Task Test_SourceProvider_Enumeration() { TestContext.Progress.WriteLine("Test: SourceProvider Enumeration"); // Setup: Create GPT disk with one NTFS partition var physicalDrivePath = SetupSourceVhd(100, PartitionTableType.GPT, FileSystemType.NTFS); TestContext.Progress.WriteLine($"Source VHD created at: {_sourceVhdPath}"); // 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://{physicalDrivePath}"; 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 Scenario 7: MBR + Multiple Partitions /// [Test] [Category("DiskImage")] public void Test_Mbr_MultiplePartitions_Backup() { TestContext.Progress.WriteLine("Test: MBR + Multiple Partitions Backup"); // Setup: Create 150MB VHD, MBR, two partitions (NTFS + FAT32) var physicalDrivePath = SetupSourceVhdMultiplePartitions(150, PartitionTableType.MBR, new[] { (FileSystemType.NTFS, 70), (FileSystemType.FAT32, 40) }); TestContext.Progress.WriteLine($"Source VHD created at: {_sourceVhdPath}"); // Backup var backupResults = RunBackup(physicalDrivePath); TestUtils.AssertResults(backupResults); TestContext.Progress.WriteLine($"Backup completed. Files examined: {backupResults.ExaminedFiles}"); // Verify both partitions are captured VerifyGeometryMetadata(); // Verify partition table type is MBR in geometry metadata VerifyPartitionTableType(PartitionTableType.MBR); } /// /// Test Scenario 8: Auto Unmount Option - Verify that restore fails when disk is online /// and succeeds when auto-unmount option is enabled. /// [Test] [Category("DiskImage")] public void Test_AutoUnmountOption_RestoreWhileOnline() { TestContext.Progress.WriteLine("Test: Auto Unmount Option - Restore While Disk Online"); // Setup source VHD: 100MB, GPT, single NTFS partition var sourceDrivePath = SetupSourceVhd(100, PartitionTableType.GPT, FileSystemType.NTFS); TestContext.Progress.WriteLine($"Source VHD created at: {_sourceVhdPath}"); // Backup var backupResults = RunBackup(sourceDrivePath); TestUtils.AssertResults(backupResults); TestContext.Progress.WriteLine($"Backup completed successfully"); // Create and attach VHD var restoreDrivePath = _diskHelper.CreateAndAttachDisk(_restoreVhdPath, 100); _restoreDiskNumber = _diskHelper.GetDiskNumber(_restoreVhdPath); // Initialize disk (the restore will overwrite this, but we need it formatted) _diskHelper.InitializeDisk(_restoreDiskNumber, PartitionTableType.GPT); _diskHelper.CreateAndFormatPartition(_restoreDiskNumber, FileSystemType.NTFS); TestContext.Progress.WriteLine($"Restore VHD created and kept online at: {_restoreVhdPath}"); // 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 } } // 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); } TestContext.Progress.WriteLine($"Restore completed successfully with auto-unmount option"); // Verify partition table matches VerifyPartitionTableMatches(); // Verify data matches byte-for-byte VerifyRestoredData(_sourceDriveLetter); } #region Helper Methods /// /// Sets up a source VHD with a single partition. /// /// The size of the VHD in megabytes. /// The partition table type (GPT or MBR). /// The filesystem type (NTFS, FAT32, EXT4, etc.). /// The physical drive path. private string SetupSourceVhd(int sizeMB, PartitionTableType tableType, FileSystemType fsType) { // Create and attach VHD var physicalDrivePath = _diskHelper.CreateAndAttachDisk(_sourceVhdPath, sizeMB); _sourceDiskNumber = _diskHelper.GetDiskNumber(_sourceVhdPath); // Initialize disk _diskHelper.InitializeDisk(_sourceDiskNumber, tableType); // Create and format partition _sourceDriveLetter = _diskHelper.CreateAndFormatPartition(_sourceDiskNumber, fsType); // Populate with test data _diskHelper.PopulateTestData(_sourceDriveLetter); _diskHelper.FlushVolume(_sourceDriveLetter); return physicalDrivePath; } /// /// Sets up a source VHD with multiple partitions. /// /// The size of the VHD in megabytes. /// The partition table type (GPT or MBR). /// Array of tuples containing filesystem type and size for each partition. /// The physical drive path. private string SetupSourceVhdMultiplePartitions(int sizeMB, PartitionTableType tableType, (FileSystemType fsType, int sizeMB)[] partitions) { // Create and attach VHD var physicalDrivePath = _diskHelper.CreateAndAttachDisk(_sourceVhdPath, sizeMB); _sourceDiskNumber = _diskHelper.GetDiskNumber(_sourceVhdPath); // Initialize disk _diskHelper.InitializeDisk(_sourceDiskNumber, tableType); // Create and format partitions for (int i = 0; i < partitions.Length; i++) { var (fsType, partSize) = partitions[i]; var driveLetter = _diskHelper.CreateAndFormatPartition(_sourceDiskNumber, fsType, partSize); _diskHelper.PopulateTestData(driveLetter, 5, 5); // Smaller data set for multiple partitions _diskHelper.FlushVolume(driveLetter); if (i == 0) { _sourceDriveLetter = driveLetter; } } return physicalDrivePath; } /// /// Sets up a restore target VHD with the specified geometry. /// /// The size of the VHD in megabytes. /// The partition table type (GPT or MBR). /// The filesystem type (NTFS, FAT32, EXT4, etc.). /// The physical drive path. private string SetupRestoreVhd(int sizeMB, PartitionTableType tableType, FileSystemType fsType) { // Create and attach VHD var physicalDrivePath = _diskHelper.CreateAndAttachDisk(_restoreVhdPath, sizeMB); _restoreDiskNumber = _diskHelper.GetDiskNumber(_restoreVhdPath); // Initialize disk (the restore will overwrite this, but we need it formatted) _diskHelper.InitializeDisk(_restoreDiskNumber, tableType); var restoreDriveLetter = _diskHelper.CreateAndFormatPartition(_restoreDiskNumber, fsType); _diskHelper.UnmountForWriting(_restoreVhdPath, restoreDriveLetter); return physicalDrivePath; } /// /// Runs a backup operation using the Controller. /// /// The physical drive path to backup. /// The backup results. private IBackupResults RunBackup(string physicalDrivePath) { var options = new Dictionary(TestOptions); options["enable-module"] = "diskimage"; options["concurrency-fileprocessors"] = "1"; 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) { _diskHelper.UnmountForWriting(_restoreVhdPath); 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[] { "*" }); _diskHelper.BringOnline(_restoreVhdPath); return results; } /// /// Verifies that the restored data matches the original data. /// /// The drive letter of the original source drive. private void VerifyRestoredData(char originalDriveLetter) { TestContext.Progress.WriteLine("Verifying restored data..."); // Attach the restored VHD to get its drive letter var restoreDiskNumber = _diskHelper.GetDiskNumber(_restoreVhdPath); if (restoreDiskNumber < 0) { // Re-attach the VHD if needed - for Windows, we need to use the static method // since this is a special case for re-attaching if (_diskHelper is DiskImage.WindowsDiskImageHelper) { DiskImage.WindowsDiskImageHelper.RunPowerShell($"Mount-DiskImage -ImagePath '{_restoreVhdPath}'"); } restoreDiskNumber = _diskHelper.GetDiskNumber(_restoreVhdPath); } // Get the drive letter for the restored partition var restoreDriveLetter = GetDriveLetterForDisk(restoreDiskNumber); if (restoreDriveLetter == '\0') { TestContext.Progress.WriteLine("Restored VHD not mounted, attempting to mount..."); restoreDriveLetter = _diskHelper.MountForReading(_restoreVhdPath); // Give Windows some time to mount the volume and assign the drive letter Thread.Sleep(2000); } if (restoreDriveLetter == '\0') { // Second attempt to get drive letter after mounting failed Assert.Fail("Could not find drive letter for restored VHD"); return; } TestContext.Progress.WriteLine($"Comparing source drive {originalDriveLetter}: with restored drive {restoreDriveLetter}:"); try { // Compare directory structures and file contents var sourcePath = $"{originalDriveLetter}:\\"; var restorePath = $"{restoreDriveLetter}:\\"; // Wait for the restore path to become available for (int i = 0; i < 10; i++) { if (Directory.Exists(restorePath)) break; Thread.Sleep(1000); } CompareDirectories(sourcePath, restorePath); } finally { // Detach the restored VHD try { _diskHelper.DetachDisk(_restoreVhdPath); } catch (Exception ex) { TestContext.Progress.WriteLine($"Warning: Failed to detach restored VHD: {ex.Message}"); } } } /// /// Recursively compares two directories for structural and content equality. /// /// The source directory path. /// The restored directory path. private void CompareDirectories(string sourcePath, string restorePath) { var options = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true, AttributesToSkip = FileAttributes.System | FileAttributes.ReparsePoint }; // Get all files in source, except for system files, which cannot be read directly var sourceFiles = Directory.EnumerateFiles(sourcePath, "*", options).Select(f => Path.GetRelativePath(sourcePath, f)).OrderBy(f => f).ToList(); var restoreFiles = Directory.EnumerateFiles(restorePath, "*", options).Select(f => Path.GetRelativePath(restorePath, f)).OrderBy(f => f).ToList(); // Check that all source files exist in restore foreach (var relativePath in sourceFiles) { Assert.That(restoreFiles, Does.Contain(relativePath), $"Restored drive should contain file: {relativePath}"); } // Compare file contents byte-by-byte foreach (var sourceFile in sourceFiles) { var relativePath = sourceFile.Substring(3); var restoreFile = Path.Combine(restorePath, relativePath); if (File.Exists(restoreFile)) { var sourceBytes = File.ReadAllBytes(sourceFile); var restoreBytes = File.ReadAllBytes(restoreFile); Assert.That(restoreBytes.Length, Is.EqualTo(sourceBytes.Length), $"File size mismatch for {relativePath}"); Assert.That(restoreBytes, Is.EqualTo(sourceBytes), $"File content mismatch for {relativePath}"); } } TestContext.Progress.WriteLine($"Verified {sourceFiles.Count} files match between source and restored drives"); } /// /// Gets the drive letter for a disk number by querying PowerShell. /// /// The disk number. /// The drive letter, or '\0' if not found. private char GetDriveLetterForDisk(int diskNumber) { // Use PowerShell to find the drive letter for this disk var script = $@"Get-Partition -DiskNumber {diskNumber} | Get-Volume | Where-Object {{ $_.DriveLetter -ne $null }} | Select-Object -ExpandProperty DriveLetter"; var output = DiskImage.WindowsDiskImageHelper.RunPowerShell(script)?.Trim(); if (!string.IsNullOrEmpty(output) && output.Length >= 1 && char.IsLetter(output[0])) { var driveLetter = char.ToUpperInvariant(output[0]); if (Directory.Exists($"{driveLetter}:\\")) { return driveLetter; } } return '\0'; } /// /// Verifies that the geometry metadata is present and valid. /// private void VerifyGeometryMetadata() { TestContext.Progress.WriteLine("Verifying geometry metadata..."); using (var c = new Controller("file://" + TARGETFOLDER, TestOptions, 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 tempGeometryPath = Path.Combine(Path.GetTempPath(), $"geometry_{Guid.NewGuid()}.json"); try { var restoreOptions = new Dictionary(TestOptions) { ["restore-path"] = Path.GetDirectoryName(tempGeometryPath)!, ["overwrite"] = "true" }; using (var restoreController = new Controller("file://" + TARGETFOLDER, restoreOptions, null)) { var restoreResults = restoreController.Restore(new[] { geometryFile!.Path }); TestUtils.AssertResults(restoreResults); } // Read and parse the geometry.json if (File.Exists(tempGeometryPath)) { var json = File.ReadAllText(tempGeometryPath); 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}"); } } finally { try { if (File.Exists(tempGeometryPath)) { File.Delete(tempGeometryPath); } } catch { // Ignore cleanup errors } } } } /// /// Verifies that the partition table type matches the expected type. /// /// The expected partition table type. private void VerifyPartitionTableType(PartitionTableType expectedType) { TestContext.Progress.WriteLine($"Verifying partition table type is {expectedType}..."); using (var c = new Controller("file://" + TARGETFOLDER, TestOptions, 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 tempGeometryPath = Path.Combine(Path.GetTempPath(), $"geometry_{Guid.NewGuid()}.json"); try { var restoreOptions = new Dictionary(TestOptions) { ["restore-path"] = Path.GetDirectoryName(tempGeometryPath)!, ["overwrite"] = "true" }; using (var restoreController = new Controller("file://" + TARGETFOLDER, restoreOptions, null)) { var restoreResults = restoreController.Restore(new[] { geometryFile!.Path }); // Ignore NoFilesRestored warning as geometry.json is a special metadata file var hasRelevantWarnings = restoreResults.Warnings.Any(w => !w.Contains("NoFilesRestored")); if (restoreResults.Errors.Any() || hasRelevantWarnings) { TestUtils.AssertResults(restoreResults); } } if (File.Exists(tempGeometryPath)) { var json = File.ReadAllText(tempGeometryPath); var geometry = GeometryMetadata.FromJson(json); Assert.That(geometry, Is.Not.Null, "geometry.json should deserialize successfully"); Assert.That(geometry!.PartitionTable, Is.Not.Null, "Partition table should be present"); Assert.That(geometry.PartitionTable!.Type, Is.EqualTo(expectedType), $"Partition table type should be {expectedType}"); TestContext.Progress.WriteLine($"Partition table type verified: {geometry.PartitionTable.Type}"); } } finally { try { if (File.Exists(tempGeometryPath)) { File.Delete(tempGeometryPath); } } catch { // Ignore cleanup errors } } } } /// /// Verifies that the restored partition table matches the source. /// private void VerifyPartitionTableMatches() { TestContext.Progress.WriteLine("Verifying partition table matches..."); // Get source disk details var sourceDetails = _diskHelper.GetDiskDetails(_sourceDiskNumber); TestContext.Progress.WriteLine("Source disk details:\n" + sourceDetails); // Get restore disk details var restoreDiskNumber = _diskHelper.GetDiskNumber(_restoreVhdPath); var restoreDetails = _diskHelper.GetDiskDetails(restoreDiskNumber); TestContext.Progress.WriteLine("Restore disk details:\n" + restoreDetails); // Parse partition information from diskpart output var sourcePartitions = ParsePartitionsFromDiskpartOutput(sourceDetails); var restorePartitions = ParsePartitionsFromDiskpartOutput(restoreDetails); // Verify partition counts match Assert.That(restorePartitions.Count, Is.EqualTo(sourcePartitions.Count), "Restored disk should have same number of partitions as source"); // Verify partition sizes match for (int i = 0; i < sourcePartitions.Count; i++) { Assert.That(restorePartitions[i].Size, Is.EqualTo(sourcePartitions[i].Size), $"Partition {i + 1} size should match"); Assert.That(restorePartitions[i].Type, Is.EqualTo(sourcePartitions[i].Type), $"Partition {i + 1} type should match"); } TestContext.Progress.WriteLine($"Partition table verified: {sourcePartitions.Count} partitions match"); } /// /// Parses partition information from diskpart detail disk output. /// /// The diskpart output text. /// A list of tuples containing partition size and type. private List<(long Size, string Type)> ParsePartitionsFromDiskpartOutput(string output) { var partitions = new List<(long Size, string Type)>(); var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); bool inPartitionSection = false; foreach (var line in lines) { // Look for partition section header if (line.Contains("Partition", StringComparison.OrdinalIgnoreCase) && line.Contains("Type", StringComparison.OrdinalIgnoreCase)) { inPartitionSection = true; continue; } if (inPartitionSection && line.Trim().StartsWith("Partition")) { // Parse partition line var parts = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); if (parts.Length >= 3) { // Try to extract size information long size = 0; string type = "Unknown"; for (int i = 0; i < parts.Length; i++) { if (parts[i].EndsWith("MB", StringComparison.OrdinalIgnoreCase) && long.TryParse(parts[i].Replace("MB", ""), out var mb)) { size = mb * 1024 * 1024; } else if (parts[i].EndsWith("GB", StringComparison.OrdinalIgnoreCase) && long.TryParse(parts[i].Replace("GB", ""), out var gb)) { size = gb * 1024 * 1024 * 1024; } else if (parts[i].Equals("Primary", StringComparison.OrdinalIgnoreCase) || parts[i].Equals("Extended", StringComparison.OrdinalIgnoreCase)) { type = parts[i]; } } partitions.Add((size, type)); } } } return partitions; } #endregion } }