// 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; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; using System.Xml.Linq; using Duplicati.Library.Utility; using Duplicati.Proprietary.DiskImage; using Duplicati.Proprietary.DiskImage.General; using NUnit.Framework; #nullable enable namespace Duplicati.UnitTest.DiskImage { /// /// macOS implementation of using hdiutil and standard macOS tools. /// internal class MacOSDiskImageHelper : IDiskImageHelper { /// public string CreateDisk(string imagePath, long sizeB) { // 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) RunProcess("hdiutil", $"create -size {sizeB} -type UDIF -layout NONE -o \"{imagePath}\""); var output = RunProcess("hdiutil", $"attach -nomount -noautofsck \"{imagePath}\""); // 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"); // Return the raw device path for direct I/O return diskDevice.Replace("/dev/disk", "/dev/rdisk"); } private string[] GetPartitionNames(string diskIdentifier) { 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(); // 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]; } private string[] GetMountPoints(string diskIdentifier) { var partitions = GetPartitionNames(diskIdentifier); var mountPoints = new System.Collections.Generic.List(); foreach (var partition in partitions) { var output = RunProcess("diskutil", $"info -plist /dev/{partition}"); var plist = XDocument.Parse(output); var dict = plist.Element("plist")?.Element("dict"); if (dict == null) continue; // Check if this is an APFS Physical Store var contentType = PlistHelper.GetStringValue(dict, "Content"); var isApfsPhysicalStore = contentType == "Apple_APFS"; if (isApfsPhysicalStore) { // For APFS, get the container and then get volumes from it var containerId = PlistHelper.GetStringValue(dict, "APFSContainerReference"); if (!string.IsNullOrEmpty(containerId)) { // Get volumes from the container (they are like disk5s1, disk5s2, etc.) var containerVolumes = GetApfsContainerVolumes(containerId); foreach (var volume in containerVolumes) { 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); } } } } else { // Regular partition - extract mount point directly var mountPoint = PlistHelper.GetStringValue(dict, "MountPoint"); if (!string.IsNullOrEmpty(mountPoint)) mountPoints.Add(mountPoint); } } return [.. mountPoints]; } private string[] GetApfsContainerVolumes(string containerId) { var output = RunProcess("diskutil", $"list -plist {containerId}"); var plist = XDocument.Parse(output); var rootDict = plist.Element("plist")?.Element("dict"); if (rootDict == null) return []; var allDisksAndPartitions = PlistHelper.GetArrayElement(rootDict, "AllDisksAndPartitions"); if (allDisksAndPartitions == null) return []; // 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"); var container = allDisksAndPartitions.Elements("dict") .FirstOrDefault(d => PlistHelper.GetStringValue(d, "DeviceIdentifier") == normalizedId); if (container == null) return []; var volumes = new List(); 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); } } return [.. volumes]; } /// public string[] InitializeDisk(string diskIdentifier, PartitionTableType tableType, (FileSystemType, long)[] partitions) { if (tableType == PartitionTableType.Unknown) return []; // No partition table, so nothing to initialize // 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, i) => { var fsType = p.Item1 switch { 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", _ => throw new ArgumentException($"Unsupported filesystem type on macOS: {p.Item1}", nameof(partitions)) }; var sizeArg = p.Item2 > 0 ? $"{p.Item2}" : "0"; return $"{fsType} \"Partition {i + 1}\" {sizeArg}"; }) : ["\"Free Space\" \"\" 0"]; var partitionArgs = string.Join(" ", partitionStrings); // 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}"); if (partitions.Length == 0) return []; return GetMountPoints(diskIdentifier); } /// public string[] Mount(string diskIdentifier, string? baseMountPath = null, bool readOnly = false, FileSystemType[]? fileSystemTypes = null) { // Check if already mounted try { var existingMountPoints = GetMountPoints(diskIdentifier); if (existingMountPoints.Length > 0 && existingMountPoints.All(mp => mp.StartsWith(baseMountPath ?? "/Volumes/"))) { return existingMountPoints; } } catch { // Ignore errors and try mounting } // Get the list of partitions first to ensure we target the correct disk var partitions = GetPartitionNames(diskIdentifier); List mountPoints = []; foreach (var partition in partitions) { var output = RunProcess("diskutil", $"info -plist /dev/{partition}"); var plist = XDocument.Parse(output); var dict = plist.Element("plist")?.Element("dict"); if (dict == null) continue; // Check if this is an APFS Physical Store var contentType = PlistHelper.GetStringValue(dict, "Content"); var isApfsPhysicalStore = contentType == "Apple_APFS"; if (isApfsPhysicalStore) { // For APFS, get the container and mount volumes from it var containerId = PlistHelper.GetStringValue(dict, "APFSContainerReference"); if (!string.IsNullOrEmpty(containerId)) { // Get volumes from the container (they are like disk5s1, disk5s2, etc.) var containerVolumes = GetApfsContainerVolumes(containerId); foreach (var volume in containerVolumes) { MountVolume(volume, baseMountPath, mountPoints, readOnly); } } } else { // Regular partition - mount directly MountVolume(partition, baseMountPath, mountPoints, readOnly); } } return mountPoints.Count == 0 ? WaitForMountPoints(diskIdentifier) : [.. mountPoints]; } /// /// Mounts a single volume/partition with optional custom mount point. /// /// The volume identifier (e.g., "disk4s1"). /// Optional base path for mounting. /// List to collect mount points. /// Indicates whether the volume should be mounted as read-only. private void MountVolume(string volume, string? baseMountPath, List mountPoints, bool readOnly) { var dir = ""; var mountArgs = readOnly ? "-readOnly" : ""; 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 var readOnlyArg = readOnly ? " -readOnly" : ""; mountArgs = $"{readOnlyArg} -mountPoint \"{dir}\""; } // Unmount first in case it's already mounted somewhere else, to ensure it gets mounted to the correct location var oldMp = GetMountPointFromPlist(RunProcess("diskutil", $"info -plist /dev/{volume}")); if (oldMp is not null && !oldMp.StartsWith(baseMountPath ?? "/Volumes/")) RunProcess("diskutil", $"unmount force /dev/{volume}"); RunProcess("diskutil", $"mount -nobrowse {mountArgs} /dev/{volume}"); // Verify the mount var mp = RunProcess("diskutil", $"info -plist /dev/{volume}"); var mountPoint = GetMountPointFromPlist(mp); if (!string.IsNullOrEmpty(mountPoint)) { mountPoints.Add(mountPoint); if (!mountPoint.StartsWith(baseMountPath ?? "/Volumes/")) { Console.WriteLine($"Warning: Volume /dev/{volume} did not mount to expected location. Output: {mp}"); } } else { Console.WriteLine($"Warning: Volume /dev/{volume} did not report a mount point after mounting. Output: {mp}"); } } /// /// Extracts the mount point from diskutil info -plist output. /// 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; } } /// /// 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) /// /// The disk identifier to check mount points for. /// An array of mount point paths. 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 return GetMountPoints(diskIdentifier); } public void Unmount(string diskIdentifier) { 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); } } } /// public void FlushDisk(string diskIdentifier) { // On macOS, use sync to flush all filesystem buffers RunProcess("sync", ""); } /// public void CleanupDisk(string imagePath, string? diskIdentifier = null) { if (diskIdentifier is null) { var output = RunProcess("hdiutil", $"info"); 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(['\t'], 2)[0] .Trim(); return device; }) .FirstOrDefault(d => d != null); } if (diskIdentifier is null) throw new InvalidOperationException($"Failed to determine disk identifier for {imagePath} during cleanup"); try { RunProcess("diskutil", $"eject {diskIdentifier}"); } catch (Exception ex) { Console.WriteLine($"Warning: Failed to detach disk image: {ex.Message}"); } try { if (File.Exists(imagePath)) File.Delete(imagePath); } catch (Exception ex) { Console.WriteLine($"Warning: Failed to delete disk image file: {ex.Message}"); } } /// public bool HasRequiredPrivileges() { // Root is not required for virtual disks. return true; } /// public PartitionTableGeometry GetPartitionTable(string diskIdentifier) { 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 { "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"); if (size == 0 || sectorSize == 0) throw new InvalidOperationException($"Failed to retrieve partition table information for disk {diskIdentifier}"); return new 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 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 }; // Parse volume UUID Guid? volumeGuid = null; var volumeUuid = PlistHelper.GetStringValue(dict, "VolumeUUID"); if (Guid.TryParse(volumeUuid, out Guid parsedGuid)) volumeGuid = parsedGuid; PartitionGeometry partition = new() { Number = partitionNumber, Type = PartitionType.Unknown, StartOffset = PlistHelper.GetLongValue(dict, "PartitionOffset"), Size = PlistHelper.GetLongValue(dict, "TotalSize"), Name = PlistHelper.GetStringValue(dict, "VolumeName"), FilesystemType = filesystemType, VolumeGuid = volumeGuid, TableType = PartitionTableType.Unknown }; partitions.Add(partition); } return partitions.ToArray(); } public string ReAttach(string imagePath, string diskIdentifier, PartitionTableType tableType, bool readOnly = false) { // 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); 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(); } public static string RunProcess(string fileName, string arguments, int retryCount = 0) { for (int i = 0; i <= retryCount; i++) { var result = ProcessRunner.RunProcessAsync(fileName, arguments, 30_000).Await(); if (result.ExitCode != 0) if (i < retryCount) { Console.WriteLine($"Warning: Process {fileName} with arguments {arguments} exited with code {result.ExitCode}. Retrying... Attempt {i + 1} of {retryCount}"); Thread.Sleep(500); continue; } else throw new InvalidOperationException($"Process {fileName} with arguments {arguments} exited with code {result.ExitCode}: {result.Error}. Output: {result.Output}"); return result.Output; } // 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."); } /// public void Dispose() { // Nothing to dispose GC.SuppressFinalize(this); } } }