// Copyright (C) 2026, 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.Runtime.InteropServices; using System.Runtime.Versioning; using Mono.Unix.Native; namespace Duplicati.Library.Common.IO { [SupportedOSPlatform("linux")] [SupportedOSPlatform("macOS")] public static class PosixFile { private static readonly bool SUPPORTS_LLISTXATTR; /// /// macOS-specific P/Invoke methods for file flags and ACLs /// [SupportedOSPlatform("macos")] private static class MacOS { private const string LIBC = "libc"; // On x86_64 macOS the bare "stat"/"lstat" symbols are the legacy 32-bit-inode // variants using a smaller (120-byte) struct layout. The 64-bit-inode variants // (matching the MacOSStat layout used here) are exported with the "$INODE64" // suffix. On arm64 there is no legacy variant, so the bare symbol is the // 64-bit-inode variant. See xamarin-macios#11892. [DllImport(LIBC, EntryPoint = "stat$INODE64", SetLastError = true)] private static extern int stat_inode64(string path, out MacOSStat buf); [DllImport(LIBC, EntryPoint = "lstat$INODE64", SetLastError = true)] private static extern int lstat_inode64(string path, out MacOSStat buf); [DllImport(LIBC, EntryPoint = "stat", SetLastError = true)] private static extern int stat_native(string path, out MacOSStat buf); [DllImport(LIBC, EntryPoint = "lstat", SetLastError = true)] private static extern int lstat_native(string path, out MacOSStat buf); /// /// True if the legacy "$INODE64" symbol variants must be used (x86/x86_64). /// private static readonly bool UseInode64Symbols = RuntimeInformation.ProcessArchitecture is Architecture.X86 or Architecture.X64; public static int stat(string path, out MacOSStat buf) => UseInode64Symbols ? stat_inode64(path, out buf) : stat_native(path, out buf); public static int lstat(string path, out MacOSStat buf) => UseInode64Symbols ? lstat_inode64(path, out buf) : lstat_native(path, out buf); [DllImport(LIBC, SetLastError = true)] public static extern int chflags(string path, uint flags); [DllImport(LIBC, SetLastError = true)] public static extern int lchflags(string path, uint flags); [DllImport(LIBC, SetLastError = true)] public static extern IntPtr acl_get_file(string path, int type); [DllImport(LIBC, SetLastError = true)] public static extern IntPtr acl_get_link_np(string path, int type); [DllImport(LIBC, SetLastError = true)] public static extern int acl_set_file(string path, int type, IntPtr acl); [DllImport(LIBC, SetLastError = true)] public static extern int acl_set_link_np(string path, int type, IntPtr acl); [DllImport(LIBC, SetLastError = true)] public static extern IntPtr acl_to_text(IntPtr acl, out IntPtr len); [DllImport(LIBC, SetLastError = true)] public static extern IntPtr acl_from_text(string buf); [DllImport(LIBC, SetLastError = true)] public static extern int acl_free(IntPtr obj); public const int ACL_TYPE_EXTENDED = 0x00000100; } static PosixFile() { bool works = false; try { string[] v; Mono.Unix.Native.Syscall.llistxattr("/", out v); works = true; } catch (EntryPointNotFoundException) { } catch { } SUPPORTS_LLISTXATTR = works; } /// /// Opens the file and honors advisory locking. /// /// A open stream that references the file /// The full path to the file public static System.IO.Stream OpenExclusive(string path, System.IO.FileAccess mode) { return OpenExclusive(path, mode, (int)Mono.Unix.Native.FilePermissions.DEFFILEMODE); } /// /// Opens the file and honors advisory locking. /// /// A open stream that references the file /// The full path to the file /// The file create mode public static System.IO.Stream OpenExclusive(string path, System.IO.FileAccess mode, int filemode) { Flock lck; lck.l_len = 0; lck.l_pid = Syscall.getpid(); lck.l_start = 0; lck.l_type = LockType.F_WRLCK; lck.l_whence = SeekFlags.SEEK_SET; OpenFlags flags = OpenFlags.O_CREAT; if (mode == System.IO.FileAccess.Read) { lck.l_type = LockType.F_RDLCK; flags |= OpenFlags.O_RDONLY; } else if (mode == System.IO.FileAccess.Write) { flags |= OpenFlags.O_WRONLY; } else { flags |= OpenFlags.O_RDWR; } int fd = Syscall.open(path, flags, (Mono.Unix.Native.FilePermissions)filemode); if (fd > 0) { //This does not work on OSX, it gives ENOTTY //int res = Syscall.fcntl(fd, Mono.Unix.Native.FcntlCommand.F_SETLK, ref lck); //This is the same (at least for our purpose, and works on OSX) int res = Syscall.lockf(fd, LockfCommand.F_TLOCK, 0); //If we have the lock, return the stream if (res == 0) return new Mono.Unix.UnixStream(fd); else { Mono.Unix.Native.Syscall.close(fd); throw new LockedFileException(path, mode); } } throw new BadFileException(path); } [Serializable] public abstract class PosixException : System.IO.IOException { public readonly int ErrorCode; public readonly Errno Errno; private readonly string _message; public override string Message => _message; protected PosixException(string message) { Errno = Syscall.GetLastError(); ErrorCode = (int)Errno; _message = string.Format("{0}, error: {1} ({2})", message, Errno, ErrorCode); } } [Serializable] private class BadFileException : PosixException { public BadFileException(string filename) : base(string.Format("Unable to open the file \"{0}\"", filename)) { } } [Serializable] private class LockedFileException : PosixException { public LockedFileException(string filename, System.IO.FileAccess mode) : base(string.Format("Unable to open the file \"{0}\" in mode {1}", filename, mode)) { } } [Serializable] private class FileAccesException : PosixException { public FileAccesException(string filename, string method) : base(string.Format("Unable to access the file \"{0}\" with method {1}", filename, method)) { } } /// /// Gets the symlink target for the given path /// /// The path to get the symlink target for /// The symlink target public static string GetSymlinkTarget(string path) { System.Text.StringBuilder sb = new System.Text.StringBuilder(2048); //2kb, should cover utf16 * 1023 chars if (Mono.Unix.Native.Syscall.readlink(path, sb, (ulong)sb.Capacity) >= 0) return sb.ToString(); throw new System.IO.FileLoadException(string.Format("Unable to get symlink for \"{0}\", error: {1} ({2})", path, Syscall.GetLastError(), (int)Syscall.GetLastError())); } /// /// Creates a new symlink /// /// The path to create the symbolic link entry /// The path the symbolic link points to public static void CreateSymlink(string path, string target) { if (Mono.Unix.Native.Syscall.symlink(target, path) != 0) throw new System.IO.IOException(string.Format("Unable to create symlink from \"{0}\" to \"{1}\", error: {2} ({3})", path, target, Syscall.GetLastError(), (int)Syscall.GetLastError())); } /// /// Enum that describes the different filesystem entry types /// public enum FileType { File, Directory, Symlink, Fifo, Socket, CharacterDevice, BlockDevice, Unknown } /// /// Gets the type of the file. /// /// The file type /// The full path to look up public static FileType GetFileType(string path) { var fse = Mono.Unix.UnixFileInfo.GetFileSystemEntry(path); if (fse.IsRegularFile) return FileType.File; else if (fse.IsDirectory) return FileType.Directory; else if (fse.IsSymbolicLink) return FileType.Symlink; else if (fse.IsFifo) return FileType.Fifo; else if (fse.IsSocket) return FileType.Socket; else if (fse.IsCharacterDevice) return FileType.CharacterDevice; else if (fse.IsBlockDevice) return FileType.BlockDevice; else return FileType.Unknown; } /// /// Gets the extended attributes. /// /// The extended attributes. /// The full path to look up /// A flag indicating if the target is a symlink /// A flag indicating if a symlink should be followed public static Dictionary GetExtendedAttributes(string path, bool isSymlink, bool followSymlink) { // If we get a symlink that we should not follow, we need llistxattr support if (isSymlink && !followSymlink && !SUPPORTS_LLISTXATTR) return null; var use_llistxattr = SUPPORTS_LLISTXATTR && !followSymlink; string[] values; var size = use_llistxattr ? Mono.Unix.Native.Syscall.llistxattr(path, out values) : Mono.Unix.Native.Syscall.listxattr(path, out values); if (size < 0) { // In case the underlying filesystem does not support extended attributes, // we simply return that there are no attributes var err = Syscall.GetLastError(); if (err == Errno.EOPNOTSUPP || err == Errno.ENODATA) return null; throw new FileAccesException(path, use_llistxattr ? "llistxattr" : "listxattr"); } var dict = new Dictionary(); foreach (var s in values) { byte[] v; var n = SUPPORTS_LLISTXATTR ? Mono.Unix.Native.Syscall.lgetxattr(path, s, out v) : Mono.Unix.Native.Syscall.getxattr(path, s, out v); if (n > 0) dict.Add(s, v); } return dict; } /// /// Sets an extended attribute. /// /// The full path to set the values for /// The extended attribute key /// The value to set public static void SetExtendedAttribute(string path, string key, byte[] value) { Mono.Unix.Native.Syscall.setxattr(path, key, value); } /// /// Describes the basic user/group/perm tuplet for a file or folder /// public struct FileInfo { public readonly long UID; public readonly long GID; public readonly long Permissions; public readonly string OwnerName; public readonly string GroupName; internal FileInfo(Mono.Unix.UnixFileSystemInfo fse) { UID = fse.OwnerUserId; GID = fse.OwnerGroupId; Permissions = (long)fse.FileAccessPermissions; try { OwnerName = fse.OwnerUser.UserName; } catch (ArgumentException) { // Could not retrieve user name, possibly the user is not defined on the local system OwnerName = null; } try { GroupName = fse.OwnerGroup.GroupName; } catch (ArgumentException) { // Could not retrieve group name, possibly the group is not defined on the local system GroupName = null; } } } /// /// Gets the basic user/group/perm tuplet for a file or folder /// /// The basic user/group/perm tuplet for a file or folder /// The full path to look up public static FileInfo GetUserGroupAndPermissions(string path) { return new FileInfo(Mono.Unix.UnixFileInfo.GetFileSystemEntry(path)); } /// /// Sets the basic user/group/perm tuplet for a file or folder /// /// The full path to look up /// The owner user id to set /// The owner group id to set /// The file access permissions to set public static void SetUserGroupAndPermissions(string path, long uid, long gid, long permissions) { Mono.Unix.UnixFileInfo.GetFileSystemEntry(path).SetOwner(uid, gid); Mono.Unix.UnixFileInfo.GetFileSystemEntry(path).FileAccessPermissions = (Mono.Unix.FileAccessPermissions)permissions; } /// /// Gets the UID from a user name /// /// The user ID. /// The user name. public static long GetUserID(string name) { return new Mono.Unix.UnixUserInfo(name).UserId; } /// /// Gets the GID from a group name /// /// The user ID. /// The group name. public static long GetGroupID(string name) { return new Mono.Unix.UnixGroupInfo(name).GroupId; } /// /// Gets the number of hard links for a file /// /// The hardlink count /// The full path to look up public static long GetHardlinkCount(string path) { var fse = Mono.Unix.UnixFileInfo.GetFileSystemEntry(path); if (fse.IsRegularFile || fse.IsDirectory) return fse.LinkCount; else return 0; } /// /// Gets a unique ID for the path inode target, /// which is the device ID and inode ID /// joined with a ":" /// /// The inode target ID. /// The full path to look up public static string GetInodeTargetID(string path) { var fse = Mono.Unix.UnixFileInfo.GetFileSystemEntry(path); return fse.Device + ":" + fse.Inode; } /// /// macOS stat struct layout for 64-bit systems /// [SupportedOSPlatform("macos")] [StructLayout(LayoutKind.Explicit, Size = 144)] private struct MacOSStat { [FieldOffset(0)] public uint st_dev; [FieldOffset(4)] public ushort st_mode; [FieldOffset(6)] public ushort st_nlink; [FieldOffset(8)] public ulong st_ino; [FieldOffset(16)] public uint st_uid; [FieldOffset(20)] public uint st_gid; [FieldOffset(24)] public uint st_rdev; [FieldOffset(32)] public long st_atime_sec; [FieldOffset(40)] public long st_atime_nsec; [FieldOffset(48)] public long st_mtime_sec; [FieldOffset(56)] public long st_mtime_nsec; [FieldOffset(64)] public long st_ctime_sec; [FieldOffset(72)] public long st_ctime_nsec; [FieldOffset(80)] public long st_birthtime_sec; [FieldOffset(88)] public long st_birthtime_nsec; [FieldOffset(96)] public long st_size; [FieldOffset(104)] public long st_blocks; [FieldOffset(112)] public int st_blksize; [FieldOffset(116)] public uint st_flags; [FieldOffset(120)] public uint st_gen; } /// /// Gets the macOS file flags (e.g., uchg, hidden) for the given path. /// When the path is a symlink that should not be followed, the flags of /// the link itself are returned rather than those of the target. /// /// The file flags, or null if not supported or an error occurred. /// The full path to look up /// A flag indicating if the target is a symlink /// A flag indicating if a symlink should be followed [SupportedOSPlatform("macos")] public static uint? GetFileFlags(string path, bool isSymlink, bool followSymlink) { if (!OperatingSystem.IsMacOS()) return null; var useLink = isSymlink && !followSymlink; var res = useLink ? MacOS.lstat(path, out var buf) : MacOS.stat(path, out buf); if (res == 0) return buf.st_flags; return null; } /// /// Sets the macOS file flags (e.g., uchg, hidden) for the given path. /// When the path is a symlink that should not be followed, the flags are /// applied to the link itself rather than to the target. /// /// The full path to set flags for /// The flags to set /// A flag indicating if the target is a symlink /// A flag indicating if a symlink should be followed [SupportedOSPlatform("macos")] public static void SetFileFlags(string path, uint flags, bool isSymlink, bool followSymlink) { if (!OperatingSystem.IsMacOS()) return; var useLink = isSymlink && !followSymlink; var res = useLink ? MacOS.lchflags(path, flags) : MacOS.chflags(path, flags); if (res != 0) { var errno = Marshal.GetLastWin32Error(); throw new System.IO.IOException($"Unable to set file flags on \"{path}\", error: {errno}"); } } /// /// Gets the macOS ACL as a text string for the given path. /// /// The ACL text, or null if no ACL or not supported. /// The full path to look up /// A flag indicating if the target is a symlink /// A flag indicating if a symlink should be followed [SupportedOSPlatform("macos")] public static string GetAcl(string path, bool isSymlink, bool followSymlink) { if (!OperatingSystem.IsMacOS()) return null; IntPtr acl = IntPtr.Zero; try { var useLink = isSymlink && !followSymlink; acl = useLink ? MacOS.acl_get_link_np(path, MacOS.ACL_TYPE_EXTENDED) : MacOS.acl_get_file(path, MacOS.ACL_TYPE_EXTENDED); // A null result means there is no ACL, the file does not exist, // or ACLs are not supported on the filesystem; in all cases there // is nothing to capture. if (acl == IntPtr.Zero) return null; var textPtr = MacOS.acl_to_text(acl, out var length); if (textPtr == IntPtr.Zero) return null; try { var text = Marshal.PtrToStringUTF8(textPtr, (int)length); // ACL text can be empty or just whitespace for no ACL if (string.IsNullOrWhiteSpace(text)) return null; return text; } finally { MacOS.acl_free(textPtr); } } finally { if (acl != IntPtr.Zero) MacOS.acl_free(acl); } } /// /// Sets the macOS ACL from a text string for the given path. /// When the path is a symlink that should not be followed, the ACL is /// applied to the link itself rather than to the target. /// /// The full path to set ACL for /// The ACL text /// A flag indicating if the target is a symlink /// A flag indicating if a symlink should be followed [SupportedOSPlatform("macos")] public static void SetAcl(string path, string aclText, bool isSymlink, bool followSymlink) { if (!OperatingSystem.IsMacOS() || string.IsNullOrWhiteSpace(aclText)) return; IntPtr acl = IntPtr.Zero; try { acl = MacOS.acl_from_text(aclText); if (acl == IntPtr.Zero) { var errno = Marshal.GetLastWin32Error(); throw new System.IO.IOException($"Unable to parse ACL text for \"{path}\", error: {errno}"); } var useLink = isSymlink && !followSymlink; var res = useLink ? MacOS.acl_set_link_np(path, MacOS.ACL_TYPE_EXTENDED, acl) : MacOS.acl_set_file(path, MacOS.ACL_TYPE_EXTENDED, acl); if (res != 0) { var errno = Marshal.GetLastWin32Error(); throw new System.IO.IOException($"Unable to set ACL on \"{path}\", error: {errno}"); } } finally { if (acl != IntPtr.Zero) MacOS.acl_free(acl); } } } }