478 lines
19 KiB
C#
478 lines
19 KiB
C#
// 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.Security.Principal;
|
|
using System.Text;
|
|
using System.Threading;
|
|
|
|
using NUnit.Framework;
|
|
|
|
namespace Duplicati.UnitTest
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
internal static class DiskImageVhdHelper
|
|
{
|
|
/// <summary>
|
|
/// Creates a VHD file, attaches it to the system, and returns the physical drive path.
|
|
/// </summary>
|
|
/// <param name="vhdPath">The path where the VHD file will be created.</param>
|
|
/// <param name="sizeMB">The size of the VHD in megabytes.</param>
|
|
/// <returns>The physical drive path (e.g., \\.\PhysicalDriveN).</returns>
|
|
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 PowerShell
|
|
var script = $@"New-VHD -Path '{vhdPath}' -SizeBytes {sizeMB}MB -Fixed | Out-Null; Mount-DiskImage -ImagePath '{vhdPath}' | Out-Null";
|
|
RunPowerShell(script);
|
|
|
|
// 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}");
|
|
}
|
|
|
|
return $"\\\\.\\PhysicalDrive{diskNumber}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Waits for a VHD to be attached and returns its disk number.
|
|
/// </summary>
|
|
/// <param name="vhdPath">The path to the VHD file.</param>
|
|
/// <param name="timeout">Maximum time to wait.</param>
|
|
/// <returns>The disk number, or -1 if not found within the timeout.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes a disk with the specified partition table type (GPT or MBR).
|
|
/// </summary>
|
|
/// <param name="diskNumber">The disk number.</param>
|
|
/// <param name="tableType">The partition table type ("gpt" or "mbr").</param>
|
|
public static void InitializeDisk(int diskNumber, string tableType)
|
|
{
|
|
var partitionStyle = tableType.ToLowerInvariant() switch
|
|
{
|
|
"gpt" => "GPT",
|
|
"mbr" => "MBR",
|
|
_ => throw new ArgumentException($"Invalid partition table type: {tableType}", nameof(tableType))
|
|
};
|
|
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Waits for a disk to be initialized.
|
|
/// </summary>
|
|
/// <param name="diskNumber">The disk number.</param>
|
|
/// <param name="timeout">Maximum time to wait.</param>
|
|
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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a partition on the specified disk, formats it with the specified filesystem,
|
|
/// and assigns a drive letter.
|
|
/// </summary>
|
|
/// <param name="diskNumber">The disk number.</param>
|
|
/// <param name="fsType">The filesystem type ("ntfs" or "fat32").</param>
|
|
/// <param name="sizeMB">The size of the partition in MB (0 for all available space).</param>
|
|
/// <returns>The assigned drive letter.</returns>
|
|
public static char CreateAndFormatPartition(int diskNumber, string fsType, long sizeMB = 0)
|
|
{
|
|
var fsTypeUpper = fsType.ToUpperInvariant();
|
|
|
|
// Find an available drive letter
|
|
char driveLetter = FindAvailableDriveLetter();
|
|
|
|
var sizeParam = sizeMB > 0 ? $"-Size {sizeMB}MB" : "-UseMaximumSize";
|
|
|
|
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
|
|
";
|
|
|
|
var result = RunPowerShell(script);
|
|
var assignedLetter = result?.Trim();
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds the drive letter assigned to a disk.
|
|
/// </summary>
|
|
/// <param name="diskNumber">The disk number.</param>
|
|
/// <returns>The drive letter, or throws if not found.</returns>
|
|
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}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Populates a drive with test data including files of various sizes and directory structures.
|
|
/// </summary>
|
|
/// <param name="driveLetter">The drive letter to populate.</param>
|
|
/// <param name="fileCount">The number of files to create.</param>
|
|
/// <param name="fileSizeKB">The size of each file in KB.</param>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Detaches a VHD file from the system.
|
|
/// </summary>
|
|
/// <param name="vhdPath">The path to the VHD file.</param>
|
|
public static void DetachVhd(string vhdPath)
|
|
{
|
|
try
|
|
{
|
|
var script = $@"
|
|
$image = Get-DiskImage -ImagePath '{vhdPath}' -ErrorAction SilentlyContinue
|
|
if ($image -and $image.Attached) {{
|
|
Dismount-DiskImage -ImagePath '{vhdPath}'
|
|
}}
|
|
";
|
|
RunPowerShell(script);
|
|
}
|
|
catch
|
|
{
|
|
// Ignore errors during detach - the disk may already be detached
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the disk number for an attached VHD.
|
|
/// </summary>
|
|
/// <param name="vhdPath">The path to the VHD file.</param>
|
|
/// <returns>The disk number, or -1 if not found.</returns>
|
|
public static int GetDiskNumber(string vhdPath)
|
|
{
|
|
try
|
|
{
|
|
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))
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
TestContext.Progress.WriteLine($"Warning: PowerShell GetDiskNumber failed: {ex.Message}");
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs a PowerShell script and returns the output.
|
|
/// </summary>
|
|
/// <param name="script">The PowerShell script to execute.</param>
|
|
/// <returns>The output from PowerShell.</returns>
|
|
public static string RunPowerShell(string script)
|
|
{
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = "powershell.exe",
|
|
Arguments = $"-NoProfile -ExecutionPolicy Bypass -Command \"{EscapeForCommandLine(script)}\"",
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
Verb = "runas" // Request elevation
|
|
};
|
|
|
|
using var process = Process.Start(startInfo);
|
|
if (process == null)
|
|
{
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Escapes a PowerShell script for use on the command line.
|
|
/// </summary>
|
|
/// <param name="script">The script to escape.</param>
|
|
/// <returns>The escaped script.</returns>
|
|
private static string EscapeForCommandLine(string script)
|
|
{
|
|
// Replace double quotes with escaped double quotes for command line
|
|
return script.Replace("\"", "\\\"");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if the current process is running with administrator privileges.
|
|
/// </summary>
|
|
/// <returns>True if running as administrator, false otherwise.</returns>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds an available drive letter that is not currently in use.
|
|
/// </summary>
|
|
/// <returns>An available drive letter.</returns>
|
|
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.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cleans up and deletes a VHD file, ensuring it is detached first.
|
|
/// </summary>
|
|
/// <param name="vhdPath">The path to the VHD file.</param>
|
|
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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets detailed information about a disk using PowerShell.
|
|
/// </summary>
|
|
/// <param name="diskNumber">The disk number.</param>
|
|
/// <returns>The PowerShell output for the disk details.</returns>
|
|
public static string GetDiskDetails(int diskNumber)
|
|
{
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the volume information for a drive letter.
|
|
/// </summary>
|
|
/// <param name="driveLetter">The drive letter.</param>
|
|
/// <returns>The volume information.</returns>
|
|
public static string GetVolumeInfo(char driveLetter)
|
|
{
|
|
var script = $@"
|
|
Get-Volume -DriveLetter {driveLetter} | Format-List
|
|
";
|
|
return RunPowerShell(script);
|
|
}
|
|
}
|
|
}
|