diff --git a/Duplicati/UnitTest/DiskImage/MacOSDiskImageHelper.cs b/Duplicati/UnitTest/DiskImage/MacOSDiskImageHelper.cs
index 35fb512e4..4d4073a85 100644
--- a/Duplicati/UnitTest/DiskImage/MacOSDiskImageHelper.cs
+++ b/Duplicati/UnitTest/DiskImage/MacOSDiskImageHelper.cs
@@ -20,7 +20,14 @@
// DEALINGS IN THE SOFTWARE.
using System;
+using System.Collections.Generic;
+using System.Diagnostics;
using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Xml.Linq;
+using Duplicati.Proprietary.DiskImage;
+using NUnit.Framework;
#nullable enable
@@ -28,122 +35,587 @@ namespace Duplicati.UnitTest.DiskImage
{
///
/// macOS implementation of using hdiutil and standard macOS tools.
- /// This is a stub implementation that throws for all operations.
///
internal class MacOSDiskImageHelper : IDiskImageHelper
{
+ ///
+ /// Tracks the last attached disk device path for cleanup.
+ ///
+ private string? _lastDiskDevice;
+
///
- /// Always thrown as macOS support is not yet implemented.
- public string CreateAndAttachDisk(string imagePath, long sizeMB)
+ public string CreateDisk(string imagePath, long sizeMB)
{
- throw new NotImplementedException("macOS disk image operations are not yet implemented.");
+ // Ensure the directory exists
+ var directory = Path.GetDirectoryName(imagePath);
+ if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ Directory.CreateDirectory(directory);
+
+ // Delete existing image if it exists
+ if (File.Exists(imagePath))
+ try
+ {
+ CleanupDisk(imagePath);
+ }
+ catch
+ {
+ // Ignore errors during cleanup
+ }
+
+ // Create a raw disk image using hdiutil
+ // Use UDIF format with "Free Space" filesystem (unformatted)
+ var psi = new ProcessStartInfo
+ {
+ FileName = "hdiutil",
+ Arguments = $"create -size {sizeMB}m -type UDIF -layout NONE -o \"{imagePath}\"",
+ 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 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");
+ }
+
+ private string[] GetPartitionNames(string diskIdentifier)
+ {
+ 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();
+ }
+
+ private string[] GetMountPoints(string diskIdentifier)
+ {
+ var partitions = GetPartitionNames(diskIdentifier);
+ var mountPoints = new System.Collections.Generic.List();
+
+ 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];
}
///
- /// Always thrown as macOS support is not yet implemented.
- public int GetDiskNumber(string imagePath)
+ public string[] InitializeDisk(string diskIdentifier, Proprietary.DiskImage.PartitionTableType tableType, (Proprietary.DiskImage.FileSystemType, long)[] partitions)
{
- throw new NotImplementedException("macOS disk image operations are not yet implemented.");
+ 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."),
+ Proprietary.DiskImage.FileSystemType.FAT32 => "MS-DOS FAT32",
+ 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);
}
///
- /// Always thrown as macOS support is not yet implemented.
- public void InitializeDisk(int diskNumber, Duplicati.Proprietary.DiskImage.PartitionTableType tableType)
+ public string[] Mount(string diskIdentifier)
{
- throw new NotImplementedException("macOS disk image operations are not yet implemented.");
+ // 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);
+ }
+
+ public void Unmount(string diskIdentifier)
+ {
+ // 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}");
}
///
- /// Always thrown as macOS support is not yet implemented.
- public char CreateAndFormatPartition(int diskNumber, Duplicati.Proprietary.DiskImage.FileSystemType fsType, long sizeMB = 0)
+ public void FlushDisk(string diskIdentifier)
{
- throw new NotImplementedException("macOS disk image operations are not yet implemented.");
+ // 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();
}
///
- /// Always thrown as macOS support is not yet implemented.
- public void FlushVolume(char driveLetter)
+ public void CleanupDisk(string imagePath, string? diskIdentifier = null)
{
- throw new NotImplementedException("macOS disk image operations are not yet implemented.");
+ 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();
+ var imagePathLine = lines.FirstOrDefault(l => l.StartsWith("image-path:"));
+ 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
+ .Split([' '], 2)[0]
+ .Trim();
+
+ return device;
+ })
+ .FirstOrDefault(d => d != null);
+ }
+
+ 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}");
+ }
}
///
- /// Always thrown as macOS support is not yet implemented.
- public void PopulateTestData(char driveLetter, int fileCount = 10, int fileSizeKB = 10)
- {
- throw new NotImplementedException("macOS disk image operations are not yet implemented.");
- }
-
- ///
- /// Always thrown as macOS support is not yet implemented.
- public void DetachDisk(string imagePath)
- {
- throw new NotImplementedException("macOS disk image operations are not yet implemented.");
- }
-
- ///
- /// Always thrown as macOS support is not yet implemented.
- public void UnmountForWriting(string imagePath, char? driveLetter = null)
- {
- throw new NotImplementedException("macOS disk image operations are not yet implemented.");
- }
-
- ///
- /// Always thrown as macOS support is not yet implemented.
- public void BringOnline(string imagePath)
- {
- throw new NotImplementedException("macOS disk image operations are not yet implemented.");
- }
-
- ///
- /// Always thrown as macOS support is not yet implemented.
- public char MountForReading(string imagePath, char? driveLetter = null)
- {
- throw new NotImplementedException("macOS disk image operations are not yet implemented.");
- }
-
- ///
- /// Always thrown as macOS support is not yet implemented.
- public void CleanupDisk(string imagePath)
- {
- throw new NotImplementedException("macOS disk image operations are not yet implemented.");
- }
-
- ///
- /// Always thrown as macOS support is not yet implemented.
- public string GetDiskDetails(int diskNumber)
- {
- throw new NotImplementedException("macOS disk image operations are not yet implemented.");
- }
-
- ///
- /// Always thrown as macOS support is not yet implemented.
- public string GetVolumeInfo(char driveLetter)
- {
- throw new NotImplementedException("macOS disk image operations are not yet implemented.");
- }
-
- ///
- ///
- /// On macOS, checks if running as root.
- ///
public bool HasRequiredPrivileges()
{
// On macOS, check if running as root (UID 0)
try
{
- return Environment.UserName == "root";
+ 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;
+ }
}
catch
{
- return false;
+ // Ignore errors
}
+
+ return false;
+ }
+
+ ///
+ 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.
+ };
+ }
+
+ ///
+ public PartitionGeometry[] GetPartitions(string diskIdentifier)
+ {
+ var names = GetPartitionNames(diskIdentifier);
+
+ var partitions = new List();
+ 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);
+ }
+
+ return partitions.ToArray();
}
///
public void Dispose()
{
- // Nothing to dispose in the stub implementation
+ // Nothing to dispose
GC.SuppressFinalize(this);
}
}