// 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);
}
}
}
}