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 @@
+