Files
duplicati/proprietary/DiskImage/Disk/Mac.cs
T

567 lines
23 KiB
C#

// 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.Xml.Linq;
using Duplicati.Proprietary.DiskImage.General;
namespace Duplicati.Proprietary.DiskImage.Disk
{
/// <summary>
/// macOS implementation of the <see cref="IRawDisk"/> interface for raw disk access.
/// Uses POSIX API calls via P/Invoke to read from and write to physical disk devices.
/// </summary>
[SupportedOSPlatform("macos")]
public partial class Mac : IRawDisk
{
private static readonly string LOGTAG = Duplicati.Library.Logging.Log.LogTagFromType<Mac>();
// Disk ioctl constants from <sys/disk.h>
private const ulong DKIOCGETBLOCKSIZE = 0x4004_6418; // _IOR('d', 24, uint32_t)
private const ulong DKIOCGETBLOCKCOUNT = 0x4008_6419; // _IOR('d', 25, uint64_t)
private const ulong DKIOCSYNCHRONIZECACHE = 0x2000_6416; // _IO('d', 22)
// File open flags
private const int O_RDONLY = 0x0000;
private const int O_RDWR = 0x0002;
private const int O_SYNC = 0x0080;
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 const string DEVICE_PREFIX = "/dev/";
private readonly SemaphoreSlim m_ioLock = new(1, 1);
/// <inheritdoc />
public static string Prefix => "/dev/";
/// <inheritdoc />
public string DevicePath { get { return m_devicePath; } }
/// <inheritdoc />
public bool IsWriteable => m_writeable;
/// <summary>
/// Initializes a new instance of the <see cref="Mac"/> class.
/// </summary>
/// <param name="devicePath">The macOS device path (e.g., "/dev/rdisk0").</param>
/// <exception cref="PlatformNotSupportedException">Thrown when not running on macOS.</exception>
public Mac(string devicePath)
{
if (!OperatingSystem.IsMacOS())
throw new PlatformNotSupportedException("macOS raw disk access is only supported on macOS platforms.");
m_devicePath = NormalizeDevicePath(devicePath);
}
/// <summary>
/// Normalizes the device path to use raw device access (/dev/rdiskN instead of /dev/diskN).
/// </summary>
/// <param name="devicePath">The device path to normalize.</param>
/// <returns>The normalized device path using raw device access.</returns>
private static string NormalizeDevicePath(string devicePath)
{
if (string.IsNullOrEmpty(devicePath))
return devicePath;
// Convert /dev/diskN to /dev/rdiskN for raw/character device access
if (devicePath.StartsWith($"{DEVICE_PREFIX}disk") && !devicePath.StartsWith($"{DEVICE_PREFIX}rdisk"))
{
return devicePath.Replace($"{DEVICE_PREFIX}disk", $"{DEVICE_PREFIX}rdisk");
}
return devicePath;
}
/// <inheritdoc />
public long Size
{
get
{
if (!m_initialized)
throw new InvalidOperationException("Disk not initialized.");
return m_size;
}
}
/// <inheritdoc />
public int SectorSize
{
get
{
if (!m_initialized)
throw new InvalidOperationException("Disk not initialized.");
return (int)m_sectorSize;
}
}
/// <inheritdoc />
public int Sectors
{
get
{
if (!m_initialized)
throw new InvalidOperationException("Disk not initialized.");
return (int)(m_size / m_sectorSize);
}
}
/// <inheritdoc />
public async Task<bool> AutoUnmountAsync(CancellationToken cancellationToken)
{
// Extract disk number from device path
string blockDevicePath = m_devicePath.Replace($"{DEVICE_PREFIX}rdisk", $"{DEVICE_PREFIX}disk");
// Use diskutil to unmount all volumes on the disk
var unmount = await ProcessRunner.RunProcessAsync("diskutil", $"unmountDisk {blockDevicePath}", 30_000, cancellationToken);
// diskutil returns 0 on success, but may also succeed with warnings
return unmount.ExitCode == 0 || unmount.Output.Contains("successful");
}
/// <inheritdoc />
public Task<bool> InitializeAsync(CancellationToken cancellationToken)
=> InitializeAsync(false, cancellationToken);
/// <inheritdoc />
public Task<bool> InitializeAsync(bool enableWrite, CancellationToken cancellationToken)
{
if (m_initialized)
return Task.FromResult(true);
// Open the device
int flags = enableWrite ? O_RDWR | O_SYNC : O_RDONLY;
m_fileDescriptor = open(m_devicePath, flags);
if (m_fileDescriptor < 0)
{
int errorCode = Marshal.GetLastWin32Error();
string errorMessage = System.Runtime.InteropServices.Marshal.GetPInvokeErrorMessage(errorCode);
Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "initialize", null, $"Failed to open device {m_devicePath}: {errorMessage} (errno: {errorCode})");
return Task.FromResult(false);
}
// Get disk geometry using ioctls
try
{
// Get block size (sector size)
uint blockSize = 0;
if (ioctl_uint32(m_fileDescriptor, DKIOCGETBLOCKSIZE, 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 Task.FromResult(false);
}
m_sectorSize = blockSize;
// Get block count
ulong blockCount = 0;
if (ioctl_uint64(m_fileDescriptor, DKIOCGETBLOCKCOUNT, ref blockCount) < 0)
{
int errorCode = Marshal.GetLastWin32Error();
close(m_fileDescriptor);
m_fileDescriptor = -1;
Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "initialize", null, $"Failed to get block count: errno {errorCode}");
return Task.FromResult(false);
}
m_size = (long)(blockCount * blockSize);
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 Task.FromResult(true);
}
catch (Exception ex)
{
close(m_fileDescriptor);
m_fileDescriptor = -1;
Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "initialize", ex, "Failed to initialize disk");
return Task.FromResult(false);
}
}
/// <inheritdoc />
public void Dispose()
{
if (m_disposed)
return;
if (m_shouldFlush && m_fileDescriptor >= 0)
{
// Use DKIOCSYNCHRONIZECACHE on macOS to guarantee flush to physical media
if (ioctl_no_arg(m_fileDescriptor, DKIOCSYNCHRONIZECACHE) < 0)
{
int errorCode = Marshal.GetLastWin32Error();
string errorMessage = System.Runtime.InteropServices.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;
}
m_ioLock.Dispose();
m_disposed = true;
}
/// <inheritdoc />
public Task<bool> FinalizeAsync(CancellationToken cancellationToken)
{
Dispose();
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<Stream> 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);
}
/// <inheritdoc />
public async Task<Stream> ReadBytesAsync(long offset, int length, CancellationToken cancellationToken)
{
// Rent a pooled buffer and read directly into it
var buffer = ArrayPool<byte>.Shared.Rent(length);
try
{
int bytesRead = await ReadBytesAsync(offset, buffer.AsMemory(0, length), cancellationToken);
return new PooledMemoryStream(buffer, bytesRead);
}
catch
{
ArrayPool<byte>.Shared.Return(buffer);
throw;
}
}
/// <inheritdoc />
public async Task<int> ReadBytesAsync(long offset, Memory<byte> 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 % SectorSize != 0)
throw new InvalidOperationException($"Address {offset:X016} is not a multiple of the sector size {SectorSize}");
if (length % SectorSize != 0)
throw new InvalidOperationException($"The requested length of {length} is not a multiple of sector size {SectorSize}");
if (offset + length > Size)
throw new InvalidOperationException($"The requested read would read beyond disk size: {offset} + {length} > {Size}");
await m_ioLock.WaitAsync(cancellationToken);
try
{
// Use pread for atomic position + read
int totalBytesRead = 0;
unsafe
{
fixed (byte* bufferPtr = destination.Span)
{
while (totalBytesRead < length)
{
IntPtr bytesRead = pread(m_fileDescriptor, bufferPtr + totalBytesRead, (IntPtr)(length - totalBytesRead), offset + totalBytesRead);
if (bytesRead.ToInt64() < 0)
{
int errorCode = Marshal.GetLastWin32Error();
string errorMessage = System.Runtime.InteropServices.Marshal.GetPInvokeErrorMessage(errorCode);
throw new IOException($"Failed to read from disk at offset {offset + totalBytesRead}: {errorMessage} (errno: {errorCode})");
}
if (bytesRead.ToInt64() == 0)
{
// End of file/device
break;
}
totalBytesRead += (int)bytesRead.ToInt64();
}
}
}
return totalBytesRead;
}
finally
{
m_ioLock.Release();
}
}
/// <inheritdoc />
public async Task<int> 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);
}
/// <inheritdoc />
public Task<int> WriteBytesAsync(long offset, byte[] data, CancellationToken cancellationToken)
=> WriteBytesAsync(offset, data.AsMemory(), cancellationToken);
/// <inheritdoc />
public async Task<int> WriteBytesAsync(long offset, ReadOnlyMemory<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.");
if (m_fileDescriptor < 0)
throw new InvalidOperationException("Device is not open.");
int dataLength = data.Length;
// Pad to sector size if necessary
int remainder = dataLength % (int)m_sectorSize;
int alignedLength = remainder == 0
? dataLength
: dataLength + ((int)m_sectorSize - remainder);
await m_ioLock.WaitAsync(cancellationToken);
try
{
byte[]? rentedBuffer = null;
try
{
ReadOnlyMemory<byte> writeData;
if (remainder != 0)
{
// Need to read-modify-write for partial sector
rentedBuffer = ArrayPool<byte>.Shared.Rent(alignedLength);
// Read existing data
unsafe
{
fixed (byte* bufferPtr = rentedBuffer)
{
IntPtr bytesRead = pread(m_fileDescriptor, bufferPtr, (IntPtr)alignedLength, offset);
if (bytesRead.ToInt64() < 0)
{
int errorCode = Marshal.GetLastWin32Error();
string errorMessage = System.Runtime.InteropServices.Marshal.GetPInvokeErrorMessage(errorCode);
throw new IOException($"Failed to read existing data for padding at offset {offset}: {errorMessage} (errno: {errorCode})");
}
}
}
// Copy new data over existing data
data.CopyTo(rentedBuffer);
writeData = rentedBuffer.AsMemory(0, alignedLength);
}
else
{
writeData = data;
}
// Write data using pwrite
int totalBytesWritten = 0;
unsafe
{
fixed (byte* bufferPtr = writeData.Span)
{
while (totalBytesWritten < alignedLength)
{
IntPtr bytesWritten = pwrite(m_fileDescriptor, bufferPtr + totalBytesWritten, (IntPtr)(alignedLength - totalBytesWritten), offset + totalBytesWritten);
if (bytesWritten.ToInt64() < 0)
{
int errorCode = Marshal.GetLastWin32Error();
string errorMessage = System.Runtime.InteropServices.Marshal.GetPInvokeErrorMessage(errorCode);
string hint = errorCode == 13 // EACCES
? "The disk may be mounted or you don't have sufficient permissions. Try unmounting the disk before writing."
: errorCode == 30 // EROFS
? "The disk is read-only."
: "Check the error code for more details.";
throw new IOException($"Failed to write to disk at offset {offset + totalBytesWritten}: {errorMessage} (errno: {errorCode}). {hint}");
}
totalBytesWritten += (int)bytesWritten.ToInt64();
}
}
}
m_shouldFlush = true;
return totalBytesWritten;
}
finally
{
if (rentedBuffer != null)
{
ArrayPool<byte>.Shared.Return(rentedBuffer);
}
}
}
finally
{
m_ioLock.Release();
}
}
#region P/Invoke Declarations
[LibraryImport("runtimes/osx/native/libSystem_wrapper.dylib", SetLastError = true)]
internal static partial int ioctl_uint32(int fd, ulong request, ref uint value);
[LibraryImport("runtimes/osx/native/libSystem_wrapper.dylib", SetLastError = true)]
internal static partial int ioctl_uint64(int fd, ulong request, ref ulong value);
[LibraryImport("runtimes/osx/native/libSystem_wrapper.dylib", SetLastError = true)]
internal static partial int ioctl_no_arg(int fd, ulong request);
[LibraryImport("libSystem", SetLastError = true)]
private static partial int open([MarshalAs(UnmanagedType.LPStr)] string pathname, int flags);
[LibraryImport("libSystem", SetLastError = true)]
private static partial int close(int fd);
[LibraryImport("libSystem", SetLastError = true)]
private static unsafe partial IntPtr pread(int fd, byte* buf, IntPtr count, long offset);
[LibraryImport("libSystem", SetLastError = true)]
private static unsafe partial IntPtr pwrite(int fd, byte* buf, IntPtr count, long offset);
#endregion
/// <inheritdoc />
public static async IAsyncEnumerable<PhysicalDriveInfo> ListPhysicalDrivesAsync([EnumeratorCancellation] CancellationToken cancellationToken)
{
var list = await ProcessRunner.RunProcessAsync("diskutil", "list -plist", 30_000, cancellationToken);
if (list.ExitCode != 0)
{
Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "listphysicaldrives", null, $"Failed to list physical drives: {list.Error}");
yield break;
}
if (string.IsNullOrWhiteSpace(list.Output))
yield break;
XDocument plist;
try
{
plist = XDocument.Parse(list.Output);
}
catch (Exception ex)
{
Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "listphysicaldrives", ex, "Failed to parse diskutil list plist output");
yield break;
}
// Parse the plist XML structure
var rootDict = plist.Element("plist")?.Element("dict");
if (rootDict == null)
yield break;
// Get AllDisksAndPartitions array
var allDisksAndPartitions = PlistHelper.GetArrayElement(rootDict, "AllDisksAndPartitions");
if (allDisksAndPartitions == null)
yield break;
foreach (var diskElement in allDisksAndPartitions.Elements("dict"))
{
var identifier = PlistHelper.GetStringValue(diskElement, "DeviceIdentifier");
if (string.IsNullOrWhiteSpace(identifier))
continue;
// Skip synthesized disks (e.g., APFS containers)
var isSynthetic = PlistHelper.GetBoolValue(diskElement, "SyntheticDisk");
if (isSynthetic)
continue;
var path = $"{DEVICE_PREFIX}{identifier}";
var size = PlistHelper.GetLongValue(diskElement, "Size");
if (size <= 0)
continue;
// Get detailed info using diskutil info -plist
var info = await ProcessRunner.RunProcessAsync("diskutil", $"info -plist {path}", 30_000, cancellationToken);
if (info.ExitCode != 0)
{
Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "listphysicaldrives", null, $"Failed to get info for {path}: {info.Error}");
continue;
}
string? displayName = null;
string? guid = null;
try
{
var infoDict = PlistHelper.ParsePlistDict(info.Output);
if (infoDict != null)
{
displayName = PlistHelper.GetStringValue(infoDict, "MediaName");
guid = PlistHelper.GetStringValue(infoDict, "DiskUUID");
}
}
catch (Exception ex)
{
Duplicati.Library.Logging.Log.WriteWarningMessage(LOGTAG, "listphysicaldrives", ex, $"Failed to parse diskutil info plist for {path}");
}
// Get mount points from partitions
var mountPoints = new List<string>();
var partitions = PlistHelper.GetArrayElement(diskElement, "Partitions") ?? PlistHelper.GetArrayElement(diskElement, "APFSVolumes");
if (partitions != null)
{
foreach (var partition in partitions.Elements("dict"))
{
var mountPoint = PlistHelper.GetStringValue(partition, "MountPoint");
if (!string.IsNullOrWhiteSpace(mountPoint))
mountPoints.Add(mountPoint);
}
}
var driveInfo = new PhysicalDriveInfo
{
Number = identifier,
Path = path,
Size = (ulong)size,
DisplayName = displayName ?? identifier,
Guid = guid,
MountPoints = mountPoints.ToArray()
};
yield return driveInfo;
}
}
}
}