diff --git a/Duplicati/UnitTest/DiskImageVhdHelper.cs b/Duplicati/UnitTest/DiskImageVhdHelper.cs
index c9f150a34..61913ef15 100644
--- a/Duplicati/UnitTest/DiskImageVhdHelper.cs
+++ b/Duplicati/UnitTest/DiskImageVhdHelper.cs
@@ -23,15 +23,16 @@ 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;
+using NUnit.Framework;
+
namespace Duplicati.UnitTest
{
///
- /// Helper class for managing VHD (Virtual Hard Disk) files using diskpart.
+ /// Helper class for managing VHD (Virtual Hard Disk) files using PowerShell.
/// This class provides methods to create, attach, format, and detach VHD files
/// for testing disk image backup and restore operations.
///
@@ -66,16 +67,12 @@ namespace Duplicati.UnitTest
}
}
- // Create the VHD using diskpart
- var script = $@"create vdisk file=""{vhdPath}"" maximum={sizeMB} type=expandable
-attach vdisk";
- RunDiskpart(script);
+ // Create the VHD using PowerShell
+ var script = $@"New-VHD -Path '{vhdPath}' -SizeBytes {sizeMB}MB -Fixed | Out-Null; Mount-DiskImage -ImagePath '{vhdPath}' | Out-Null";
+ RunPowerShell(script);
- // Wait a moment for the disk to be attached
- Thread.Sleep(500);
-
- // Get the disk number
- int diskNumber = GetDiskNumber(vhdPath);
+ // Wait for the disk to be attached and get the disk number
+ int diskNumber = WaitForDiskAttachment(vhdPath, TimeSpan.FromSeconds(30));
if (diskNumber < 0)
{
throw new InvalidOperationException($"Failed to get disk number for VHD: {vhdPath}");
@@ -84,6 +81,27 @@ attach vdisk";
return $"\\\\.\\PhysicalDrive{diskNumber}";
}
+ ///
+ /// Waits for a VHD to be attached and returns its disk number.
+ ///
+ /// The path to the VHD file.
+ /// Maximum time to wait.
+ /// The disk number, or -1 if not found within the timeout.
+ private static int WaitForDiskAttachment(string vhdPath, TimeSpan timeout)
+ {
+ var startTime = DateTime.UtcNow;
+ while (DateTime.UtcNow - startTime < timeout)
+ {
+ var diskNumber = GetDiskNumber(vhdPath);
+ if (diskNumber >= 0)
+ {
+ return diskNumber;
+ }
+ Thread.Sleep(100);
+ }
+ return -1;
+ }
+
///
/// Initializes a disk with the specified partition table type (GPT or MBR).
///
@@ -91,12 +109,45 @@ attach vdisk";
/// 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);
+ var partitionStyle = tableType.ToLowerInvariant() switch
+ {
+ "gpt" => "GPT",
+ "mbr" => "MBR",
+ _ => throw new ArgumentException($"Invalid partition table type: {tableType}", nameof(tableType))
+ };
- // Wait for the conversion to complete
- Thread.Sleep(200);
+ var script = $@"
+ $disk = Get-Disk -Number {diskNumber}
+ if ($disk.PartitionStyle -ne 'RAW') {{
+ Clear-Disk -Number {diskNumber} -RemoveData -Confirm:$false
+ }}
+ Initialize-Disk -Number {diskNumber} -PartitionStyle {partitionStyle}
+ ";
+ RunPowerShell(script);
+
+ // Wait for the initialization to complete
+ WaitForDiskInitialization(diskNumber, TimeSpan.FromSeconds(30));
+ }
+
+ ///
+ /// Waits for a disk to be initialized.
+ ///
+ /// The disk number.
+ /// Maximum time to wait.
+ private static void WaitForDiskInitialization(int diskNumber, TimeSpan timeout)
+ {
+ var startTime = DateTime.UtcNow;
+ while (DateTime.UtcNow - startTime < timeout)
+ {
+ var script = $"(Get-Disk -Number {diskNumber}).PartitionStyle";
+ var result = RunPowerShell(script)?.Trim();
+ if (result == "GPT" || result == "MBR")
+ {
+ return;
+ }
+ Thread.Sleep(100);
+ }
+ throw new TimeoutException($"Disk {diskNumber} initialization timed out");
}
///
@@ -109,23 +160,58 @@ convert {tableType.ToLowerInvariant()}";
/// 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();
+ var fsTypeUpper = fsType.ToUpperInvariant();
// Find an available drive letter
char driveLetter = FindAvailableDriveLetter();
- var script = $@"select disk {diskNumber}
-create partition primary {sizeArg}
-format fs={fsTypeLower} quick
-assign letter={driveLetter}";
+ var sizeParam = sizeMB > 0 ? $"-Size {sizeMB}MB" : "-UseMaximumSize";
- RunDiskpart(script);
+ var script = $@"
+ $partition = New-Partition -DiskNumber {diskNumber} {sizeParam} -AssignDriveLetter
+ # Wait for the partition to be ready
+ $timeout = (Get-Date).AddSeconds(30)
+ while ((Get-Date) -lt $timeout) {{
+ $vol = Get-Partition -DiskNumber {diskNumber} | Where-Object {{ $_.PartitionNumber -eq $partition.PartitionNumber }} | Get-Volume
+ if ($vol) {{ break }}
+ Start-Sleep -Milliseconds 100
+ }}
+ # Format the volume
+ $vol = Format-Volume -Partition $partition -FileSystem {fsTypeUpper} -NewFileSystemLabel 'TestVol' -Confirm:$false
+ # Get the assigned drive letter
+ (Get-Partition -DiskNumber {diskNumber} | Where-Object {{ $_.PartitionNumber -eq $partition.PartitionNumber }} | Get-Volume).DriveLetter
+ ";
- // Wait for formatting to complete
- Thread.Sleep(500);
+ var result = RunPowerShell(script);
+ var assignedLetter = result?.Trim();
- return driveLetter;
+ if (!string.IsNullOrEmpty(assignedLetter) && assignedLetter.Length == 1 && char.IsLetter(assignedLetter[0]))
+ {
+ return char.ToUpperInvariant(assignedLetter[0]);
+ }
+
+ // Fallback: try to find the drive letter by checking the disk
+ return FindDriveLetterForDisk(diskNumber);
+ }
+
+ ///
+ /// Finds the drive letter assigned to a disk.
+ ///
+ /// The disk number.
+ /// The drive letter, or throws if not found.
+ private static char FindDriveLetterForDisk(int diskNumber)
+ {
+ var script = $@"
+ Get-Partition -DiskNumber {diskNumber} | Get-Volume | Where-Object {{ $_.DriveLetter -ne $null }} | Select-Object -ExpandProperty DriveLetter
+ ";
+ var result = RunPowerShell(script)?.Trim();
+
+ if (!string.IsNullOrEmpty(result) && result.Length >= 1 && char.IsLetter(result[0]))
+ {
+ return char.ToUpperInvariant(result[0]);
+ }
+
+ throw new InvalidOperationException($"Could not find drive letter for disk {diskNumber}");
}
///
@@ -180,9 +266,13 @@ assign letter={driveLetter}";
{
try
{
- var script = $@"select vdisk file=""{vhdPath}""
-detach vdisk";
- RunDiskpart(script);
+ var script = $@"
+ $image = Get-DiskImage -ImagePath '{vhdPath}' -ErrorAction SilentlyContinue
+ if ($image -and $image.Attached) {{
+ Dismount-DiskImage -ImagePath '{vhdPath}'
+ }}
+ ";
+ RunPowerShell(script);
}
catch
{
@@ -197,109 +287,85 @@ detach vdisk";
/// 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)
+ try
{
- if (line.Contains(vhdPath, StringComparison.OrdinalIgnoreCase))
+ var script = $@"
+ $diskImage = Get-DiskImage -ImagePath '{vhdPath}' -ErrorAction SilentlyContinue
+ if ($diskImage -and $diskImage.Attached) {{
+ Get-Disk | Where-Object {{ $_.Path -like '*{Path.GetFileName(vhdPath)}*' -or ($_ | Get-DiskImage).ImagePath -eq '{vhdPath}' }} | Select-Object -ExpandProperty Number
+ }}
+ ";
+ var result = RunPowerShell(script)?.Trim();
+
+ if (!string.IsNullOrEmpty(result) && int.TryParse(result, out int diskNumber))
{
- // 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;
- }
- }
- }
+ return diskNumber;
+ }
+
+ // Alternative approach: query by disk image path
+ script = $@"
+ Get-DiskImage -ImagePath '{vhdPath}' | Get-Disk | Select-Object -ExpandProperty Number
+ ";
+ result = RunPowerShell(script)?.Trim();
+
+ if (!string.IsNullOrEmpty(result) && int.TryParse(result, out 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)
+ catch (Exception ex)
{
- 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;
- }
- }
- }
+ TestContext.Progress.WriteLine($"Warning: PowerShell GetDiskNumber failed: {ex.Message}");
}
- return maxDiskNumber;
+ return -1;
}
///
- /// Runs a diskpart script and returns the output.
+ /// Runs a PowerShell script and returns the output.
///
- /// The diskpart script to execute.
- /// The output from diskpart.
- public static string RunDiskpart(string script)
+ /// The PowerShell script to execute.
+ /// The output from PowerShell.
+ public static string RunPowerShell(string script)
{
- var scriptPath = Path.GetTempFileName() + ".txt";
- try
+ var startInfo = new ProcessStartInfo
{
- File.WriteAllText(scriptPath, script, Encoding.ASCII);
+ FileName = "powershell.exe",
+ Arguments = $"-NoProfile -ExecutionPolicy Bypass -Command \"{EscapeForCommandLine(script)}\"",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ Verb = "runas" // Request elevation
+ };
- 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
+ using var process = Process.Start(startInfo);
+ if (process == null)
{
- try
- {
- File.Delete(scriptPath);
- }
- catch
- {
- // Ignore cleanup errors
- }
+ throw new InvalidOperationException("Failed to start PowerShell process");
}
+
+ var output = process.StandardOutput.ReadToEnd();
+ var error = process.StandardError.ReadToEnd();
+ process.WaitForExit();
+
+ if (process.ExitCode != 0 && !string.IsNullOrEmpty(error))
+ {
+ throw new InvalidOperationException($"PowerShell failed with exit code {process.ExitCode}: {error}\nScript:\n{script}");
+ }
+
+ return output;
+ }
+
+ ///
+ /// Escapes a PowerShell script for use on the command line.
+ ///
+ /// The script to escape.
+ /// The escaped script.
+ private static string EscapeForCommandLine(string script)
+ {
+ // Replace double quotes with escaped double quotes for command line
+ return script.Replace("\"", "\\\"");
}
///
@@ -377,26 +443,35 @@ detach vdisk";
}
///
- /// Gets detailed information about a disk using diskpart.
+ /// Gets detailed information about a disk using PowerShell.
///
/// The disk number.
- /// The diskpart output for the disk details.
+ /// The PowerShell output for the disk details.
public static string GetDiskDetails(int diskNumber)
{
- var script = $@"select disk {diskNumber}
-detail disk";
- return RunDiskpart(script);
+ var script = $@"
+ Write-Output ""Disk Details for Disk {diskNumber}:""
+ Write-Output ""================================""
+ Get-Disk -Number {diskNumber} | Format-List
+ Write-Output ''
+ Write-Output ""Partitions:""
+ Write-Output ""===========""
+ Get-Partition -DiskNumber {diskNumber} | Format-Table
+ ";
+ return RunPowerShell(script);
}
///
/// Gets the volume information for a drive letter.
///
/// The drive letter.
+ /// The volume information.
public static string GetVolumeInfo(char driveLetter)
{
- var script = $@"select volume {driveLetter}
-detail volume";
- return RunDiskpart(script);
+ var script = $@"
+ Get-Volume -DriveLetter {driveLetter} | Format-List
+ ";
+ return RunPowerShell(script);
}
}
}