// Copyright (c) 2026 Duplicati Inc. All rights reserved. using System; using System.Buffers; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Threading; using System.Threading.Tasks; using System.Text.Json; using Duplicati.Proprietary.DiskImage.General; namespace Duplicati.Proprietary.DiskImage.Disk { /// /// Linux implementation of the interface for raw disk access. /// Uses POSIX API calls via P/Invoke to read from and write to block devices. /// [SupportedOSPlatform("linux")] public partial class Linux : IRawDisk { private static readonly string LOGTAG = Duplicati.Library.Logging.Log.LogTagFromType(); // Linux block device ioctl constants from // BLKGETSIZE64: _IOR(0x12, 114, size_t) = 0x80081272 (on x86_64) // Returns the size of the block device in bytes as uint64. private const ulong BLKGETSIZE64 = 0x80081272; // BLKSSZGET: _IO(0x12, 104) = 0x1268 // Returns the logical sector size of the block device as int. private const uint BLKSSZGET = 0x1268; // BLKFLSBUF: _IO(0x12, 97) = 0x1261 // Flushes the buffer cache for the block device. private const uint BLKFLSBUF = 0x1261; // File open flags from private const int O_RDONLY = 0x0000; private const int O_RDWR = 0x0002; // TODO Should be processor architecture agnostic. // O_DIRECT: bypass kernel page cache for unbuffered I/O // Value is architecture-dependent: 0x4000 on x86_64, 0x10000 on aarch64 // Using 0x4000 as the most common value (x86_64) private const int O_DIRECT = 0x4000; private readonly string m_devicePath; private int m_fileDescriptor = -1; 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 readonly SemaphoreSlim m_ioLock = new(1, 1); // Aligned buffers for O_DIRECT I/O (must be sector-aligned and a multiple of sector size) private long m_allignedBufferSize = 0; unsafe private byte* m_allignedBufferPtr = null; /// public static string Prefix => "/dev/"; /// public string DevicePath { get { return m_devicePath; } } /// public bool IsWriteable => m_writeable; /// /// Initializes a new instance of the class. /// /// The Linux device path (e.g., "/dev/sda", "/dev/nvme0n1", "/dev/loop0"). /// Thrown when not running on Linux. public Linux(string devicePath) { if (!OperatingSystem.IsLinux()) throw new PlatformNotSupportedException("Linux raw disk access is only supported on Linux platforms."); m_devicePath = devicePath; } /// public long Size { get { if (!m_initialized) throw new InvalidOperationException("Disk not initialized."); return 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); } } private async Task> GetMountedPartitionsAsync(CancellationToken cancellationToken) { var mountedPartitions = new List(); // Read /proc/mounts to find mounted partitions for this device using (var reader = new StreamReader("/proc/mounts")) { string? line; while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) != null) if (line.StartsWith(m_devicePath)) { var partitionDevice = line.Split(' ').FirstOrDefault(); if (partitionDevice != null) mountedPartitions.Add(partitionDevice); } } return mountedPartitions; } /// public async Task AutoUnmountAsync(CancellationToken cancellationToken) { try { var mountPoints = await GetMountedPartitionsAsync(cancellationToken); // Unmount each partition bool allSucceeded = true; foreach (var mountPoint in mountPoints) { var psi = new ProcessStartInfo { FileName = "umount", Arguments = $"\"{mountPoint}\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; using var process = new Process { StartInfo = psi }; process.Start(); string output = await process.StandardOutput.ReadToEndAsync(cancellationToken); string error = await process.StandardError.ReadToEndAsync(cancellationToken); await process.WaitForExitAsync(cancellationToken); if (process.ExitCode != 0) { Duplicati.Library.Logging.Log.WriteWarningMessage(LOGTAG, "autounmount", null, $"Failed to unmount {mountPoint}: {error}"); allSucceeded = false; } else { Duplicati.Library.Logging.Log.WriteVerboseMessage(LOGTAG, "autounmount", $"Successfully unmounted {mountPoint}"); } } return allSucceeded; } catch (Exception ex) { Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "autounmount", ex, "Failed to auto-unmount disk"); return false; } } /// public Task InitializeAsync(CancellationToken cancellationToken) => InitializeAsync(false, cancellationToken); /// public async Task InitializeAsync(bool enableWrite, CancellationToken cancellationToken) { if (m_initialized) return true; if (enableWrite && (await GetMountedPartitionsAsync(cancellationToken)).Count > 0) { throw new IOException($"Cannot initialize disk {m_devicePath} because it has mounted partitions. Please unmount all partitions before initializing."); } // Open the device with O_DIRECT for unbuffered I/O (matching Windows FILE_FLAG_NO_BUFFERING behavior) // Note: O_DIRECT requires sector-aligned buffers and lengths int flags = enableWrite ? O_RDWR | O_DIRECT : O_RDONLY | O_DIRECT; m_fileDescriptor = open(m_devicePath, flags); if (m_fileDescriptor < 0) { int errorCode = Marshal.GetLastWin32Error(); string errorMessage = Marshal.GetPInvokeErrorMessage(errorCode); ; Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "initialize", null, $"Failed to open device {m_devicePath}: {errorMessage} (errno: {errorCode})"); return false; } // Get disk geometry using ioctls try { // Get logical block size (sector size) uint blockSize = 0; if (ioctl_uint32(m_fileDescriptor, BLKSSZGET, ref blockSize) < 0) { int errorCode = Marshal.GetLastWin32Error(); close(m_fileDescriptor); m_fileDescriptor = -1; Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "initialize", null, $"Failed to get block size: errno {errorCode}"); return false; } m_sectorSize = blockSize; // Get disk size in bytes ulong sizeInBytes = 0; if (ioctl_uint64(m_fileDescriptor, BLKGETSIZE64, ref sizeInBytes) < 0) { int errorCode = Marshal.GetLastWin32Error(); close(m_fileDescriptor); m_fileDescriptor = -1; Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "initialize", null, $"Failed to get disk size: errno {errorCode}"); return false; } m_size = (long)sizeInBytes; m_writeable = enableWrite; m_initialized = true; Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "initialize", $"Successfully initialized disk {m_devicePath}: Size={m_size}, SectorSize={m_sectorSize}"); return true; } catch (Exception ex) { close(m_fileDescriptor); m_fileDescriptor = -1; Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "initialize", ex, "Failed to initialize disk"); return false; } } /// public void Dispose() { if (m_disposed) return; if (m_shouldFlush && m_fileDescriptor >= 0) { // Use BLKFLSBUF on Linux to flush the block device buffer cache if (ioctl_no_arg(m_fileDescriptor, BLKFLSBUF) < 0) { int errorCode = Marshal.GetLastWin32Error(); string errorMessage = Marshal.GetPInvokeErrorMessage(errorCode); ; Duplicati.Library.Logging.Log.WriteWarningMessage(LOGTAG, "dispose", null, $"Failed to flush data: {errorMessage} (errno: {errorCode})"); } } if (m_fileDescriptor >= 0) { close(m_fileDescriptor); m_fileDescriptor = -1; } unsafe { if (m_allignedBufferPtr is not null) { NativeMemory.AlignedFree(m_allignedBufferPtr); m_allignedBufferPtr = null; m_allignedBufferSize = 0; } } m_ioLock.Dispose(); m_disposed = true; } /// public Task FinalizeAsync(CancellationToken cancellationToken) { Dispose(); return Task.FromResult(true); } /// public 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 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_fileDescriptor < 0) throw new InvalidOperationException("Device is not open."); int length = destination.Length; if (offset + length > Size) throw new InvalidOperationException($"The requested read would read beyond disk size: {offset} + {length} > {Size}"); // Calculate aligned offset and length for O_DIRECT I/O long alignedOffset = (offset / SectorSize) * SectorSize; long offsetDelta = offset - alignedOffset; long alignedLength = ((offsetDelta + length + SectorSize - 1) / SectorSize) * SectorSize; await m_ioLock.WaitAsync(cancellationToken); try { // Use pread for atomic position + read int totalBytesRead = 0; unsafe { EnsureAllignedBuffer((int)alignedLength); var bytesRead = pread(m_fileDescriptor, m_allignedBufferPtr, (nint)alignedLength, alignedOffset); if (bytesRead.ToInt64() < 0) { int errorCode = Marshal.GetLastWin32Error(); string errorMessage = Marshal.GetPInvokeErrorMessage(errorCode); ; throw new IOException($"Failed to read {alignedLength} bytes from disk at offset {alignedOffset}: {errorMessage} (errno: {errorCode})"); } // Copy only the requested portion from the aligned buffer int bytesToCopy = Math.Min(length, (int)(bytesRead.ToInt64() - offsetDelta)); if (bytesToCopy > 0) { var srcSpan = new ReadOnlySpan(m_allignedBufferPtr + offsetDelta, bytesToCopy); srcSpan.CopyTo(destination.Span); } totalBytesRead = bytesToCopy; } return totalBytesRead; } 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 Task WriteBytesAsync(long offset, byte[] data, CancellationToken cancellationToken) => 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_fileDescriptor < 0) throw new InvalidOperationException("Device is not open."); int dataLength = data.Length; if (offset + dataLength > Size) throw new InvalidOperationException($"The requested write would write beyond disk size: {offset} + {dataLength} > {Size}"); // Calculate aligned offset and length for O_DIRECT I/O long alignedOffset = (offset / SectorSize) * SectorSize; long offsetDelta = offset - alignedOffset; long alignedLength = ((offsetDelta + dataLength + SectorSize - 1) / SectorSize) * SectorSize; await m_ioLock.WaitAsync(cancellationToken); try { // Ensure we have an aligned buffer for O_DIRECT writes EnsureAllignedBuffer((int)alignedLength); int totalBytesWritten = 0; unsafe { // Check if this is an unaligned write (needs read-modify-write) bool isUnaligned = offsetDelta != 0 || dataLength != alignedLength; if (isUnaligned) { // Read existing data first (read-modify-write) var bytesRead = pread(m_fileDescriptor, m_allignedBufferPtr, (nint)alignedLength, alignedOffset); if (bytesRead.ToInt64() < 0) { int errorCode = Marshal.GetLastWin32Error(); string errorMessage = Marshal.GetPInvokeErrorMessage(errorCode); ; throw new IOException($"Failed to read existing data for unaligned write at offset {alignedOffset}: {errorMessage} (errno: {errorCode})"); } } // Copy new data into the aligned buffer at the correct offset var destSpan = new Span(m_allignedBufferPtr + offsetDelta, dataLength); data.Span.CopyTo(destSpan); // Write the aligned buffer var bytesWritten = pwrite(m_fileDescriptor, m_allignedBufferPtr, (nint)alignedLength, alignedOffset); totalBytesWritten = (int)bytesWritten.ToInt64(); } if (totalBytesWritten < 0) { int errorCode = Marshal.GetLastWin32Error(); string errorMessage = Marshal.GetPInvokeErrorMessage(errorCode); ; throw new IOException($"Failed to write {dataLength} bytes to disk at offset {offset}: {errorMessage} (errno: {errorCode})"); } m_shouldFlush = true; return dataLength; } finally { m_ioLock.Release(); } } #region P/Invoke Declarations // P/Invoke to the native wrapper library for ioctls [LibraryImport("libc_wrapper.so", SetLastError = true)] internal static partial int ioctl_uint32(int fd, uint request, ref uint value); [LibraryImport("libc_wrapper.so", SetLastError = true)] internal static partial int ioctl_uint64(int fd, ulong request, ref ulong value); [LibraryImport("libc_wrapper.so", SetLastError = true)] internal static partial int ioctl_no_arg(int fd, uint request); // Standard libc functions [LibraryImport("libc", SetLastError = true)] private static partial int open([MarshalAs(UnmanagedType.LPStr)] string pathname, int flags); [LibraryImport("libc", SetLastError = true)] private static partial int close(int fd); [LibraryImport("libc", SetLastError = true)] private static unsafe partial nint pread(int fd, void* buf, nint count, long offset); [LibraryImport("libc", SetLastError = true)] private static unsafe partial nint pwrite(int fd, void* buf, nint count, long offset); #endregion unsafe private void EnsureAllignedBuffer(int requiredSize) { if (m_allignedBufferSize >= requiredSize) return; nuint alignedSize = (nuint)((requiredSize + SectorSize - 1) / SectorSize * SectorSize); // Free existing buffer if it exists if (m_allignedBufferPtr is not null) { m_allignedBufferPtr = (byte*)NativeMemory.AlignedRealloc(m_allignedBufferPtr, alignedSize, (nuint)m_sectorSize); } else { m_allignedBufferPtr = (byte*)NativeMemory.AlignedAlloc(alignedSize, (nuint)m_sectorSize); } } /// public static async IAsyncEnumerable ListPhysicalDrivesAsync([EnumeratorCancellation] CancellationToken cancellationToken) { // Use lsblk to get list of block devices in JSON format // -J: JSON output // -O: include all available columns // -b: sizes in bytes var result = await ProcessRunner.RunProcessAsync("lsblk", "-JO -b", 30_000, cancellationToken); if (result.ExitCode != 0) { Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "listphysicaldrives", null, $"Failed to list physical drives: {result.Error}"); yield break; } if (string.IsNullOrWhiteSpace(result.Output)) yield break; JsonDocument doc; try { doc = JsonDocument.Parse(result.Output); } catch (JsonException ex) { Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "listphysicaldrives", ex, "Failed to parse lsblk JSON output"); yield break; } using (doc) { var rootElement = doc.RootElement; if (!rootElement.TryGetProperty("blockdevices", out var blockDevices)) yield break; foreach (var device in blockDevices.EnumerateArray()) { // Only include whole disks (type="disk"), not partitions (type="part") if (!device.TryGetProperty("type", out var typeElement) || typeElement.GetString() != "disk") continue; var path = device.TryGetProperty("path", out var pathElement) ? pathElement.GetString() : null; if (string.IsNullOrWhiteSpace(path)) continue; // Get size var size = device.TryGetProperty("size", out var sizeElement) ? sizeElement.GetUInt64() : 0UL; if (size == 0) continue; // Get device name/number (e.g., sda, nvme0n1) var name = device.TryGetProperty("name", out var nameElement) ? nameElement.GetString() : null; // Get mount points from children (partitions) var mountPoints = new List(); if (device.TryGetProperty("children", out var children)) { foreach (var child in children.EnumerateArray()) { if (child.TryGetProperty("mountpoint", out var mpElement) && mpElement.ValueKind == JsonValueKind.String) { var mountPoint = mpElement.GetString(); if (!string.IsNullOrWhiteSpace(mountPoint)) mountPoints.Add(mountPoint); } } } // Try to get UUID using blkid (this is expensive, so we only do it for valid disks) string? guid = null; try { var blkidResult = await ProcessRunner.RunProcessAsync("blkid", $"-o export {path}", 10_000, cancellationToken); if (blkidResult.ExitCode == 0 && !string.IsNullOrWhiteSpace(blkidResult.Output)) { // Parse blkid output to find UUID foreach (var line in blkidResult.Output.Split('\n')) { if (line.StartsWith("UUID=")) { guid = line[5..].Trim('"'); break; } } } } catch { // Ignore blkid failures - not all disks have UUIDs } var driveInfo = new PhysicalDriveInfo { Number = name ?? path, Path = path, Size = size, DisplayName = name ?? path, Guid = guid, MountPoints = [.. mountPoints], Online = null // Linux doesn't have an equivalent concept to "online" disks }; yield return driveInfo; } } } } }