diff --git a/Duplicati/UnitTest/DiskImageTests.cs b/Duplicati/UnitTest/DiskImageTests.cs new file mode 100644 index 000000000..2ee48f42e --- /dev/null +++ b/Duplicati/UnitTest/DiskImageTests.cs @@ -0,0 +1,793 @@ +// 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; + + [SetUp] + public void DiskImageSetUp() + { + // Check for admin privileges + if (!DiskImageVhdHelper.IsAdministrator()) + { + Assert.Ignore("DiskImage tests require administrator privileges"); + } + + // Create temp VHD paths + var tempPath = Path.GetTempPath(); + _sourceVhdPath = Path.Combine(tempPath, $"duplicati_test_source_{Guid.NewGuid()}.vhd"); + _restoreVhdPath = Path.Combine(tempPath, $"duplicati_test_restore_{Guid.NewGuid()}.vhd"); + } + + [TearDown] + public void DiskImageTearDown() + { + // Cleanup VHDs + try + { + DiskImageVhdHelper.CleanupVhd(_sourceVhdPath); + } + catch (Exception ex) + { + TestContext.Progress.WriteLine($"Warning: Failed to cleanup source VHD: {ex.Message}"); + } + + try + { + DiskImageVhdHelper.CleanupVhd(_restoreVhdPath); + } + catch (Exception ex) + { + TestContext.Progress.WriteLine($"Warning: Failed to cleanup restore VHD: {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, "gpt", "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, "gpt", "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, "mbr", "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, "mbr", "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, "gpt", + new[] { ("ntfs", 100), ("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, "gpt", "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, "gpt", "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, "gpt", + new[] { ("ntfs", 80), ("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 void Test_SourceProvider_Enumeration() + { + TestContext.Progress.WriteLine("Test: SourceProvider Enumeration"); + + // Setup: Create GPT disk with one NTFS partition + var physicalDrivePath = SetupSourceVhd(100, "gpt", "ntfs"); + TestContext.Progress.WriteLine($"Source VHD created at: {_sourceVhdPath}"); + + // Directly instantiate SourceProvider + var sourceUrl = $"diskimage://{physicalDrivePath}"; + using var provider = new SourceProvider(sourceUrl, "", new Dictionary()); + + // Initialize the provider + provider.Initialize(CancellationToken.None).Wait(); + + // Enumerate entries + var entries = new List(); + foreach (var entry in provider.Enumerate(CancellationToken.None).ToBlockingEnumerable()) + { + entries.Add(entry); + TestContext.Progress.WriteLine($"Found entry: {entry.Path} (IsFolder: {entry.IsFolder}, Size: {entry.Size})"); + } + + // Verify hierarchy: disk → partition → filesystem → files + Assert.That(entries.Count, Is.GreaterThan(0), "Should have at least one entry (the disk)"); + + // Check for disk entry + var diskEntry = entries.FirstOrDefault(e => e.IsRootEntry); + Assert.That(diskEntry, Is.Not.Null, "Should have a root disk entry"); + + // 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"); + + // Check for filesystem entries + var fsEntries = entries.Where(e => e.Path.Contains("fs_")).ToList(); + 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, "mbr", + new[] { ("ntfs", 70), ("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); + } + + #region Helper Methods + + /// + /// Sets up a source VHD with a single partition. + /// + private string SetupSourceVhd(int sizeMB, string tableType, string fsType) + { + // Create and attach VHD + var physicalDrivePath = DiskImageVhdHelper.CreateAndAttachVhd(_sourceVhdPath, sizeMB); + _sourceDiskNumber = DiskImageVhdHelper.GetDiskNumber(_sourceVhdPath); + + // Initialize disk + DiskImageVhdHelper.InitializeDisk(_sourceDiskNumber, tableType); + + // Create and format partition + _sourceDriveLetter = DiskImageVhdHelper.CreateAndFormatPartition(_sourceDiskNumber, fsType); + + // Populate with test data + DiskImageVhdHelper.PopulateTestData(_sourceDriveLetter); + + return physicalDrivePath; + } + + /// + /// Sets up a source VHD with multiple partitions. + /// + private string SetupSourceVhdMultiplePartitions(int sizeMB, string tableType, (string fsType, int sizeMB)[] partitions) + { + // Create and attach VHD + var physicalDrivePath = DiskImageVhdHelper.CreateAndAttachVhd(_sourceVhdPath, sizeMB); + _sourceDiskNumber = DiskImageVhdHelper.GetDiskNumber(_sourceVhdPath); + + // Initialize disk + DiskImageVhdHelper.InitializeDisk(_sourceDiskNumber, tableType); + + // Create and format partitions + for (int i = 0; i < partitions.Length; i++) + { + var (fsType, partSize) = partitions[i]; + var driveLetter = DiskImageVhdHelper.CreateAndFormatPartition(_sourceDiskNumber, fsType, partSize); + DiskImageVhdHelper.PopulateTestData(driveLetter, 5, 5); // Smaller data set for multiple partitions + + if (i == 0) + { + _sourceDriveLetter = driveLetter; + } + } + + return physicalDrivePath; + } + + /// + /// Sets up a restore target VHD with the specified geometry. + /// + private string SetupRestoreVhd(int sizeMB, string tableType, string fsType) + { + // Create and attach VHD + var physicalDrivePath = DiskImageVhdHelper.CreateAndAttachVhd(_restoreVhdPath, sizeMB); + _restoreDiskNumber = DiskImageVhdHelper.GetDiskNumber(_restoreVhdPath); + + // Initialize disk (the restore will overwrite this, but we need it formatted) + DiskImageVhdHelper.InitializeDisk(_restoreDiskNumber, tableType); + DiskImageVhdHelper.CreateAndFormatPartition(_restoreDiskNumber, fsType); + + return physicalDrivePath; + } + + /// + /// Runs a backup operation using the Controller. + /// + private IBackupResults RunBackup(string physicalDrivePath) + { + var options = new Dictionary(TestOptions); + options["enable-module"] = "diskimage"; + + using var c = new Controller("file://" + TARGETFOLDER, options, null); + var sourceUrl = $"diskimage://{physicalDrivePath}"; + return c.Backup(new[] { sourceUrl }); + } + + /// + /// Runs a restore operation using the Controller. + /// + private IRestoreResults RunRestore(string restoreDrivePath) + { + var options = new Dictionary(TestOptions); + options["restore-path"] = $"@diskimage://{restoreDrivePath}"; + options["overwrite"] = "true"; + + using var c = new Controller("file://" + TARGETFOLDER, options, null); + return c.Restore(new[] { "*" }); + } + + /// + /// Verifies that the restored data matches the original data. + /// + private void VerifyRestoredData(char originalDriveLetter) + { + TestContext.Progress.WriteLine("Verifying restored data..."); + + // Attach the restored VHD to get its drive letter + var restoreDiskNumber = DiskImageVhdHelper.GetDiskNumber(_restoreVhdPath); + if (restoreDiskNumber < 0) + { + // Re-attach the VHD if needed + DiskImageVhdHelper.RunDiskpart($"select vdisk file=\"{_restoreVhdPath}\"\nattach vdisk"); + Thread.Sleep(500); + restoreDiskNumber = DiskImageVhdHelper.GetDiskNumber(_restoreVhdPath); + } + + // Get the drive letter for the restored partition + var restoreDriveLetter = GetDriveLetterForDisk(restoreDiskNumber); + if (restoreDriveLetter == '\0') + { + 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}:\\"; + + CompareDirectories(sourcePath, restorePath); + } + finally + { + // Detach the restored VHD + try + { + DiskImageVhdHelper.DetachVhd(_restoreVhdPath); + } + catch (Exception ex) + { + TestContext.Progress.WriteLine($"Warning: Failed to detach restored VHD: {ex.Message}"); + } + } + } + + /// + /// Recursively compares two directories for structural and content equality. + /// + private void CompareDirectories(string sourcePath, string restorePath) + { + // Get all files in source + var sourceFiles = Directory.GetFiles(sourcePath, "*", SearchOption.AllDirectories).OrderBy(f => f).ToList(); + var restoreFiles = Directory.GetFiles(restorePath, "*", SearchOption.AllDirectories).OrderBy(f => f).ToList(); + + // Normalize paths for comparison (remove drive letter) + var sourceRelativePaths = sourceFiles.Select(f => f.Substring(3)).ToList(); + var restoreRelativePaths = restoreFiles.Select(f => f.Substring(3)).ToList(); + + // Check that all source files exist in restore + foreach (var relativePath in sourceRelativePaths) + { + Assert.That(restoreRelativePaths, 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 diskpart. + /// + private char GetDriveLetterForDisk(int diskNumber) + { + // Use diskpart to find the drive letter for this disk + var output = DiskImageVhdHelper.RunDiskpart($"select disk {diskNumber}\nlist volume"); + var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var line in lines) + { + // Look for lines containing a drive letter (e.g., " Volume 1 D ") + var trimmed = line.Trim(); + if (trimmed.Length > 0 && char.IsLetter(trimmed[0])) + { + // Try to extract drive letter + var parts = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var part in parts) + { + if (part.Length == 1 && char.IsLetter(part[0])) + { + // Verify this drive exists + var driveLetter = char.ToUpperInvariant(part[0]); + if (Directory.Exists($"{driveLetter}:\\")) + { + return driveLetter; + } + } + } + } + } + + // Alternative: check all drives to find one that matches our VHD + var drives = DriveInfo.GetDrives() + .Where(d => d.DriveType == DriveType.Fixed && d.IsReady) + .ToList(); + + // Return the first available drive letter that's not the source + foreach (var drive in drives) + { + var letter = char.ToUpperInvariant(drive.Name[0]); + if (letter != _sourceDriveLetter) + { + return letter; + } + } + + 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. + /// + 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 }); + 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 = DiskImageVhdHelper.GetDiskDetails(_sourceDiskNumber); + TestContext.Progress.WriteLine("Source disk details:\n" + sourceDetails); + + // Get restore disk details + var restoreDiskNumber = DiskImageVhdHelper.GetDiskNumber(_restoreVhdPath); + var restoreDetails = DiskImageVhdHelper.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. + /// + 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 + } +} diff --git a/Duplicati/UnitTest/DiskImageVhdHelper.cs b/Duplicati/UnitTest/DiskImageVhdHelper.cs new file mode 100644 index 000000000..c9f150a34 --- /dev/null +++ b/Duplicati/UnitTest/DiskImageVhdHelper.cs @@ -0,0 +1,402 @@ +// 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.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Text; +using System.Threading; + +namespace Duplicati.UnitTest +{ + /// + /// Helper class for managing VHD (Virtual Hard Disk) files using diskpart. + /// This class provides methods to create, attach, format, and detach VHD files + /// for testing disk image backup and restore operations. + /// + internal static class DiskImageVhdHelper + { + /// + /// Creates a VHD file, attaches it to the system, and returns the physical drive path. + /// + /// The path where the VHD file will be created. + /// The size of the VHD in megabytes. + /// The physical drive path (e.g., \\.\PhysicalDriveN). + public static string CreateAndAttachVhd(string vhdPath, long sizeMB) + { + // Ensure the directory exists + var directory = Path.GetDirectoryName(vhdPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + // Delete existing VHD if it exists + if (File.Exists(vhdPath)) + { + try + { + DetachVhd(vhdPath); + File.Delete(vhdPath); + } + catch + { + // Ignore errors during cleanup + } + } + + // Create the VHD using diskpart + var script = $@"create vdisk file=""{vhdPath}"" maximum={sizeMB} type=expandable +attach vdisk"; + RunDiskpart(script); + + // Wait a moment for the disk to be attached + Thread.Sleep(500); + + // Get the disk number + int diskNumber = GetDiskNumber(vhdPath); + if (diskNumber < 0) + { + throw new InvalidOperationException($"Failed to get disk number for VHD: {vhdPath}"); + } + + return $"\\\\.\\PhysicalDrive{diskNumber}"; + } + + /// + /// Initializes a disk with the specified partition table type (GPT or MBR). + /// + /// The disk number. + /// The partition table type ("gpt" or "mbr"). + public static void InitializeDisk(int diskNumber, string tableType) + { + var script = $@"select disk {diskNumber} +convert {tableType.ToLowerInvariant()}"; + RunDiskpart(script); + + // Wait for the conversion to complete + Thread.Sleep(200); + } + + /// + /// Creates a partition on the specified disk, formats it with the specified filesystem, + /// and assigns a drive letter. + /// + /// The disk number. + /// The filesystem type ("ntfs" or "fat32"). + /// The size of the partition in MB (0 for all available space). + /// The assigned drive letter. + public static char CreateAndFormatPartition(int diskNumber, string fsType, long sizeMB = 0) + { + var sizeArg = sizeMB > 0 ? $"size={sizeMB}" : ""; + var fsTypeLower = fsType.ToLowerInvariant(); + + // Find an available drive letter + char driveLetter = FindAvailableDriveLetter(); + + var script = $@"select disk {diskNumber} +create partition primary {sizeArg} +format fs={fsTypeLower} quick +assign letter={driveLetter}"; + + RunDiskpart(script); + + // Wait for formatting to complete + Thread.Sleep(500); + + return driveLetter; + } + + /// + /// Populates a drive with test data including files of various sizes and directory structures. + /// + /// The drive letter to populate. + /// The number of files to create. + /// The size of each file in KB. + public static void PopulateTestData(char driveLetter, int fileCount = 10, int fileSizeKB = 10) + { + var drivePath = $"{driveLetter}:\\"; + + // Create a small text file + File.WriteAllText(Path.Combine(drivePath, "testfile_small.txt"), + "This is a small test file for disk image backup testing.\n" + + "It contains simple text data.\n" + + new string('=', 100)); + + // Create a medium binary file with random data + var random = new Random(42); // Fixed seed for reproducibility + var mediumData = new byte[fileSizeKB * 1024]; + random.NextBytes(mediumData); + File.WriteAllBytes(Path.Combine(drivePath, "testfile_medium.bin"), mediumData); + + // Create nested directories with files + var testDir = Path.Combine(drivePath, "testdir"); + Directory.CreateDirectory(testDir); + File.WriteAllText(Path.Combine(testDir, "nested_file.txt"), + "This file is in a nested directory.\n" + + $"Created at: {DateTime.UtcNow:O}\n"); + + var subDir = Path.Combine(testDir, "subdir"); + Directory.CreateDirectory(subDir); + var deepData = new byte[1024]; + random.NextBytes(deepData); + File.WriteAllBytes(Path.Combine(subDir, "deep_file.bin"), deepData); + + // Create additional files if requested + for (int i = 0; i < fileCount - 4; i++) + { + var fileData = new byte[fileSizeKB * 1024]; + random.NextBytes(fileData); + File.WriteAllBytes(Path.Combine(drivePath, $"testfile_{i:D3}.bin"), fileData); + } + } + + /// + /// Detaches a VHD file from the system. + /// + /// The path to the VHD file. + public static void DetachVhd(string vhdPath) + { + try + { + var script = $@"select vdisk file=""{vhdPath}"" +detach vdisk"; + RunDiskpart(script); + } + catch + { + // Ignore errors during detach - the disk may already be detached + } + } + + /// + /// Gets the disk number for an attached VHD. + /// + /// The path to the VHD file. + /// The disk number, or -1 if not found. + public static int GetDiskNumber(string vhdPath) + { + // Use diskpart to list vdisks and find our disk number + var output = RunDiskpart("list vdisk"); + + // Parse the output to find the disk number + // The output format includes the VHD file path and associated disk number + var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var line in lines) + { + if (line.Contains(vhdPath, StringComparison.OrdinalIgnoreCase)) + { + // Try to extract disk number from the line + // Format typically includes: Disk ###, State, Type, File + var parts = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < parts.Length; i++) + { + if (parts[i].StartsWith("Disk", StringComparison.OrdinalIgnoreCase) && + i + 1 < parts.Length) + { + var diskNumStr = parts[i + 1].Trim(); + if (int.TryParse(diskNumStr, out int diskNumber)) + { + return diskNumber; + } + } + } + } + } + + // Alternative approach: use WMI or check diskpart list disk output + // Try to find the disk by looking for recently attached disks + var listDiskOutput = RunDiskpart("list disk"); + var diskLines = listDiskOutput.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + + // Find the highest disk number (usually the most recently attached VHD) + int maxDiskNumber = -1; + foreach (var line in diskLines) + { + if (line.StartsWith(" Disk ", StringComparison.OrdinalIgnoreCase)) + { + var parts = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2 && int.TryParse(parts[1], out int diskNum)) + { + if (diskNum > maxDiskNumber) + { + maxDiskNumber = diskNum; + } + } + } + } + + return maxDiskNumber; + } + + /// + /// Runs a diskpart script and returns the output. + /// + /// The diskpart script to execute. + /// The output from diskpart. + public static string RunDiskpart(string script) + { + var scriptPath = Path.GetTempFileName() + ".txt"; + try + { + File.WriteAllText(scriptPath, script, Encoding.ASCII); + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "diskpart.exe", + Arguments = $"/s \"{scriptPath}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + Verb = "runas" // Request elevation + } + }; + + process.Start(); + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0 && !string.IsNullOrEmpty(error)) + { + throw new InvalidOperationException($"diskpart failed with exit code {process.ExitCode}: {error}\nScript:\n{script}"); + } + + return output; + } + finally + { + try + { + File.Delete(scriptPath); + } + catch + { + // Ignore cleanup errors + } + } + } + + /// + /// Checks if the current process is running with administrator privileges. + /// + /// True if running as administrator, false otherwise. + public static bool IsAdministrator() + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + + try + { + using var identity = WindowsIdentity.GetCurrent(); + var principal = new WindowsPrincipal(identity); + return principal.IsInRole(WindowsBuiltInRole.Administrator); + } + catch + { + return false; + } + } + + /// + /// Finds an available drive letter that is not currently in use. + /// + /// An available drive letter. + private static char FindAvailableDriveLetter() + { + var usedDrives = DriveInfo.GetDrives() + .Where(d => d.Name.Length >= 2) + .Select(d => char.ToUpperInvariant(d.Name[0])) + .ToHashSet(); + + // Try letters from Z down to D (avoid A, B, C which are typically reserved) + for (char c = 'Z'; c >= 'D'; c--) + { + if (!usedDrives.Contains(c)) + { + return c; + } + } + + throw new InvalidOperationException("No available drive letters found."); + } + + /// + /// Cleans up and deletes a VHD file, ensuring it is detached first. + /// + /// The path to the VHD file. + public static void CleanupVhd(string vhdPath) + { + try + { + DetachVhd(vhdPath); + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Failed to detach VHD {vhdPath}: {ex.Message}"); + } + + try + { + if (File.Exists(vhdPath)) + { + File.Delete(vhdPath); + } + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Failed to delete VHD file {vhdPath}: {ex.Message}"); + } + } + + /// + /// Gets detailed information about a disk using diskpart. + /// + /// The disk number. + /// The diskpart output for the disk details. + public static string GetDiskDetails(int diskNumber) + { + var script = $@"select disk {diskNumber} +detail disk"; + return RunDiskpart(script); + } + + /// + /// Gets the volume information for a drive letter. + /// + /// The drive letter. + public static string GetVolumeInfo(char driveLetter) + { + var script = $@"select volume {driveLetter} +detail volume"; + return RunDiskpart(script); + } + } +} diff --git a/Duplicati/UnitTest/Duplicati.UnitTest.csproj b/Duplicati/UnitTest/Duplicati.UnitTest.csproj index 2580fde95..6884ae186 100644 --- a/Duplicati/UnitTest/Duplicati.UnitTest.csproj +++ b/Duplicati/UnitTest/Duplicati.UnitTest.csproj @@ -43,6 +43,7 @@ +