using System; using System.Buffers; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Duplicati.Proprietary.DiskImage.General; using Vanara.InteropServices; using Vanara.PInvoke; using static Vanara.PInvoke.Kernel32; namespace Duplicati.Proprietary.DiskImage.Disk { /// /// Windows implementation of the interface for raw disk access. /// Uses Windows API calls via Vanara.PInvoke to read from and write to physical disk devices. /// [SupportedOSPlatform("windows")] public class Windows : IRawDisk { private static readonly string LOGTAG = Duplicati.Library.Logging.Log.LogTagFromType(); private readonly string m_devicePath; private SafeHFILE? m_deviceHandle; private bool m_disposed = false; private bool m_initialized = false; private bool m_writeable = false; private uint m_sectorSize = 0; private long m_size = 0; private bool m_shouldFlush = false; private const string DEVICE_PREFIX = @"\\.\PHYSICALDRIVE"; // Reusable aligned native buffer for zero-copy I/O private SafeHGlobalHandle? m_alignedBuffer; private int m_alignedBufferSize = 0; private readonly SemaphoreSlim m_ioLock = new(1, 1); /// public static string Prefix => @"\\.\"; /// public string DevicePath { get { return m_devicePath; } } /// public bool IsWriteable => m_writeable; /// /// Initializes a new instance of the class. /// /// The Windows device path (e.g., "\\.\PhysicalDrive0"). /// Thrown when not running on Windows. public Windows(string devicePath) { // Check if windows platform if (Environment.OSVersion.Platform != PlatformID.Win32NT) throw new PlatformNotSupportedException("Windows raw disk access is only supported on Windows platforms."); m_devicePath = devicePath.TrimEnd(Path.DirectorySeparatorChar); } /// public long Size { get { if (!m_initialized) throw new InvalidOperationException("Disk not initialized."); return (long)m_size; } } /// public int SectorSize { get { if (!m_initialized) throw new InvalidOperationException("Disk not initialized."); return (int)m_sectorSize; } } /// public int Sectors { get { if (!m_initialized) throw new InvalidOperationException("Disk not initialized."); return (int)(m_size / m_sectorSize); } } /// public async Task AutoUnmountAsync(CancellationToken cancellationToken) { if (!int.TryParse(m_devicePath.TrimEnd('/', '\\')[^1..], out var number)) throw new InvalidDataException($"Failed to parse device number from {m_devicePath}"); if (number < 0) throw new InvalidDataException($"Parsed invalid device number {number} from {m_devicePath}"); var script = @$" Get-Partition -DiskNumber {number} | Where-Object DriveLetter -ne $null | ForEach-Object {{ Remove-PartitionAccessPath ` -DiskNumber {number} ` -PartitionNumber $_.PartitionNumber ` -AccessPath ""$($_.DriveLetter):\"" }} Set-Disk -Number {number} -IsOffline $true Set-Disk -Number {number} -IsReadOnly $false "; var psi = new ProcessStartInfo { FileName = "powershell.exe", Arguments = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command -", RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; using var p = new Process { StartInfo = psi }; p.Start(); p.StandardInput.WriteLine(script); p.StandardInput.Close(); string output = p.StandardOutput.ReadToEnd(); string error = p.StandardError.ReadToEnd(); p.WaitForExit(); Duplicati.Library.Logging.Log.WriteVerboseMessage(LOGTAG, "autounmount", output, null); if (!string.IsNullOrWhiteSpace(error)) Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "autounmount", null, error, null); return p.ExitCode == 0; } /// public Task InitializeAsync(CancellationToken cancellationToken) => InitializeAsync(false, cancellationToken); /// public async Task InitializeAsync(bool enableWrite, CancellationToken cancellationToken) { if (m_initialized) return true; // Determine access rights var access = enableWrite ? Kernel32.FileAccess.GENERIC_READ | Kernel32.FileAccess.GENERIC_WRITE : Kernel32.FileAccess.GENERIC_READ; // Open the device m_deviceHandle = CreateFile( m_devicePath, access, FILE_SHARE.FILE_SHARE_READ | FILE_SHARE.FILE_SHARE_WRITE, null, CreationOption.OPEN_EXISTING, FileFlagsAndAttributes.FILE_FLAG_NO_BUFFERING, IntPtr.Zero); if (m_deviceHandle.IsInvalid) { m_deviceHandle = null; return false; } // Allow extended DASD I/O DeviceIoControl(m_deviceHandle, IOControlCode.FSCTL_ALLOW_EXTENDED_DASD_IO); DeviceIoControl(m_deviceHandle, IOControlCode.IOCTL_DISK_GET_DRIVE_GEOMETRY_EX, out DISK_GEOMETRY_EX diskGeometry); m_sectorSize = diskGeometry.Geometry.BytesPerSector; DeviceIoControl(m_deviceHandle, IOControlCode.IOCTL_DISK_GET_LENGTH_INFO, out GET_LENGTH_INFORMATION lengthInfo); m_size = lengthInfo.Length; m_writeable = enableWrite; m_initialized = true; return true; } /// public void Dispose() { if (m_disposed) return; if (m_shouldFlush) { var flushed = FlushFileBuffers(m_deviceHandle); if (!flushed) { var error = Marshal.GetLastWin32Error(); var msg = new System.ComponentModel.Win32Exception(error).Message; Console.WriteLine($"Warning: Failed to flush file buffers. Win32 Error Code: {error}. Message: {msg}"); } } m_deviceHandle?.Dispose(); m_deviceHandle = null; m_alignedBuffer?.Dispose(); m_alignedBuffer = null; m_alignedBufferSize = 0; m_ioLock.Dispose(); m_disposed = true; } /// public Task FinalizeAsync(CancellationToken cancellationToken) { Dispose(); return Task.FromResult(true); } /// public async Task ReadSectorsAsync(long startSector, int sectorCount, CancellationToken cancellationToken) { if (!m_initialized) throw new InvalidOperationException("Disk not initialized."); long offset = startSector * m_sectorSize; int length = sectorCount * (int)m_sectorSize; return await ReadBytesAsync(offset, length, cancellationToken); } /// public async Task ReadBytesAsync(long offset, int length, CancellationToken cancellationToken) { // Rent a pooled buffer and read directly into it var buffer = ArrayPool.Shared.Rent(length); try { int bytesRead = await ReadBytesAsync(offset, buffer.AsMemory(0, length), cancellationToken); return new PooledMemoryStream(buffer, bytesRead); } catch { ArrayPool.Shared.Return(buffer); throw; } } /// public async Task ReadBytesAsync(long offset, Memory destination, CancellationToken cancellationToken) { if (!m_initialized) throw new InvalidOperationException("Disk not initialized."); if (m_deviceHandle == null || m_deviceHandle.IsInvalid) throw new InvalidOperationException("Device handle is invalid."); int length = destination.Length; if (offset + length > Size) throw new InvalidOperationException($"The requested read would read beyond disk size: {offset} + {length} > {Size}"); if (length == 0) return 0; // Calculate aligned offset and length for unbuffered I/O (FILE_FLAG_NO_BUFFERING) long alignedOffset = (offset / SectorSize) * SectorSize; long offsetDelta = offset - alignedOffset; long alignedLength = ((offsetDelta + length + SectorSize - 1) / SectorSize) * SectorSize; await m_ioLock.WaitAsync(cancellationToken); try { // Ensure reusable buffer is large enough EnsureAlignedBuffer((int)alignedLength); // Move file pointer to the aligned offset var seeked = SetFilePointerEx(m_deviceHandle, alignedOffset, out _, SeekOrigin.Begin); if (!seeked) { var error = Marshal.GetLastWin32Error(); var msg = new System.ComponentModel.Win32Exception(error).Message; throw new IOException($"Failed to seek to offset {alignedOffset}. Win32 Error Code: {error}. Message: {msg}"); } bool result = ReadFile(m_deviceHandle, m_alignedBuffer!, (uint)alignedLength, out uint bytesRead, IntPtr.Zero); if (!result || bytesRead < offsetDelta + length) { var error = Marshal.GetLastWin32Error(); var msg = new System.ComponentModel.Win32Exception(error).Message; throw new IOException($"Failed to read from disk at aligned offset {alignedOffset}. Win32 Error Code: {error}. Message: {msg}"); } // Copy only the requested portion from the aligned buffer unsafe { var srcPtr = (byte*)m_alignedBuffer!.DangerousGetHandle().ToPointer(); var destSpan = destination.Span; int bytesToCopy = Math.Min(length, (int)(bytesRead - offsetDelta)); for (int i = 0; i < bytesToCopy; i++) { destSpan[i] = srcPtr[offsetDelta + i]; } } return Math.Min(length, (int)(bytesRead - offsetDelta)); } finally { m_ioLock.Release(); } } /// public async Task WriteSectorsAsync(long startSector, byte[] data, CancellationToken cancellationToken) { if (!m_initialized) throw new InvalidOperationException("Disk not initialized."); if (!m_writeable) throw new InvalidOperationException("Disk not opened for write access."); long offset = startSector * m_sectorSize; return await WriteBytesAsync(offset, data, cancellationToken); } /// public async Task WriteBytesAsync(long offset, byte[] data, CancellationToken cancellationToken) => await WriteBytesAsync(offset, data.AsMemory(), cancellationToken); /// public async Task WriteBytesAsync(long offset, ReadOnlyMemory data, CancellationToken cancellationToken) { if (!m_initialized) throw new InvalidOperationException("Disk not initialized."); if (!m_writeable) throw new InvalidOperationException("Disk not opened for write access."); if (m_deviceHandle == null || m_deviceHandle.IsInvalid) throw new InvalidOperationException("Device handle is invalid."); int dataLength = data.Length; if (offset + dataLength > Size) throw new InvalidOperationException($"The requested write would write beyond disk size: {offset} + {dataLength} > {Size}"); if (dataLength == 0) return 0; // Calculate aligned offset and length for unbuffered I/O (FILE_FLAG_NO_BUFFERING) long alignedOffset = (offset / SectorSize) * SectorSize; long offsetDelta = offset - alignedOffset; long alignedLength = ((offsetDelta + dataLength + SectorSize - 1) / SectorSize) * SectorSize; await m_ioLock.WaitAsync(cancellationToken); try { // Ensure reusable buffer is large enough EnsureAlignedBuffer((int)alignedLength); // Check if this is an unaligned write (needs read-modify-write) bool isUnaligned = offsetDelta != 0 || dataLength != alignedLength; if (isUnaligned) { // Move file pointer to the aligned offset var seeked = SetFilePointerEx(m_deviceHandle, alignedOffset, out _, SeekOrigin.Begin); if (!seeked) { var error = Marshal.GetLastWin32Error(); var msg = new System.ComponentModel.Win32Exception(error).Message; throw new IOException($"Failed to seek to aligned offset {alignedOffset}. Win32 Error Code: {error}. Message: {msg}"); } // Read existing data first (read-modify-write) bool readResult = ReadFile(m_deviceHandle, m_alignedBuffer!, (uint)alignedLength, out uint bytesRead, IntPtr.Zero); if (!readResult) { var error = Marshal.GetLastWin32Error(); var msg = new System.ComponentModel.Win32Exception(error).Message; throw new IOException($"Failed to read existing data for unaligned write at offset {alignedOffset}. Win32 Error Code: {error}. Message: {msg}"); } } // Copy data from managed buffer to native buffer at the correct offset unsafe { var destPtr = (byte*)m_alignedBuffer!.DangerousGetHandle().ToPointer(); var srcSpan = data.Span; for (int i = 0; i < dataLength; i++) { destPtr[offsetDelta + i] = srcSpan[i]; } } // Move file pointer to the aligned offset for writing var seekedForWrite = SetFilePointerEx(m_deviceHandle, alignedOffset, out _, SeekOrigin.Begin); if (!seekedForWrite) { var error = Marshal.GetLastWin32Error(); var msg = new System.ComponentModel.Win32Exception(error).Message; throw new IOException($"Failed to seek to aligned offset {alignedOffset} for writing. Win32 Error Code: {error}. Message: {msg}"); } bool result = WriteFile(m_deviceHandle, m_alignedBuffer!, (uint)alignedLength, out uint bytesWritten, IntPtr.Zero); if (!result) { // Error code 5 is "Access Denied", which can occur if the disk is mounted or online. It should be unmounted and offline for writing. // Error code 19 is "the media is write protected", which can occur if the disk is write protected. To fix use diskpart: // select disk // attributes disk clear readonly var error = Marshal.GetLastWin32Error(); var msg = new System.ComponentModel.Win32Exception(error).Message; var hint = error == 5 ? "The disk may be mounted or online. Try unmounting and taking the disk offline before writing." : error == 19 ? "The disk may be write protected. Try clearing the readonly attribute using diskpart." : "Check the error code and message for more details."; throw new IOException($"Failed to write to disk. Win32 Error Code: {error}. Message: {msg}. Hint: {hint}"); } m_shouldFlush = true; return dataLength; } finally { m_ioLock.Release(); } } /// /// Runs a PowerShell script and returns the standard output. Throws an exception if the script fails. /// /// The PowerShell script to run. /// A cancellation token to cancel the operation. /// The standard output of the PowerShell script. /// Thrown if the PowerShell script fails. private static async Task RunPowerShellAsync(string script, CancellationToken cancellationToken) { var program = "powershell.exe"; var args = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command \"{script.Replace("\"", "\\\"")}\""; var result = await ProcessRunner.RunProcessAsync(program, args, 60_000, cancellationToken); if (result.ExitCode != 0) { Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "RunPowerShellAsync", null, $"PowerShell script failed with exit code {result.ExitCode}. Output: {result.Output}. Error: {result.Error}"); throw new IOException($"PowerShell script failed with exit code {result.ExitCode}. Error: {result.Error}"); } return result.Output; } /// /// Ensures the reusable aligned buffer is at least the specified size. /// Reallocates if necessary. Must be called while holding m_ioLock. /// private void EnsureAlignedBuffer(int requiredSize) { if (m_alignedBufferSize >= requiredSize) return; m_alignedBuffer?.Dispose(); m_alignedBuffer = new SafeHGlobalHandle(requiredSize); m_alignedBufferSize = requiredSize; } /// public static async IAsyncEnumerable ListPhysicalDrivesAsync([EnumeratorCancellation] CancellationToken cancellationToken) { var script = @" $diskInfo = Get-CimInstance Win32_DiskDrive | ForEach-Object { $wmi = $_ $diskNumber = [int]$wmi.Index $disk = Get-Disk -Number $diskNumber -ErrorAction SilentlyContinue $driveLetters = @( Get-Partition -DiskNumber $diskNumber -ErrorAction SilentlyContinue | Where-Object { $_.DriveLetter } | ForEach-Object { $_.DriveLetter.ToString() } | Sort-Object -Unique ) [pscustomobject]@{ Path = $wmi.DeviceID Size = [uint64]$wmi.Size DisplayName = if ($disk) { $disk.FriendlyName } else { $wmi.Model } Guid = if ($disk) { $disk.Guid } else { $null } DriveLetters = $driveLetters # Always an array Online = if ($disk) { -not $disk.IsOffline } else { $null } } } $diskInfo | ConvertTo-Json -Depth 4 "; var output = await RunPowerShellAsync(script, cancellationToken); if (string.IsNullOrWhiteSpace(output)) yield break; JsonDocument doc; try { doc = JsonDocument.Parse(output); } catch (JsonException ex) { Duplicati.Library.Logging.Log.WriteWarningMessage(LOGTAG, "FailedDriveOutputParsing", ex, "Failed to parse output"); yield break; } using (doc) { // PowerShell ConvertTo-Json returns a single object for 1 item, array for multiple if (doc.RootElement.ValueKind == JsonValueKind.Array) { PhysicalDriveInfo[]? drives = null; try { drives = JsonSerializer.Deserialize(output); } catch (JsonException ex) { Duplicati.Library.Logging.Log.WriteWarningMessage(LOGTAG, "FailedDriveOutputParsing", ex, "Failed to parse output as array"); } if (drives != null) { foreach (var drive in drives) { var number = -1; if (drive.Path.StartsWith(DEVICE_PREFIX, StringComparison.OrdinalIgnoreCase)) { int.TryParse(drive.Path.AsSpan(DEVICE_PREFIX.Length), out number); } drive.Number = number.ToString(); yield return drive; } } } else if (doc.RootElement.ValueKind == JsonValueKind.Object) { PhysicalDriveInfo? drive = null; try { drive = JsonSerializer.Deserialize(output); } catch (JsonException ex) { Duplicati.Library.Logging.Log.WriteWarningMessage(LOGTAG, "FailedDriveOutputParsing", ex, "Failed to parse output as single object"); } if (drive != null) { // Extract drive number from path (e.g., \\.\PHYSICALDRIVE0 -> 0) var number = -1; if (drive.Path.StartsWith(DEVICE_PREFIX, StringComparison.OrdinalIgnoreCase)) { int.TryParse(drive.Path.AsSpan(DEVICE_PREFIX.Length), out number); } drive.Number = number.ToString(); yield return drive; } } } } } }