2026-02-15 11:45:52 +01:00
// 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 ;
2026-02-23 16:55:29 +01:00
using System.Collections.Generic ;
using System.Diagnostics ;
2026-02-15 11:45:52 +01:00
using System.IO ;
2026-02-23 16:55:29 +01:00
using System.Linq ;
using System.Threading ;
using System.Xml.Linq ;
2026-03-05 15:30:36 +01:00
using Duplicati.Library.Utility ;
2026-02-23 16:55:29 +01:00
using Duplicati.Proprietary.DiskImage ;
2026-03-05 15:30:36 +01:00
using Duplicati.Proprietary.DiskImage.General ;
2026-02-23 16:55:29 +01:00
using NUnit.Framework ;
2026-02-15 11:45:52 +01:00
#nullable enable
namespace Duplicati.UnitTest.DiskImage
{
/// <summary>
2026-02-15 12:45:59 +01:00
/// macOS implementation of <see cref="IDiskImageHelper"/> using hdiutil and standard macOS tools.
2026-02-15 11:45:52 +01:00
/// </summary>
internal class MacOSDiskImageHelper : IDiskImageHelper
{
/// <inheritdoc />
2026-02-23 17:07:00 +01:00
public string CreateDisk ( string imagePath , long sizeB )
2026-02-15 11:45:52 +01:00
{
2026-02-23 16:55:29 +01:00
// Ensure the directory exists
var directory = Path . GetDirectoryName ( imagePath );
if (! string . IsNullOrEmpty ( directory ) && ! Directory . Exists ( directory ))
Directory . CreateDirectory ( directory );
2026-02-15 11:45:52 +01:00
2026-02-23 16:55:29 +01:00
// Delete existing image if it exists
if ( File . Exists ( imagePath ))
try
{
CleanupDisk ( imagePath );
}
catch
{
// Ignore errors during cleanup
}
2026-02-15 11:45:52 +01:00
2026-02-23 16:55:29 +01:00
// Create a raw disk image using hdiutil
// Use UDIF format with "Free Space" filesystem (unformatted)
2026-02-23 21:04:46 +01:00
RunProcess ( "hdiutil" , $"create -size {sizeB} -type UDIF -layout NONE -o \" { imagePath } \ "" );
2026-02-15 11:45:52 +01:00
2026-02-27 06:38:52 +01:00
var output = RunProcess ( "hdiutil" , $"attach -nomount -noautofsck \" { imagePath } \ "" );
2026-02-23 16:55:29 +01:00
2026-02-23 21:04:46 +01:00
// Parse the disk device from output (e.g., "/dev/disk4")
var diskDevice = output . Trim (). Split ( new [] { '\r' , '\n' }, StringSplitOptions . RemoveEmptyEntries ). FirstOrDefault ()?. Trim ()
?? throw new InvalidOperationException ( "Failed to get disk device from hdiutil attach output" );
2026-02-23 16:55:29 +01:00
// Return the raw device path for direct I/O
return diskDevice . Replace ( "/dev/disk" , "/dev/rdisk" );
2026-02-15 11:45:52 +01:00
}
2026-02-23 16:55:29 +01:00
private string [] GetPartitionNames ( string diskIdentifier )
2026-02-15 11:45:52 +01:00
{
2026-03-05 17:42:24 +01:00
var output = RunProcess ( "diskutil" , $"list -plist {diskIdentifier}" );
var plist = XDocument . Parse ( output );
var rootDict = plist . Element ( "plist" )?. Element ( "dict" );
if ( rootDict == null )
return [];
// Find the disk in AllDisksAndPartitions
var allDisksAndPartitions = PlistHelper . GetArrayElement ( rootDict , "AllDisksAndPartitions" );
if ( allDisksAndPartitions == null )
return [];
// Normalize disk identifier - plist stores just "disk4" but we might have "/dev/disk4" or "/dev/rdisk4"
var normalizedId = diskIdentifier . Replace ( "/dev/rdisk" , "disk" ). Replace ( "/dev/disk" , "disk" );
var diskElement = allDisksAndPartitions . Elements ( "dict" )
. FirstOrDefault ( d => PlistHelper . GetStringValue ( d , "DeviceIdentifier" ) == normalizedId );
if ( diskElement == null )
return [];
var partitions = new List < string >();
// Check for regular partitions
var partitionsArray = PlistHelper . GetArrayElement ( diskElement , "Partitions" );
if ( partitionsArray != null )
{
foreach ( var partition in partitionsArray . Elements ( "dict" ))
{
var deviceIdentifier = PlistHelper . GetStringValue ( partition , "DeviceIdentifier" );
if (! string . IsNullOrWhiteSpace ( deviceIdentifier ))
partitions . Add ( deviceIdentifier );
}
}
// Check for APFS volumes
var apfsVolumes = PlistHelper . GetArrayElement ( diskElement , "APFSVolumes" );
if ( apfsVolumes != null )
{
foreach ( var volume in apfsVolumes . Elements ( "dict" ))
{
var deviceIdentifier = PlistHelper . GetStringValue ( volume , "DeviceIdentifier" );
if (! string . IsNullOrWhiteSpace ( deviceIdentifier ))
partitions . Add ( deviceIdentifier );
}
}
return [.. partitions ];
2026-02-15 11:45:52 +01:00
}
2026-02-23 16:55:29 +01:00
private string [] GetMountPoints ( string diskIdentifier )
2026-02-15 11:45:52 +01:00
{
2026-02-23 16:55:29 +01:00
var partitions = GetPartitionNames ( diskIdentifier );
var mountPoints = new System . Collections . Generic . List < string >();
foreach ( var partition in partitions )
{
2026-03-05 17:42:24 +01:00
var output = RunProcess ( "diskutil" , $"info -plist /dev/{partition}" );
var plist = XDocument . Parse ( output );
var dict = plist . Element ( "plist" )?. Element ( "dict" );
if ( dict == null )
continue ;
2026-02-23 16:55:29 +01:00
2026-02-24 11:37:03 +01:00
// Check if this is an APFS Physical Store
2026-03-05 17:42:24 +01:00
var contentType = PlistHelper . GetStringValue ( dict , "Content" );
var isApfsPhysicalStore = contentType == "Apple_APFS" ;
2026-02-24 11:37:03 +01:00
if ( isApfsPhysicalStore )
{
// For APFS, get the container and then get volumes from it
2026-03-05 17:42:24 +01:00
var containerId = PlistHelper . GetStringValue ( dict , "APFSContainerReference" );
if (! string . IsNullOrEmpty ( containerId ))
2026-02-24 11:37:03 +01:00
{
// Get volumes from the container (they are like disk5s1, disk5s2, etc.)
var containerVolumes = GetApfsContainerVolumes ( containerId );
foreach ( var volume in containerVolumes )
{
2026-03-05 17:42:24 +01:00
var volumeOutput = RunProcess ( "diskutil" , $"info -plist {volume}" );
var volumePlist = XDocument . Parse ( volumeOutput );
var volumeDict = volumePlist . Element ( "plist" )?. Element ( "dict" );
if ( volumeDict != null )
{
var mountPoint = PlistHelper . GetStringValue ( volumeDict , "MountPoint" );
if (! string . IsNullOrEmpty ( mountPoint ))
mountPoints . Add ( mountPoint );
}
2026-02-24 11:37:03 +01:00
}
}
}
else
{
// Regular partition - extract mount point directly
2026-03-05 17:42:24 +01:00
var mountPoint = PlistHelper . GetStringValue ( dict , "MountPoint" );
2026-02-24 11:37:03 +01:00
if (! string . IsNullOrEmpty ( mountPoint ))
mountPoints . Add ( mountPoint );
}
2026-02-23 16:55:29 +01:00
}
return [.. mountPoints ];
2026-02-15 11:45:52 +01:00
}
2026-02-24 11:37:03 +01:00
private string [] GetApfsContainerVolumes ( string containerId )
{
2026-03-05 17:42:24 +01:00
var output = RunProcess ( "diskutil" , $"list -plist {containerId}" );
2026-02-24 11:37:03 +01:00
2026-03-05 17:42:24 +01:00
var plist = XDocument . Parse ( output );
var rootDict = plist . Element ( "plist" )?. Element ( "dict" );
if ( rootDict == null )
return [];
2026-02-24 11:37:03 +01:00
2026-03-05 17:42:24 +01:00
var allDisksAndPartitions = PlistHelper . GetArrayElement ( rootDict , "AllDisksAndPartitions" );
if ( allDisksAndPartitions == null )
return [];
2026-02-24 11:37:03 +01:00
2026-03-05 17:42:24 +01:00
// Normalize container id - plist stores just "disk4" but we might have "/dev/disk4" or "/dev/rdisk4"
var normalizedId = containerId . Replace ( "/dev/rdisk" , "disk" ). Replace ( "/dev/disk" , "disk" );
2026-02-24 11:37:03 +01:00
2026-03-05 17:42:24 +01:00
var container = allDisksAndPartitions . Elements ( "dict" )
. FirstOrDefault ( d => PlistHelper . GetStringValue ( d , "DeviceIdentifier" ) == normalizedId );
if ( container == null )
return [];
2026-02-24 11:37:03 +01:00
2026-03-05 17:42:24 +01:00
var volumes = new List < string >();
var apfsVolumes = PlistHelper . GetArrayElement ( container , "APFSVolumes" );
if ( apfsVolumes != null )
{
foreach ( var volume in apfsVolumes . Elements ( "dict" ))
{
var deviceIdentifier = PlistHelper . GetStringValue ( volume , "DeviceIdentifier" );
if (! string . IsNullOrWhiteSpace ( deviceIdentifier ))
volumes . Add ( deviceIdentifier );
}
}
2026-02-24 11:37:03 +01:00
2026-03-05 17:42:24 +01:00
return [.. volumes ];
2026-02-24 11:37:03 +01:00
}
2026-02-15 11:45:52 +01:00
/// <inheritdoc />
2026-03-05 15:30:36 +01:00
public string [] InitializeDisk ( string diskIdentifier , PartitionTableType tableType , ( FileSystemType , long )[] partitions )
2026-02-15 11:45:52 +01:00
{
2026-03-05 15:30:36 +01:00
if ( tableType == PartitionTableType . Unknown )
2026-02-24 13:32:01 +01:00
return []; // No partition table, so nothing to initialize
2026-02-23 16:55:29 +01:00
// Use diskutil to partition the disk with the specified scheme
// "Free Space" means no filesystem, just the partition table
2026-02-25 13:29:07 +01:00
var partitionStrings = partitions . Length > 0 ? partitions . Select (( p , i ) =>
2026-02-23 16:55:29 +01:00
{
var fsType = p . Item1 switch
{
2026-03-05 15:30:36 +01:00
FileSystemType . NTFS => throw new NotSupportedException ( "NTFS is not natively supported on macOS for creating partitions. Use ExFAT or FAT32 instead." ),
FileSystemType . FAT32 => "\"MS-DOS FAT32\"" ,
FileSystemType . ExFAT => "ExFAT" ,
FileSystemType . HFSPlus => "HFS+" ,
FileSystemType . APFS => "APFS" ,
2026-02-23 16:55:29 +01:00
_ => throw new ArgumentException ( $"Unsupported filesystem type on macOS: {p.Item1}" , nameof ( partitions ))
};
var sizeArg = p . Item2 > 0 ? $"{p.Item2}" : "0" ;
2026-02-25 13:29:07 +01:00
return $"{fsType} \" Partition { i + 1 } \ " {sizeArg}" ;
2026-02-23 16:55:29 +01:00
})
2026-02-24 09:22:32 +01:00
: [ "\"Free Space\" \"\" 0" ];
2026-02-23 16:55:29 +01:00
var partitionArgs = string . Join ( " " , partitionStrings );
2026-03-05 17:42:24 +01:00
// Normalize disk identifier for diskutil command (it expects "disk4", not "/dev/rdisk4")
var normalizedDiskId = diskIdentifier . Replace ( "/dev/rdisk" , "disk" ). Replace ( "/dev/disk" , "disk" );
RunProcess ( "diskutil" , $"partitionDisk {normalizedDiskId} {tableType.ToString().ToUpperInvariant()} {partitionArgs}" );
2026-02-23 16:55:29 +01:00
if ( partitions . Length == 0 )
return [];
return GetMountPoints ( diskIdentifier );
2026-02-15 11:45:52 +01:00
}
/// <inheritdoc />
2026-03-02 12:24:34 +00:00
public string [] Mount ( string diskIdentifier , string? baseMountPath = null , bool readOnly = false , FileSystemType []? fileSystemTypes = null )
2026-02-15 11:45:52 +01:00
{
2026-02-23 16:55:29 +01:00
// Check if already mounted
2026-02-23 22:53:14 +01:00
try
{
2026-02-25 13:27:56 +01:00
var existingMountPoints = GetMountPoints ( diskIdentifier );
if ( existingMountPoints . Length > 0 && existingMountPoints . All ( mp => mp . StartsWith ( baseMountPath ?? "/Volumes/" )))
{
return existingMountPoints ;
}
2026-02-23 22:53:14 +01:00
}
catch
{
// Ignore errors and try mounting
}
2026-02-23 16:55:29 +01:00
2026-02-25 13:27:56 +01:00
// Get the list of partitions first to ensure we target the correct disk
var partitions = GetPartitionNames ( diskIdentifier );
List < string > mountPoints = [];
foreach ( var partition in partitions )
{
2026-03-05 17:42:24 +01:00
var output = RunProcess ( "diskutil" , $"info -plist /dev/{partition}" );
var plist = XDocument . Parse ( output );
var dict = plist . Element ( "plist" )?. Element ( "dict" );
if ( dict == null )
continue ;
2026-02-25 13:27:56 +01:00
// Check if this is an APFS Physical Store
2026-03-05 17:42:24 +01:00
var contentType = PlistHelper . GetStringValue ( dict , "Content" );
var isApfsPhysicalStore = contentType == "Apple_APFS" ;
2026-02-25 13:27:56 +01:00
if ( isApfsPhysicalStore )
{
// For APFS, get the container and mount volumes from it
2026-03-05 17:42:24 +01:00
var containerId = PlistHelper . GetStringValue ( dict , "APFSContainerReference" );
if (! string . IsNullOrEmpty ( containerId ))
2026-02-25 13:27:56 +01:00
{
// Get volumes from the container (they are like disk5s1, disk5s2, etc.)
var containerVolumes = GetApfsContainerVolumes ( containerId );
foreach ( var volume in containerVolumes )
{
2026-02-25 15:48:26 +01:00
MountVolume ( volume , baseMountPath , mountPoints , readOnly );
2026-02-25 13:27:56 +01:00
}
}
}
else
{
// Regular partition - mount directly
2026-02-25 15:48:26 +01:00
MountVolume ( partition , baseMountPath , mountPoints , readOnly );
2026-02-25 13:27:56 +01:00
}
}
return mountPoints . Count == 0 ? WaitForMountPoints ( diskIdentifier ) : [.. mountPoints ];
}
/// <summary>
/// Mounts a single volume/partition with optional custom mount point.
/// </summary>
/// <param name="volume">The volume identifier (e.g., "disk4s1").</param>
/// <param name="baseMountPath">Optional base path for mounting.</param>
/// <param name="mountPoints">List to collect mount points.</param>
2026-02-25 15:48:26 +01:00
/// <param name="readOnly">Indicates whether the volume should be mounted as read-only.</param>
private void MountVolume ( string volume , string? baseMountPath , List < string > mountPoints , bool readOnly )
2026-02-25 13:27:56 +01:00
{
var dir = "" ;
2026-02-27 06:40:33 +01:00
var mountArgs = readOnly ? "-readOnly" : "" ;
2026-02-25 13:27:56 +01:00
if ( baseMountPath is not null )
{
dir = Path . Combine ( baseMountPath , volume );
Directory . CreateDirectory ( dir ); // diskutil will mount to this directory instead of /Volumes/ if it exists, so create it ahead of time
2026-02-27 06:40:33 +01:00
var readOnlyArg = readOnly ? " -readOnly" : "" ;
mountArgs = $"{readOnlyArg} -mountPoint \" { dir } \ "" ;
2026-02-25 13:27:56 +01:00
}
2026-02-25 15:46:59 +01:00
2026-02-27 06:41:26 +01:00
// Unmount first in case it's already mounted somewhere else, to ensure it gets mounted to the correct location
2026-03-05 17:42:24 +01:00
var oldMp = GetMountPointFromPlist ( RunProcess ( "diskutil" , $"info -plist /dev/{volume}" ));
2026-02-27 06:41:26 +01:00
if ( oldMp is not null && ! oldMp . StartsWith ( baseMountPath ?? "/Volumes/" ))
RunProcess ( "diskutil" , $"unmount force /dev/{volume}" );
RunProcess ( "diskutil" , $"mount -nobrowse {mountArgs} /dev/{volume}" );
2026-02-25 13:27:56 +01:00
// Verify the mount
2026-03-05 17:42:24 +01:00
var mp = RunProcess ( "diskutil" , $"info -plist /dev/{volume}" );
var mountPoint = GetMountPointFromPlist ( mp );
2026-02-25 13:27:56 +01:00
if (! string . IsNullOrEmpty ( mountPoint ))
{
mountPoints . Add ( mountPoint );
if (! mountPoint . StartsWith ( baseMountPath ?? "/Volumes/" ))
{
2026-03-05 17:42:24 +01:00
Console . WriteLine ( $"Warning: Volume /dev/{volume} did not mount to expected location. Output: {mp}" );
2026-02-25 13:27:56 +01:00
}
}
else
{
2026-03-05 17:42:24 +01:00
Console . WriteLine ( $"Warning: Volume /dev/{volume} did not report a mount point after mounting. Output: {mp}" );
2026-02-25 13:27:56 +01:00
}
2026-03-05 17:42:24 +01:00
}
2026-02-23 16:55:29 +01:00
2026-03-05 17:42:24 +01:00
/// <summary>
/// Extracts the mount point from diskutil info -plist output.
/// </summary>
private string? GetMountPointFromPlist ( string plistOutput )
{
try
{
var plist = XDocument . Parse ( plistOutput );
var dict = plist . Element ( "plist" )?. Element ( "dict" );
if ( dict == null )
return null ;
var mountPoint = PlistHelper . GetStringValue ( dict , "MountPoint" );
// Return null if not mounted (empty string indicates not mounted)
return string . IsNullOrWhiteSpace ( mountPoint ) ? null : mountPoint ;
}
catch
{
return null ;
}
2026-02-24 14:56:31 +01:00
}
/// <summary>
/// Waits for mount points to be fully established after a mount operation.
/// Uses exponential backoff starting from a short delay to minimize wait time
/// in the common case while still handling slow mounts.
///
/// After mounting (especially after raw partition table writes during restore),
/// macOS may need time to fully recognize filesystems and establish mount points
/// in /Volumes/. Without this retry loop, mount points may be:
/// - Not yet available (empty array)
/// - In a transient location like /private/var/ instead of /Volumes/
/// - Mounted but with empty directories (filesystem not yet recognized)
/// </summary>
/// <param name="diskIdentifier">The disk identifier to check mount points for.</param>
/// <returns>An array of mount point paths.</returns>
private string [] WaitForMountPoints ( string diskIdentifier )
{
const int maxRetries = 10 ;
const int initialDelayMs = 100 ;
const int maxDelayMs = 1000 ;
var delayMs = initialDelayMs ;
for ( int i = 0 ; i < maxRetries ; i ++)
{
var mountPoints = GetMountPoints ( diskIdentifier );
if ( mountPoints . Length > 0 )
{
// Check if any mount points are in a transient location (not under /Volumes/)
if ( mountPoints . Any ( mp => ! mp . StartsWith ( "/Volumes/" )))
{
Thread . Sleep ( delayMs );
delayMs = Math . Min ( delayMs * 2 , maxDelayMs );
continue ;
}
// Check if all mount points have filesystem entries (not empty)
var allNonEmpty = mountPoints . All ( mp =>
{
try { return Directory . GetFileSystemEntries ( mp ). Length > 0 ; }
catch { return false ; }
});
if ( allNonEmpty || i == maxRetries - 1 )
return mountPoints ;
}
Thread . Sleep ( delayMs );
delayMs = Math . Min ( delayMs * 2 , maxDelayMs );
}
// Final attempt - return whatever we have
2026-02-23 16:55:29 +01:00
return GetMountPoints ( diskIdentifier );
2026-02-15 11:45:52 +01:00
}
2026-02-23 16:55:29 +01:00
public void Unmount ( string diskIdentifier )
2026-02-15 11:45:52 +01:00
{
2026-02-27 06:45:52 +01:00
int retryCount = 3 ;
for ( int i = 0 ; i < retryCount ; i ++)
{
try
{
// Unmount all volumes on the disk
RunProcess ( "diskutil" , $"unmountDisk {diskIdentifier}" );
}
catch
{
// Sometimes an unmount is issued too fast after writing data, which makes diskutil throw an errory.
Thread . Sleep ( 500 );
}
}
2026-02-15 11:45:52 +01:00
}
/// <inheritdoc />
2026-02-23 16:55:29 +01:00
public void FlushDisk ( string diskIdentifier )
2026-02-15 11:45:52 +01:00
{
2026-02-23 16:55:29 +01:00
// On macOS, use sync to flush all filesystem buffers
2026-02-23 21:04:46 +01:00
RunProcess ( "sync" , "" );
2026-02-15 11:45:52 +01:00
}
/// <inheritdoc />
2026-02-23 16:55:29 +01:00
public void CleanupDisk ( string imagePath , string? diskIdentifier = null )
2026-02-15 11:45:52 +01:00
{
2026-02-23 16:55:29 +01:00
if ( diskIdentifier is null )
{
2026-02-23 21:04:46 +01:00
var output = RunProcess ( "hdiutil" , $"info" );
2026-02-23 16:55:29 +01:00
diskIdentifier = output
. Split ( "================================================" )
. Skip ( 1 ) // Skip the header
. Select ( d =>
{
var lines = d
. Split ( '\n' )
. Select ( l => l . Trim ())
. Where ( l => l . Length > 0 )
. ToList ();
2026-02-23 17:24:38 +01:00
var imagePathLine = lines . FirstOrDefault ( l => l . StartsWith ( "image-path" ));
2026-02-23 16:55:29 +01:00
if ( imagePathLine == null )
return null ;
var path = imagePathLine . Split ([ ':' ], 2 )[ 1 ]. Trim ();
if (! string . Equals ( path , imagePath , StringComparison . OrdinalIgnoreCase ))
return null ;
var deviceLine = lines . FirstOrDefault ( l => l . StartsWith ( "/dev/" ));
if ( deviceLine == null )
return null ;
var device = deviceLine
2026-02-23 17:24:38 +01:00
. Split ([ '\t' ], 2 )[ 0 ]
2026-02-23 16:55:29 +01:00
. Trim ();
return device ;
})
. FirstOrDefault ( d => d != null );
}
2026-02-23 17:24:38 +01:00
if ( diskIdentifier is null )
throw new InvalidOperationException ( $"Failed to determine disk identifier for {imagePath} during cleanup" );
2026-02-23 16:55:29 +01:00
try
{
2026-02-23 21:04:46 +01:00
RunProcess ( "diskutil" , $"eject {diskIdentifier}" );
2026-02-23 16:55:29 +01:00
}
catch ( Exception ex )
{
2026-03-05 17:42:24 +01:00
Console . WriteLine ( $"Warning: Failed to detach disk image: {ex.Message}" );
2026-02-23 16:55:29 +01:00
}
try
{
if ( File . Exists ( imagePath ))
File . Delete ( imagePath );
}
catch ( Exception ex )
{
2026-03-05 17:42:24 +01:00
Console . WriteLine ( $"Warning: Failed to delete disk image file: {ex.Message}" );
2026-02-23 16:55:29 +01:00
}
2026-02-15 11:45:52 +01:00
}
/// <inheritdoc />
public bool HasRequiredPrivileges ()
{
2026-02-24 13:54:38 +01:00
// Root is not required for virtual disks.
return true ;
2026-02-23 16:55:29 +01:00
}
/// <inheritdoc />
2026-03-05 15:30:36 +01:00
public PartitionTableGeometry GetPartitionTable ( string diskIdentifier )
2026-02-23 16:55:29 +01:00
{
2026-03-05 17:42:24 +01:00
var output = RunProcess ( "diskutil" , $"info -plist {diskIdentifier}" );
var plist = XDocument . Parse ( output );
var dict = plist . Element ( "plist" )?. Element ( "dict" );
if ( dict == null )
throw new InvalidOperationException ( $"Failed to parse diskutil info plist for {diskIdentifier}" );
// Verify disk identifier
var deviceIdentifier = PlistHelper . GetStringValue ( dict , "DeviceIdentifier" );
if (! diskIdentifier . EndsWith ( deviceIdentifier ?? "" , StringComparison . OrdinalIgnoreCase ))
throw new InvalidOperationException ( $"Disk identifier mismatch in diskutil info output: {diskIdentifier} doesn't end with {deviceIdentifier}" );
// Determine partition table type
var contentType = PlistHelper . GetStringValue ( dict , "Content" );
var tableType = contentType switch
2026-02-23 16:55:29 +01:00
{
2026-03-05 17:42:24 +01:00
"GUID_partition_scheme" => PartitionTableType . GPT ,
"FDisk_partition_scheme" => PartitionTableType . MBR ,
_ => PartitionTableType . Unknown
};
// Get size and sector size
var size = PlistHelper . GetLongValue ( dict , "TotalSize" );
var sectorSize = ( int ) PlistHelper . GetLongValue ( dict , "DeviceBlockSize" );
2026-02-23 16:55:29 +01:00
2026-03-05 17:42:24 +01:00
if ( size == 0 || sectorSize == 0 )
2026-02-23 16:55:29 +01:00
throw new InvalidOperationException ( $"Failed to retrieve partition table information for disk {diskIdentifier}" );
2026-03-05 15:30:36 +01:00
return new PartitionTableGeometry
2026-02-23 16:55:29 +01:00
{
Type = tableType ,
Size = size ,
SectorSize = sectorSize
// Ignore the rest for now.
};
}
/// <inheritdoc />
public PartitionGeometry [] GetPartitions ( string diskIdentifier )
{
var names = GetPartitionNames ( diskIdentifier );
var partitions = new List < PartitionGeometry >();
foreach ( var name in names )
{
2026-03-05 17:42:24 +01:00
var output = RunProcess ( "diskutil" , $"info -plist /dev/{name}" );
var plist = XDocument . Parse ( output );
var dict = plist . Element ( "plist" )?. Element ( "dict" );
if ( dict == null )
continue ;
// Verify device node
var deviceNode = PlistHelper . GetStringValue ( dict , "DeviceNode" );
if (! string . Equals ( deviceNode , $"/dev/{name}" , StringComparison . OrdinalIgnoreCase ))
throw new InvalidOperationException ( $"Partition identifier mismatch in diskutil info output: expected /dev/{name}, got {deviceNode}" );
// Extract partition number from name (e.g., "disk4s1" -> 1)
var partitionNumber = int . TryParse (
name . LastIndexOf ( 's' ) >= 0 ? name [( name . LastIndexOf ( 's' ) + 1 )..] : "" ,
out int num ) ? num : - 1 ;
// Parse filesystem type
var fsPersonality = PlistHelper . GetStringValue ( dict , "FilesystemPersonality" );
var filesystemType = fsPersonality switch
{
"MS-DOS FAT32" => FileSystemType . FAT32 ,
"ExFAT" => FileSystemType . ExFAT ,
"HFS+" => FileSystemType . HFSPlus ,
"APFS" => FileSystemType . APFS ,
_ => FileSystemType . Unknown
};
2026-02-23 16:55:29 +01:00
2026-03-05 17:42:24 +01:00
// Parse volume UUID
Guid ? volumeGuid = null ;
var volumeUuid = PlistHelper . GetStringValue ( dict , "VolumeUUID" );
if ( Guid . TryParse ( volumeUuid , out Guid parsedGuid ))
volumeGuid = parsedGuid ;
2026-02-23 16:55:29 +01:00
PartitionGeometry partition = new ()
{
2026-03-05 17:42:24 +01:00
Number = partitionNumber ,
2026-02-23 16:55:29 +01:00
Type = PartitionType . Unknown ,
2026-03-05 17:42:24 +01:00
StartOffset = PlistHelper . GetLongValue ( dict , "PartitionOffset" ),
Size = PlistHelper . GetLongValue ( dict , "TotalSize" ),
Name = PlistHelper . GetStringValue ( dict , "VolumeName" ),
FilesystemType = filesystemType ,
VolumeGuid = volumeGuid ,
2026-02-23 16:55:29 +01:00
TableType = PartitionTableType . Unknown
};
partitions . Add ( partition );
2026-02-15 11:45:52 +01:00
}
2026-02-23 16:55:29 +01:00
return partitions . ToArray ();
2026-02-15 11:45:52 +01:00
}
2026-02-27 07:06:41 +01:00
public string ReAttach ( string imagePath , string diskIdentifier , PartitionTableType tableType , bool readOnly = false )
{
2026-03-02 08:49:50 +01:00
// Eject can fail if issued too fast after writing data, which makes diskutil throw an error. Retry a few times with a delay to mitigate this.
RunProcess ( "diskutil" , $"eject {diskIdentifier}" , retryCount : 3 );
2026-02-27 07:06:41 +01:00
var reattachoutput = RunProcess ( "hdiutil" , $"attach -nomount -noautofsck -readonly \" { imagePath } \ "" );
if ( tableType == PartitionTableType . Unknown )
return reattachoutput . Split ( '\n' ). First (). Trim ();
else
return reattachoutput
. Split ( '\n' )
. Select ( x => x . Trim ()
. Split ( ' ' )
. Where ( y => ! string . IsNullOrEmpty ( y ))
. Select ( y => y . Trim ())
. ToArray ()
)
. Where ( x => x . Length > 0 && x [^ 1 ]. EndsWith ( "_partition_scheme" ))
. First (). First ();
}
2026-03-02 08:49:50 +01:00
public static string RunProcess ( string fileName , string arguments , int retryCount = 0 )
2026-02-23 21:04:46 +01:00
{
2026-03-02 08:49:50 +01:00
for ( int i = 0 ; i <= retryCount ; i ++)
2026-02-23 21:04:46 +01:00
{
2026-03-05 15:30:36 +01:00
var result = ProcessRunner . RunProcessAsync ( fileName , arguments , 30_000 ). Await ();
2026-02-23 21:04:46 +01:00
2026-03-05 15:30:36 +01:00
if ( result . ExitCode != 0 )
2026-03-02 08:49:50 +01:00
if ( i < retryCount )
{
2026-03-05 17:42:24 +01:00
Console . WriteLine ( $"Warning: Process {fileName} with arguments {arguments} exited with code {result.ExitCode}. Retrying... Attempt {i + 1} of {retryCount}" );
2026-03-02 08:49:50 +01:00
Thread . Sleep ( 500 );
continue ;
}
else
2026-03-05 15:30:36 +01:00
throw new InvalidOperationException ( $"Process {fileName} with arguments {arguments} exited with code {result.ExitCode}: {result.Error}. Output: {result.Output}" );
2026-03-02 08:49:50 +01:00
2026-03-05 15:30:36 +01:00
return result . Output ;
2026-02-23 21:04:46 +01:00
}
2026-03-02 08:49:50 +01:00
// This should never be hit due to the retry loop logic, but is required to satisfy the compiler.
throw new InvalidOperationException ( $"Failed to run process {fileName} with arguments {arguments} after retries. This should not happen." );
2026-02-23 21:04:46 +01:00
}
2026-02-15 11:45:52 +01:00
/// <inheritdoc />
public void Dispose ()
{
2026-02-23 16:55:29 +01:00
// Nothing to dispose
2026-02-15 11:45:52 +01:00
GC . SuppressFinalize ( this );
}
2026-03-05 17:42:24 +01:00
2026-02-15 11:45:52 +01:00
}
}