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 ;
using Duplicati.Proprietary.DiskImage ;
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
{
2026-02-23 16:55:29 +01:00
/// <summary>
/// Tracks the last attached disk device path for cleanup.
/// </summary>
private string? _lastDiskDevice ;
2026-02-15 11:45:52 +01:00
/// <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)
var psi = new ProcessStartInfo
{
FileName = "hdiutil" ,
2026-02-23 17:07:00 +01:00
Arguments = $"create -size {sizeB}b -type UDIF -layout NONE -o \" { imagePath } \ "" ,
2026-02-23 16:55:29 +01:00
RedirectStandardOutput = true ,
RedirectStandardError = true ,
UseShellExecute = false ,
CreateNoWindow = true
};
2026-02-15 11:45:52 +01:00
2026-02-23 16:55:29 +01:00
using ( var process = new Process { StartInfo = psi })
{
process . Start ();
string output = process . StandardOutput . ReadToEnd ();
string error = process . StandardError . ReadToEnd ();
process . WaitForExit ();
2026-02-15 11:45:52 +01:00
2026-02-23 16:55:29 +01:00
if ( process . ExitCode != 0 )
throw new InvalidOperationException ( $"Failed to create disk image '{imagePath}': {error}" );
}
// Attach the disk image without mounting
psi = new ProcessStartInfo
{
FileName = "hdiutil" ,
Arguments = $"attach -nomount \" { imagePath } \ "" ,
RedirectStandardOutput = true ,
RedirectStandardError = true ,
UseShellExecute = false ,
CreateNoWindow = true
};
string diskDevice ;
using ( var process = new Process { StartInfo = psi })
{
process . Start ();
string output = process . StandardOutput . ReadToEnd ();
string error = process . StandardError . ReadToEnd ();
process . WaitForExit ();
if ( process . ExitCode != 0 )
throw new InvalidOperationException ( $"Failed to attach disk image: {error}" );
// Parse the disk device from output (e.g., "/dev/disk4")
diskDevice = output . Trim (). Split ( new [] { '\r' , '\n' }, StringSplitOptions . RemoveEmptyEntries ). FirstOrDefault ()?. Trim ()
?? throw new InvalidOperationException ( "Failed to get disk device from hdiutil attach output" );
_lastDiskDevice = diskDevice ;
}
// 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-02-23 16:55:29 +01:00
var psi = new ProcessStartInfo
{
FileName = "diskutil" ,
Arguments = $"list {diskIdentifier}" ,
RedirectStandardOutput = true ,
RedirectStandardError = true ,
UseShellExecute = false ,
CreateNoWindow = true
};
using var process = new Process { StartInfo = psi };
process . Start ();
string output = process . StandardOutput . ReadToEnd ();
process . WaitForExit ();
// Very hardcoded to disks with < 10 partitions and no identifiers with spaces. Should be sufficient for test purposes.
return output
. Split ( '\n' )
. Select ( x => x . Trim ())
. Where ( x => x . Length > 1 && x [ 1 ] == ':' )
. Skip ( 2 )
. Select ( x => x . Split ([ ' ' ], StringSplitOptions . RemoveEmptyEntries ). LastOrDefault ())
. Where ( x => x is not null )
. Select ( x => x !)
. ToArray ();
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 )
{
var psi = new ProcessStartInfo
{
FileName = "diskutil" ,
Arguments = $"info /dev/{partition}" ,
RedirectStandardOutput = true ,
RedirectStandardError = true ,
UseShellExecute = false ,
CreateNoWindow = true
};
using var process = new Process { StartInfo = psi };
process . Start ();
string output = process . StandardOutput . ReadToEnd ();
process . WaitForExit ();
var mountLine = output
. Split ( '\n' )
. FirstOrDefault ( l => l . Trim (). StartsWith ( "Mount Point:" ))
?? throw new InvalidOperationException ( $"Failed to get mount point for partition {partition} of disk {diskIdentifier}" );
var mountPoint = mountLine . Split ([ ':' ], 2 )[ 1 ]. Trim ();
if (! string . IsNullOrEmpty ( mountPoint ) && mountPoint != "Not mounted" )
mountPoints . Add ( mountPoint );
}
return [.. mountPoints ];
2026-02-15 11:45:52 +01:00
}
/// <inheritdoc />
2026-02-23 16:55:29 +01:00
public string [] InitializeDisk ( string diskIdentifier , Proprietary . DiskImage . PartitionTableType tableType , ( Proprietary . DiskImage . FileSystemType , long )[] partitions )
2026-02-15 11:45:52 +01:00
{
2026-02-23 16:55:29 +01:00
if ( tableType == Proprietary . DiskImage . PartitionTableType . Unknown )
throw new ArgumentException ( "Invalid partition table type" , nameof ( tableType ));
// Use diskutil to partition the disk with the specified scheme
// "Free Space" means no filesystem, just the partition table
var partitionStrings = partitions . Length > 0 ? partitions . Select ( p =>
{
var fsType = p . Item1 switch
{
Proprietary . DiskImage . FileSystemType . NTFS => throw new NotSupportedException ( "NTFS is not natively supported on macOS for creating partitions. Use ExFAT or FAT32 instead." ),
2026-02-23 17:25:13 +01:00
Proprietary . DiskImage . FileSystemType . FAT32 => "\"MS-DOS FAT32\"" ,
2026-02-23 16:55:29 +01:00
Proprietary . DiskImage . FileSystemType . ExFAT => "ExFAT" ,
Proprietary . DiskImage . FileSystemType . HFSPlus => "HFS+" ,
Proprietary . DiskImage . FileSystemType . APFS => "APFS" ,
_ => throw new ArgumentException ( $"Unsupported filesystem type on macOS: {p.Item1}" , nameof ( partitions ))
};
var sizeArg = p . Item2 > 0 ? $"{p.Item2}" : "0" ;
return $"{fsType} \" Partition \ " {sizeArg}" ;
})
: [ "Free Space \"\" 0" ];
var partitionArgs = string . Join ( " " , partitionStrings );
var psi = new ProcessStartInfo
{
FileName = "diskutil" ,
Arguments = $"partitionDisk {diskIdentifier} {tableType.ToString().ToUpperInvariant()} {partitionArgs}" ,
RedirectStandardOutput = true ,
RedirectStandardError = true ,
UseShellExecute = false ,
CreateNoWindow = true
};
using var process = new Process { StartInfo = psi };
process . Start ();
string output = process . StandardOutput . ReadToEnd ();
string error = process . StandardError . ReadToEnd ();
process . WaitForExit ();
if ( process . ExitCode != 0 )
throw new InvalidOperationException ( $"Failed to initialize disk: {error}" );
if ( partitions . Length == 0 )
return [];
return GetMountPoints ( diskIdentifier );
2026-02-15 11:45:52 +01:00
}
/// <inheritdoc />
2026-02-23 16:55:29 +01:00
public string [] Mount ( string diskIdentifier )
2026-02-15 11:45:52 +01:00
{
2026-02-23 16:55:29 +01:00
// Check if already mounted
var mountPoints = GetMountPoints ( diskIdentifier );
if ( mountPoints . Length > 0 )
return mountPoints ;
// Mount all volumes on the disk
var psi = new ProcessStartInfo
{
FileName = "diskutil" ,
Arguments = $"mountDisk {diskIdentifier}" ,
RedirectStandardOutput = true ,
RedirectStandardError = true ,
UseShellExecute = false ,
CreateNoWindow = true
};
using var process = new Process { StartInfo = psi };
process . Start ();
string output = process . StandardOutput . ReadToEnd ();
string error = process . StandardError . ReadToEnd ();
process . WaitForExit ();
if ( process . ExitCode != 0 )
throw new InvalidOperationException ( $"Failed to mount disk: {error}" );
// Find the mount points for the mounted partitions
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-23 16:55:29 +01:00
// Unmount all volumes on the disk
var psi = new ProcessStartInfo
{
FileName = "diskutil" ,
Arguments = $"unmountDisk {diskIdentifier}" ,
RedirectStandardOutput = true ,
RedirectStandardError = true ,
UseShellExecute = false ,
CreateNoWindow = true
};
using var process = new Process { StartInfo = psi };
process . Start ();
string output = process . StandardOutput . ReadToEnd ();
string error = process . StandardError . ReadToEnd ();
process . WaitForExit ();
if ( process . ExitCode != 0 )
throw new InvalidOperationException ( $"Failed to mount disk: {error}" );
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
var psi = new ProcessStartInfo
{
FileName = "sync" ,
RedirectStandardOutput = true ,
RedirectStandardError = true ,
UseShellExecute = false ,
CreateNoWindow = true
};
using var process = new Process { StartInfo = psi };
process . Start ();
process . WaitForExit ();
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 )
{
var psi = new ProcessStartInfo
{
FileName = "hdiutil" ,
Arguments = $"info" ,
RedirectStandardOutput = true ,
RedirectStandardError = true ,
UseShellExecute = false ,
CreateNoWindow = true
};
using var process = new Process { StartInfo = psi };
process . Start ();
string output = process . StandardOutput . ReadToEnd ();
process . WaitForExit ();
if ( process . ExitCode != 0 )
{
throw new InvalidOperationException ( $"Failed to get hdiutil info: {process.StandardError.ReadToEnd()}" );
}
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
{
var psi = new ProcessStartInfo
{
FileName = "diskutil" ,
Arguments = $"eject {diskIdentifier}" ,
RedirectStandardOutput = true ,
RedirectStandardError = true ,
UseShellExecute = false ,
CreateNoWindow = true
};
using var process = new Process { StartInfo = psi };
process . Start ();
string output = process . StandardOutput . ReadToEnd ();
string error = process . StandardError . ReadToEnd ();
process . WaitForExit ();
if ( process . ExitCode != 0 )
TestContext . Progress . WriteLine ( $"Warning: Failed to eject disk {diskIdentifier}: {error}" );
}
catch ( Exception ex )
{
TestContext . Progress . WriteLine ( $"Warning: Failed to detach disk image: {ex.Message}" );
}
try
{
if ( File . Exists ( imagePath ))
File . Delete ( imagePath );
}
catch ( Exception ex )
{
TestContext . Progress . WriteLine ( $"Warning: Failed to delete disk image file: {ex.Message}" );
}
2026-02-15 11:45:52 +01:00
}
/// <inheritdoc />
public bool HasRequiredPrivileges ()
{
// On macOS, check if running as root (UID 0)
try
{
2026-02-23 16:55:29 +01:00
var psi = new ProcessStartInfo
{
FileName = "id" ,
Arguments = "-u" ,
RedirectStandardOutput = true ,
RedirectStandardError = true ,
UseShellExecute = false ,
CreateNoWindow = true
};
using var process = new Process { StartInfo = psi };
process . Start ();
string output = process . StandardOutput . ReadToEnd (). Trim ();
process . WaitForExit ();
if ( int . TryParse ( output , out int uid ))
{
return uid == 0 ;
}
2026-02-15 11:45:52 +01:00
}
catch
{
2026-02-23 16:55:29 +01:00
// Ignore errors
}
return false ;
}
/// <inheritdoc />
public Proprietary . DiskImage . PartitionTableGeometry GetPartitionTable ( string diskIdentifier )
{
var psi = new ProcessStartInfo
{
FileName = "diskutil" ,
Arguments = $"info {diskIdentifier}" ,
RedirectStandardOutput = true ,
RedirectStandardError = true ,
UseShellExecute = false ,
CreateNoWindow = true
};
using var process = new Process { StartInfo = psi };
process . Start ();
string output = process . StandardOutput . ReadToEnd ();
string error = process . StandardError . ReadToEnd ();
process . WaitForExit ();
if ( process . ExitCode != 0 )
throw new InvalidOperationException ( $"Process diskutil exited with code {process.ExitCode}: {error}" );
var lines = output
. Split ( '\n' )
. Select ( l => l . Trim ())
. Where ( l => l . Length > 0 )
. ToList ();
PartitionTableType tableType = PartitionTableType . Unknown ;
long size = - 1 ;
int sectorSize = - 1 ;
foreach ( var line in lines )
{
var parts = line . Split ([ ':' ], 2 );
switch ( parts [ 0 ]. Trim ())
{
case "Device Node" :
if (! string . Equals ( parts [ 1 ]. Trim (), diskIdentifier , StringComparison . OrdinalIgnoreCase ))
throw new InvalidOperationException ( $"Disk identifier mismatch in diskutil info output: expected {diskIdentifier}, got {parts[1].Trim()}" );
break ;
case "Content (IOContent)" :
tableType = parts [ 1 ]. Trim () switch
{
"GUID_partition_scheme" => PartitionTableType . GPT ,
"MBR_partition_scheme" => PartitionTableType . MBR ,
_ => PartitionTableType . Unknown
};
break ;
case "Disk Size" :
var sizePart = parts [ 1 ]. Trim (). Split ( ' ' ). FirstOrDefault ();
var unitPart = parts [ 1 ]. Trim (). Split ( ' ' ). Skip ( 1 ). FirstOrDefault ();
if ( sizePart != null && unitPart != null )
{
if ( long . TryParse ( sizePart , out long parsedSize ))
{
size = unitPart switch
{
"TB" => parsedSize * 1_000_000_000_000 ,
"GB" => parsedSize * 1_000_000_000 ,
"MB" => parsedSize * 1_000_000 ,
"KB" => parsedSize * 1_000 ,
"B" => parsedSize ,
_ => - 1
};
}
}
break ;
case "Device Block Size" :
var sectorSizePart = parts [ 1 ]. Trim (). Split ( ' ' ). FirstOrDefault ();
if ( sectorSizePart != null && int . TryParse ( sectorSizePart , out int parsedSectorSize ))
sectorSize = parsedSectorSize ;
break ;
}
}
if ( tableType == PartitionTableType . Unknown || size == - 1 || sectorSize == - 1 )
throw new InvalidOperationException ( $"Failed to retrieve partition table information for disk {diskIdentifier}" );
return new Proprietary . DiskImage . PartitionTableGeometry
{
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 )
{
var psi = new ProcessStartInfo
{
FileName = "diskutil" ,
Arguments = $"info /dev/{name}" ,
RedirectStandardOutput = true ,
RedirectStandardError = true ,
UseShellExecute = false ,
CreateNoWindow = true
};
using var process = new Process { StartInfo = psi };
process . Start ();
string output = process . StandardOutput . ReadToEnd ();
string error = process . StandardError . ReadToEnd ();
process . WaitForExit ();
if ( process . ExitCode != 0 )
throw new InvalidOperationException ( $"Process diskutil exited with code {process.ExitCode}: {error}" );
var lines = output
. Split ( '\n' )
. Select ( l => l . Trim ())
. Where ( l => l . Length > 0 )
. ToList ();
PartitionGeometry partition = new ()
{
Number = int . TryParse ( name . AsSpan ( name . Length - 1 ), out int num ) ? num : - 1 ,
Type = PartitionType . Unknown ,
StartOffset = - 1 ,
Size = - 1 ,
Name = null ,
FilesystemType = Proprietary . DiskImage . FileSystemType . Unknown ,
VolumeGuid = null ,
TableType = PartitionTableType . Unknown
};
foreach ( var line in lines )
{
var parts = line . Split ([ ':' ], 2 );
switch ( parts [ 0 ]. Trim ())
{
case "Device Node" :
if (! string . Equals ( parts [ 1 ]. Trim (), $"/dev/{name}" , StringComparison . OrdinalIgnoreCase ))
throw new InvalidOperationException ( $"Partition identifier mismatch in diskutil info output: expected /dev/{name}, got {parts[1].Trim()}" );
break ;
case "Partition Offset" :
var offsetPart = parts [ 1 ]. Trim (). Split ( ' ' ). FirstOrDefault ();
if ( offsetPart != null && long . TryParse ( offsetPart , out long parsedOffset ))
partition . StartOffset = parsedOffset ;
break ;
case "Disk Size" :
var sizePart = parts [ 1 ]. Trim (). Split ( ' ' ). FirstOrDefault ();
if ( sizePart != null && long . TryParse ( sizePart , out long parsedSize ))
partition . Size = parsedSize ;
break ;
case "Volume Name" :
partition . Name = parts [ 1 ]. Trim ();
break ;
case "File System Personality" :
partition . FilesystemType = parts [ 1 ]. Trim () switch
{
"MS-DOS FAT32" => Proprietary . DiskImage . FileSystemType . FAT32 ,
"ExFAT" => Proprietary . DiskImage . FileSystemType . ExFAT ,
"HFS+" => Proprietary . DiskImage . FileSystemType . HFSPlus ,
"APFS" => Proprietary . DiskImage . FileSystemType . APFS ,
_ => Proprietary . DiskImage . FileSystemType . Unknown
};
break ;
case "Volume UUID" :
if ( Guid . TryParse ( parts [ 1 ]. Trim (), out Guid parsedGuid ))
partition . VolumeGuid = parsedGuid ;
break ;
default :
// Ignore other lines for now
break ;
}
}
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
}
/// <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 );
}
}
}