diff --git a/.gitmodules b/.gitmodules
index 6131918f6..0a1560294 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -3,4 +3,7 @@
url = https://github.com/securefolderfs-community/nwebdav.git
[submodule "lib/Tmds.Fuse"]
path = lib/Tmds.Fuse
- url = https://github.com/securefolderfs-community/Tmds.Fuse
\ No newline at end of file
+ url = https://github.com/securefolderfs-community/Tmds.Fuse
+[submodule "lib/FuseSharp"]
+ path = lib/FuseSharp
+ url = git@github.com:securefolderfs-community/FuseSharp.git
diff --git a/SecureFolderFS.Public.slnx b/SecureFolderFS.Public.slnx
index c7b978124..2d771d68c 100644
--- a/SecureFolderFS.Public.slnx
+++ b/SecureFolderFS.Public.slnx
@@ -3,6 +3,7 @@
+
@@ -19,6 +20,7 @@
+
diff --git a/SecureFolderFS.slnx b/SecureFolderFS.slnx
index f7d1d4207..ddfb900cc 100644
--- a/SecureFolderFS.slnx
+++ b/SecureFolderFS.slnx
@@ -21,6 +21,11 @@
+
+
+
+
+
@@ -57,6 +62,11 @@
+
+
+
+
+
diff --git a/lib/FuseSharp b/lib/FuseSharp
new file mode 160000
index 000000000..fc1f76aa7
--- /dev/null
+++ b/lib/FuseSharp
@@ -0,0 +1 @@
+Subproject commit fc1f76aa780d0fa90f2517a5eea9ff901feb2635
diff --git a/src/Core/SecureFolderFS.Core.FUSE/SecureFolderFS.Core.FUSE.csproj b/src/Core/SecureFolderFS.Core.FUSE/SecureFolderFS.Core.FUSE.csproj
index 62fffd219..f4feb3bfa 100644
--- a/src/Core/SecureFolderFS.Core.FUSE/SecureFolderFS.Core.FUSE.csproj
+++ b/src/Core/SecureFolderFS.Core.FUSE/SecureFolderFS.Core.FUSE.csproj
@@ -4,14 +4,7 @@
net10.0
enable
enable
-
-
-
- true
-
-
-
- true
+ true
diff --git a/src/Core/SecureFolderFS.Core.MacFuse/AppModels/MacFuseOptions.cs b/src/Core/SecureFolderFS.Core.MacFuse/AppModels/MacFuseOptions.cs
new file mode 100644
index 000000000..f645865ac
--- /dev/null
+++ b/src/Core/SecureFolderFS.Core.MacFuse/AppModels/MacFuseOptions.cs
@@ -0,0 +1,73 @@
+using SecureFolderFS.Core.FileSystem.AppModels;
+using SecureFolderFS.Shared.Extensions;
+using SecureFolderFS.Storage.VirtualFileSystem;
+
+namespace SecureFolderFS.Core.MacFuse.AppModels
+{
+ ///
+ public sealed class MacFuseOptions : VirtualFileSystemOptions
+ {
+ ///
+ /// Gets the path where the file system should be mounted. If a null value is given, default mount point will be used.
+ ///
+ public string? MountPoint { get; init; }
+
+ ///
+ /// Gets whether the root user should have access to the filesystem.
+ ///
+ ///
+ ///
+ /// Requires the allow_other macFUSE tunable to be enabled (sysctl vfs.generic.macfuse.tunables.allow_other=1).
+ ///
+ ///
+ /// This option is mutually exclusive with .
+ ///
+ ///
+ public bool AllowRootUserAccess { get; init; }
+
+ ///
+ /// Gets whether other users, including root, should have access to the filesystem.
+ ///
+ ///
+ ///
+ /// Requires the allow_other macFUSE tunable to be enabled (sysctl vfs.generic.macfuse.tunables.allow_other=1).
+ ///
+ ///
+ /// This option is mutually exclusive with .
+ ///
+ ///
+ public bool AllowOtherUserAccess { get; init; }
+
+ ///
+ /// Gets whether to print debugging information to the console.
+ ///
+ public bool PrintDebugInformation { get; init; }
+
+ ///
+ public override string? GetDescription()
+ {
+ return MountPoint;
+ }
+
+ public static MacFuseOptions ToOptions(IDictionary options)
+ {
+ return new()
+ {
+ VolumeName = (string?)options.Get(nameof(VolumeName)) ?? throw new ArgumentNullException(nameof(VolumeName)),
+ HealthStatistics = (IHealthStatistics?)options.Get(nameof(HealthStatistics)) ?? new HealthStatistics(),
+ FileSystemStatistics = (IFileSystemStatistics?)options.Get(nameof(FileSystemStatistics)) ?? new FileSystemStatistics(),
+ IsReadOnly = (bool?)options.Get(nameof(IsReadOnly)) ?? false,
+ IsCachingChunks = (bool?)options.Get(nameof(IsCachingChunks)) ?? true,
+ IsCachingFileNames = (bool?)options.Get(nameof(IsCachingFileNames)) ?? false,
+ IsCachingDirectoryIds = (bool?)options.Get(nameof(IsCachingDirectoryIds)) ?? true,
+ RecycleBinSize = (long?)options.Get(nameof(RecycleBinSize)) ?? 0L,
+
+ // macFUSE specific
+ MountPoint = (string?)options.Get(nameof(MountPoint)),
+ AllowRootUserAccess = (bool?)options.Get(nameof(AllowRootUserAccess)) ?? false,
+ AllowOtherUserAccess = (bool?)options.Get(nameof(AllowOtherUserAccess)) ?? false,
+ PrintDebugInformation = (bool?)options.Get(nameof(PrintDebugInformation)) ?? false
+ };
+ }
+ }
+}
diff --git a/src/Core/SecureFolderFS.Core.MacFuse/Callbacks/BaseMacFuseCallbacks.cs b/src/Core/SecureFolderFS.Core.MacFuse/Callbacks/BaseMacFuseCallbacks.cs
new file mode 100644
index 000000000..470b3eab6
--- /dev/null
+++ b/src/Core/SecureFolderFS.Core.MacFuse/Callbacks/BaseMacFuseCallbacks.cs
@@ -0,0 +1,26 @@
+using FuseSharp;
+using SecureFolderFS.Core.FileSystem;
+using SecureFolderFS.Core.MacFuse.AppModels;
+using SecureFolderFS.Core.MacFuse.OpenHandles;
+
+namespace SecureFolderFS.Core.MacFuse.Callbacks
+{
+ internal abstract class BaseMacFuseCallbacks : FuseFileSystemBase
+ {
+ protected FileSystemSpecifics specifics;
+ protected readonly MacFuseHandlesManager handlesManager;
+
+ protected BaseMacFuseCallbacks(FileSystemSpecifics specifics, MacFuseHandlesManager handlesManager)
+ {
+ this.specifics = specifics;
+ this.handlesManager = handlesManager;
+ }
+
+ ///
+ /// Null before the filesystem has been mounted.
+ ///
+ public MacFuseOptions? FuseOptions { get; set; }
+
+ protected abstract string? GetCiphertextPath(ReadOnlySpan plaintextName);
+ }
+}
diff --git a/src/Core/SecureFolderFS.Core.MacFuse/Callbacks/OnDeviceMacFuse.cs b/src/Core/SecureFolderFS.Core.MacFuse/Callbacks/OnDeviceMacFuse.cs
new file mode 100644
index 000000000..79260bf2c
--- /dev/null
+++ b/src/Core/SecureFolderFS.Core.MacFuse/Callbacks/OnDeviceMacFuse.cs
@@ -0,0 +1,722 @@
+using System.Text;
+using FuseSharp;
+using FuseSharp.Native;
+using OwlCore.Storage;
+using SecureFolderFS.Core.FileSystem;
+using SecureFolderFS.Core.FileSystem.Helpers.Paths;
+using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract;
+using SecureFolderFS.Core.FileSystem.Helpers.Paths.Native;
+using SecureFolderFS.Core.FileSystem.Helpers.RecycleBin.Native;
+using SecureFolderFS.Core.MacFuse.OpenHandles;
+using SecureFolderFS.Storage.Extensions;
+using static FuseSharp.Native.LibC;
+
+namespace SecureFolderFS.Core.MacFuse.Callbacks
+{
+ internal sealed class OnDeviceMacFuse : BaseMacFuseCallbacks
+ {
+ public override bool SupportsMultiThreading => true;
+
+ public OnDeviceMacFuse(FileSystemSpecifics specifics, MacFuseHandlesManager handlesManager)
+ : base(specifics, handlesManager)
+ {
+ }
+
+ // Access doesn't need to be implemented due to the default_permissions mount option
+
+ public override int ChMod(ReadOnlySpan path, uint mode, FuseFileInfoRef fiRef)
+ {
+ if (FuseOptions!.IsReadOnly)
+ return -EROFS;
+
+ var ciphertextPath = GetCiphertextPath(path);
+ if (ciphertextPath is null)
+ return -ENOENT;
+
+ try
+ {
+ File.SetUnixFileMode(ciphertextPath, (UnixFileMode)(mode & 0xFFFu));
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ return -MapExceptionToErrno(ex);
+ }
+ }
+
+ public override unsafe int Chown(ReadOnlySpan path, uint uid, uint gid, FuseFileInfoRef fiRef)
+ {
+ if (FuseOptions!.IsReadOnly)
+ return -EROFS;
+
+ var ciphertextPath = GetCiphertextPath(path);
+ if (ciphertextPath is null)
+ return -ENOENT;
+
+ fixed (byte* ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath))
+ {
+ if (chown(ciphertextPathPtr, uid, gid) == -1)
+ return -errno;
+ }
+
+ return 0;
+ }
+
+ public override int Create(ReadOnlySpan path, uint mode, ref FuseFileInfo fi)
+ {
+ if (FuseOptions!.IsReadOnly)
+ return -EROFS;
+
+ var ciphertextPath = GetCiphertextPath(path);
+ if (ciphertextPath is null)
+ return -ENOENT;
+
+ var fileExists = File.Exists(ciphertextPath);
+ if ((fi.flags & O_CREAT) != 0 && (fi.flags & O_EXCL) != 0 && fileExists)
+ return -EEXIST;
+
+ try
+ {
+ if (!fileExists)
+ {
+ File.Create(ciphertextPath).Dispose();
+ File.SetUnixFileMode(ciphertextPath, (UnixFileMode)(mode & 0xFFFu));
+ }
+ }
+ catch (Exception ex)
+ {
+ return -MapExceptionToErrno(ex);
+ }
+
+ return Open(path, ref fi);
+ }
+
+ public override int FAllocate(ReadOnlySpan path, int mode, ulong offset, long length, ref FuseFileInfo fi)
+ {
+ if (FuseOptions!.IsReadOnly)
+ return -EROFS;
+
+ var ciphertextPath = GetCiphertextPath(path);
+ if (ciphertextPath is null)
+ return -ENOENT;
+
+ var handle = handlesManager.GetHandle(fi.fh);
+ if (handle is null || !handle.FileAccess.HasFlag(FileAccess.Write))
+ return -EBADF;
+
+ // Only the default mode (extend allocation and size) is supported
+ if (mode != 0)
+ return -ENOTSUP;
+
+ var newLength = (long)offset + length;
+ lock (handle.Stream)
+ {
+ if (newLength > handle.Stream.Length)
+ handle.Stream.SetLength(newLength);
+ }
+
+ return 0;
+ }
+
+ public override int Flush(ReadOnlySpan path, ref FuseFileInfo fi)
+ {
+ var handle = handlesManager.GetHandle(fi.fh);
+ if (handle is null)
+ return -EBADF;
+
+ // Flush is invoked for every close(2), including read-only descriptors
+ if (!handle.FileAccess.HasFlag(FileAccess.Write))
+ return 0;
+
+ lock (handle.Stream)
+ handle.Stream.Flush();
+
+ return 0;
+ }
+
+ public override int FSync(ReadOnlySpan path, bool onlyData, ref FuseFileInfo fi)
+ {
+ var handle = handlesManager.GetHandle(fi.fh);
+ if (handle is null)
+ return -EBADF;
+
+ // fsync(2) is valid on read-only descriptors - there is nothing to flush
+ if (!handle.FileAccess.HasFlag(FileAccess.Write))
+ return 0;
+
+ var ciphertextPath = GetCiphertextPath(path);
+ if (ciphertextPath is null)
+ return -ENOENT;
+
+ lock (handle.Stream)
+ handle.Stream.Flush();
+
+ if (onlyData)
+ return 0;
+
+ return FlushCiphertextToDisk(ciphertextPath);
+ }
+
+ public override int FSyncDir(ReadOnlySpan path, bool onlyData, ref FuseFileInfo fi)
+ {
+ var ciphertextPath = GetCiphertextPath(path);
+ if (ciphertextPath is null)
+ return -ENOENT;
+
+ // Flush writable handles of files located inside the directory being synced
+ foreach (var handle in handlesManager.OpenHandles)
+ {
+ if (handle is MacFuseFileHandle { Stream.CanWrite: true } macFuseFileHandle && macFuseFileHandle.Directory.StartsWith(ciphertextPath, StringComparison.Ordinal))
+ {
+ lock (macFuseFileHandle.Stream)
+ macFuseFileHandle.Stream.Flush();
+ }
+ }
+
+ return 0;
+ }
+
+ public override int GetAttr(ReadOnlySpan path, ref Stat stat, FuseFileInfoRef fiRef)
+ {
+ var ciphertextPath = GetCiphertextPath(path);
+ if (ciphertextPath is null)
+ return -ENOENT;
+
+ var isDirectory = Directory.Exists(ciphertextPath);
+ var isFile = !isDirectory && File.Exists(ciphertextPath);
+ if (!path.SequenceEqual(RootPath) && !isDirectory && !isFile)
+ return -ENOENT;
+
+ try
+ {
+ FileSystemInfo info = isFile ? new FileInfo(ciphertextPath) : new DirectoryInfo(ciphertextPath);
+
+ stat.st_mode = (ushort)((isFile ? S_IFREG : S_IFDIR) | (ushort)File.GetUnixFileMode(ciphertextPath));
+ stat.st_nlink = (ushort)(isFile ? 1 : 2);
+ stat.st_uid = getuid();
+ stat.st_gid = getgid();
+ stat.st_atimespec = TimeSpec.FromDateTime(info.LastAccessTimeUtc);
+ stat.st_mtimespec = TimeSpec.FromDateTime(info.LastWriteTimeUtc);
+ stat.st_ctimespec = TimeSpec.FromDateTime(info.LastWriteTimeUtc);
+ stat.st_birthtimespec = TimeSpec.FromDateTime(info.CreationTimeUtc);
+
+ // Prefer the open handle's stream length. It may include data that
+ // has not been flushed to the ciphertext file yet
+ if (!fiRef.IsNull && handlesManager.GetHandle(fiRef.Value.fh) is { } handle)
+ {
+ lock (handle.Stream)
+ stat.st_size = handle.Stream.Length;
+ }
+ else if (isFile)
+ {
+ var ciphertextSize = ((FileInfo)info).Length;
+ stat.st_size = Math.Max(0, specifics.Security.ContentCrypt.CalculatePlaintextSize(ciphertextSize - specifics.Security.HeaderCrypt.HeaderCiphertextSize));
+ }
+
+ stat.st_blksize = 4096;
+ stat.st_blocks = (stat.st_size + 511) / 512;
+
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ return -MapExceptionToErrno(ex);
+ }
+ }
+
+ public override unsafe int GetXAttr(ReadOnlySpan path, ReadOnlySpan name, Span value, uint position)
+ {
+ var ciphertextPath = GetCiphertextPath(path);
+ if (ciphertextPath is null)
+ return -ENOENT;
+
+ fixed (byte* namePtr = NullTerminate(name))
+ fixed (byte* ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath))
+ {
+ nint result;
+ if (value.Length == 0)
+ result = getxattr(ciphertextPathPtr, namePtr, null, 0, position, 0);
+ else
+ {
+ fixed (void* valuePtr = value)
+ result = getxattr(ciphertextPathPtr, namePtr, valuePtr, (nuint)value.Length, position, 0);
+ }
+
+ if (result == -1)
+ return -errno;
+
+ return (int)result;
+ }
+ }
+
+ public override unsafe int ListXAttr(ReadOnlySpan path, Span list)
+ {
+ var ciphertextPath = GetCiphertextPath(path);
+ if (ciphertextPath is null)
+ return -ENOENT;
+
+ fixed (byte* ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath))
+ {
+ nint result;
+ if (list.Length == 0)
+ result = listxattr(ciphertextPathPtr, null, 0, 0);
+ else
+ {
+ fixed (byte* listPtr = list)
+ result = listxattr(ciphertextPathPtr, listPtr, (nuint)list.Length, 0);
+ }
+
+ if (result == -1)
+ return -errno;
+
+ return (int)result;
+ }
+ }
+
+ public override int MkDir(ReadOnlySpan path, uint mode)
+ {
+ if (FuseOptions!.IsReadOnly)
+ return -EROFS;
+
+ var ciphertextPath = GetCiphertextPath(path);
+ if (ciphertextPath is null)
+ return -ENOENT;
+
+ if (Directory.Exists(ciphertextPath) || File.Exists(ciphertextPath))
+ return -EEXIST;
+
+ try
+ {
+ Directory.CreateDirectory(ciphertextPath, (UnixFileMode)(mode & 0xFFFu));
+ }
+ catch (Exception ex)
+ {
+ return -MapExceptionToErrno(ex);
+ }
+
+ // Create new DirectoryID
+ var directoryId = Guid.NewGuid().ToByteArray();
+ var directoryIdPath = Path.Combine(ciphertextPath, FileSystem.Constants.Names.DIRECTORY_ID_FILENAME);
+
+ // Initialize directory with DirectoryID
+ using var directoryIdStream = File.Create(directoryIdPath);
+ directoryIdStream.Write(directoryId);
+
+ // Set DirectoryID to known IDs
+ specifics.DirectoryIdCache.CacheSet(directoryIdPath, new(directoryId));
+
+ return 0;
+ }
+
+ public override int Open(ReadOnlySpan path, ref FuseFileInfo fi)
+ {
+ var ciphertextPath = GetCiphertextPath(path);
+ if (ciphertextPath is null)
+ return -ENOENT;
+
+ var mode = FileMode.Open;
+ if ((fi.flags & O_APPEND) != 0)
+ mode = FileMode.Append;
+ else if ((fi.flags & O_TRUNC) != 0)
+ mode = (fi.flags & O_CREAT) != 0 ? FileMode.Create : FileMode.Truncate;
+ else if ((fi.flags & O_CREAT) != 0)
+ mode = FileMode.OpenOrCreate; // O_CREAT without O_TRUNC must not truncate an existing file
+
+ var options = FileOptions.None;
+ if ((fi.flags & O_ASYNC) != 0)
+ options |= FileOptions.Asynchronous;
+
+ if ((fi.flags & O_SYNC) != 0)
+ options |= FileOptions.WriteThrough;
+
+ var wantsWrite = (fi.flags & (O_WRONLY | O_RDWR)) != 0;
+ if (FuseOptions!.IsReadOnly && (mode is FileMode.Create or FileMode.Truncate or FileMode.OpenOrCreate || wantsWrite))
+ return -EROFS;
+
+ // Read-only opens must not request write access. Otherwise, files with
+ // read-only permissions (or on read-only media) cannot be opened at all
+ var access = mode switch
+ {
+ FileMode.Append => FileAccess.Write,
+ FileMode.Create or FileMode.Truncate => FileAccess.ReadWrite,
+ _ => wantsWrite ? FileAccess.ReadWrite : FileAccess.Read
+ };
+
+ var handle = handlesManager.OpenFileHandle(ciphertextPath, mode, access, FileShare.ReadWrite, options);
+ if (handle == FileSystem.Constants.INVALID_HANDLE)
+ return -EACCES;
+
+ fi.fh = handle;
+ return 0;
+ }
+
+ public override int OpenDir(ReadOnlySpan path, ref FuseFileInfo fi)
+ {
+ var ciphertextPath = GetCiphertextPath(path);
+ if (ciphertextPath is null)
+ return -ENOENT;
+
+ if (File.Exists(ciphertextPath))
+ return -ENOTDIR;
+
+ if (!Directory.Exists(ciphertextPath))
+ return -ENOENT;
+
+ return 0;
+ }
+
+ public override int Read(ReadOnlySpan path, ulong offset, Span buffer, ref FuseFileInfo fi)
+ {
+ var handle = handlesManager.GetHandle(fi.fh);
+ if (handle is null)
+ return -EBADF;
+
+ // Lock on the handle's stream. The kernel can issue concurrent operations
+ // on the same handle, and setting the position and reading must be atomic
+ lock (handle.Stream)
+ {
+ if ((long)offset > handle.Stream.Length)
+ return 0;
+
+ handle.Stream.Position = (long)offset;
+ return handle.Stream.Read(buffer);
+ }
+ }
+
+ public override int ReadDir(ReadOnlySpan path, ulong offset, DirectoryContent content, ref FuseFileInfo fi)
+ {
+ var ciphertextPath = GetCiphertextPath(path);
+ if (ciphertextPath is null)
+ return -ENOENT;
+
+ content.AddEntry(".");
+ content.AddEntry("..");
+
+ var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security);
+ foreach (var entry in Directory.GetFileSystemEntries(ciphertextPath))
+ {
+ var ciphertextName = Path.GetFileName(entry);
+ if (PathHelpers.IsCoreName(ciphertextName))
+ continue;
+
+ // Skip entries whose names cannot be decrypted
+ var plaintextName = NativePathHelpers.DecryptName(ciphertextName, ciphertextPath, specifics, directoryId);
+ if (string.IsNullOrEmpty(plaintextName))
+ continue;
+
+ content.AddEntry(plaintextName);
+ }
+
+ return 0;
+ }
+
+ public override void Release(ReadOnlySpan path, ref FuseFileInfo fi)
+ {
+ handlesManager.CloseHandle(fi.fh);
+ }
+
+ public override int ReleaseDir(ReadOnlySpan path, ref FuseFileInfo fi)
+ {
+ return 0;
+ }
+
+ public override unsafe int RemoveXAttr(ReadOnlySpan path, ReadOnlySpan name)
+ {
+ if (FuseOptions!.IsReadOnly)
+ return -EROFS;
+
+ var ciphertextPath = GetCiphertextPath(path);
+ if (ciphertextPath is null)
+ return -ENOENT;
+
+ fixed (byte* namePtr = NullTerminate(name))
+ fixed (byte* ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath))
+ {
+ if (removexattr(ciphertextPathPtr, namePtr, 0) == -1)
+ return -errno;
+ }
+
+ return 0;
+ }
+
+ public override unsafe int Rename(ReadOnlySpan path, ReadOnlySpan newPath, uint flags)
+ {
+ if (FuseOptions!.IsReadOnly)
+ return -EROFS;
+
+ var ciphertextPath = GetCiphertextPath(path);
+ var newCiphertextPath = GetCiphertextPath(newPath);
+ if (ciphertextPath is null || newCiphertextPath is null)
+ return -ENOENT;
+
+ fixed (byte* ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath))
+ fixed (byte* newCiphertextPathPtr = Encoding.UTF8.GetBytes(newCiphertextPath))
+ {
+ var result = flags == 0u
+ ? rename(ciphertextPathPtr, newCiphertextPathPtr)
+ : renamex_np(ciphertextPathPtr, newCiphertextPathPtr, flags);
+
+ if (result == -1)
+ return -errno;
+ }
+
+ return 0;
+ }
+
+ public override int RmDir(ReadOnlySpan path)
+ {
+ if (FuseOptions!.IsReadOnly)
+ return -EROFS;
+
+ var ciphertextPath = GetCiphertextPath(path);
+ if (ciphertextPath is null)
+ return -ENOENT;
+
+ if (!Directory.Exists(ciphertextPath))
+ return -ENOENT;
+
+ // Protect core folders from deletion
+ if (PathHelpers.IsCoreName(Path.GetFileName(Path.TrimEndingDirectorySeparator(ciphertextPath))))
+ return -EACCES;
+
+ if (Directory.EnumerateFileSystemEntries(ciphertextPath).Any(static x => !PathHelpers.IsCoreName(Path.GetFileName(x))))
+ return -ENOTEMPTY;
+
+ var directoryIdPath = Path.Combine(ciphertextPath, FileSystem.Constants.Names.DIRECTORY_ID_FILENAME);
+ try
+ {
+ if (FuseOptions.IsRecycleBinEnabled())
+ NativeRecycleBinHelpers.DeleteOrRecycle(ciphertextPath, specifics, StorableType.Folder);
+ else
+ Directory.Delete(ciphertextPath, recursive: true); // Recursive because we want to delete the Directory ID file
+
+ // Delete and remove Directory ID
+ File.Delete(directoryIdPath);
+ specifics.DirectoryIdCache.CacheRemove(directoryIdPath);
+
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ return -MapExceptionToErrno(ex);
+ }
+ }
+
+ public override unsafe int SetXAttr(ReadOnlySpan path, ReadOnlySpan name, ReadOnlySpan value, int flags, uint position)
+ {
+ if (FuseOptions!.IsReadOnly)
+ return -EROFS;
+
+ var ciphertextPath = GetCiphertextPath(path);
+ if (ciphertextPath is null)
+ return -ENOENT;
+
+ // macFUSE forwards the kernel's internal VFS xattr flags (e.g. XATTR_NOSECURITY,
+ // XATTR_NODEFAULT), but the setxattr(2) syscall only accepts XATTR_CREATE/XATTR_REPLACE
+ // and rejects the rest with EINVAL. Re-issuing them verbatim on the backing file makes
+ // Finder's FinderInfo write fail with error -50 and can leave copied files empty.
+ var options = flags & (XATTR_CREATE | XATTR_REPLACE);
+
+ fixed (byte* namePtr = NullTerminate(name))
+ fixed (void* valuePtr = value)
+ fixed (byte* ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath))
+ {
+ if (setxattr(ciphertextPathPtr, namePtr, valuePtr, (nuint)value.Length, position, options) == -1)
+ return -errno;
+ }
+
+ return 0;
+ }
+
+ public override unsafe int StatFS(ReadOnlySpan path, ref StatVfs statfs)
+ {
+ var ciphertextPath = GetCiphertextPath(path);
+ if (ciphertextPath is null)
+ return -ENOENT;
+
+ fixed (StatVfs* statfsPtr = &statfs)
+ fixed (byte* ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath))
+ {
+ if (statvfs(ciphertextPathPtr, statfsPtr) == -1)
+ return -errno;
+ }
+
+ return 0;
+ }
+
+ public override int Truncate(ReadOnlySpan path, long length, FuseFileInfoRef fiRef)
+ {
+ if (FuseOptions!.IsReadOnly)
+ return -EROFS;
+
+ var ciphertextPath = GetCiphertextPath(path);
+ if (ciphertextPath is null)
+ return -ENOENT;
+
+ if (Directory.Exists(ciphertextPath))
+ return -EISDIR;
+
+ if (!File.Exists(ciphertextPath))
+ return -ENOENT;
+
+ MacFuseFileHandle? handle;
+ var temporaryHandleId = FileSystem.Constants.INVALID_HANDLE;
+ if (fiRef.IsNull)
+ {
+ temporaryHandleId = handlesManager.OpenFileHandle(ciphertextPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite, FileOptions.None);
+ if (temporaryHandleId == FileSystem.Constants.INVALID_HANDLE)
+ return -EIO;
+
+ handle = handlesManager.GetHandle(temporaryHandleId)!;
+ }
+ else
+ {
+ handle = handlesManager.GetHandle(fiRef.Value.fh);
+ if (handle is null || !handle.FileAccess.HasFlag(FileAccess.Write))
+ return -EBADF;
+ }
+
+ try
+ {
+ lock (handle.Stream)
+ {
+ var position = handle.Stream.Position;
+ handle.Stream.SetLength(length);
+ handle.Stream.Position = position;
+ }
+
+ return 0;
+ }
+ finally
+ {
+ // Close the temporary handle
+ if (temporaryHandleId != FileSystem.Constants.INVALID_HANDLE)
+ handlesManager.CloseHandle(temporaryHandleId);
+ }
+ }
+
+ ///
+ /// This method is also responsible for file deletion.
+ ///
+ public override int Unlink(ReadOnlySpan path)
+ {
+ if (FuseOptions!.IsReadOnly)
+ return -EROFS;
+
+ var ciphertextPath = GetCiphertextPath(path);
+ if (ciphertextPath is null || !File.Exists(ciphertextPath))
+ return -ENOENT;
+
+ if (Directory.Exists(ciphertextPath))
+ return -EISDIR;
+
+ // Protect core files from deletion
+ if (PathHelpers.IsCoreName(Path.GetFileName(ciphertextPath)))
+ return -EACCES;
+
+ try
+ {
+ if (FuseOptions.IsRecycleBinEnabled())
+ NativeRecycleBinHelpers.DeleteOrRecycle(ciphertextPath, specifics, StorableType.File);
+ else
+ File.Delete(ciphertextPath);
+
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ return -MapExceptionToErrno(ex);
+ }
+ }
+
+ public override unsafe int UpdateTimestamps(ReadOnlySpan path, ref TimeSpec atime, ref TimeSpec mtime, FuseFileInfoRef fiRef)
+ {
+ if (FuseOptions!.IsReadOnly)
+ return -EROFS;
+
+ var ciphertextPath = GetCiphertextPath(path);
+ if (ciphertextPath is null)
+ return -ENOENT;
+
+ if (!File.Exists(ciphertextPath) && !Directory.Exists(ciphertextPath))
+ return -ENOENT;
+
+ var times = stackalloc TimeSpec[2] { atime, mtime };
+ fixed (byte* ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath))
+ {
+ if (utimensat(AT_FDCWD, ciphertextPathPtr, times, 0) == -1)
+ return -errno;
+ }
+
+ return 0;
+ }
+
+ public override int Write(ReadOnlySpan path, ulong offset, ReadOnlySpan buffer, ref FuseFileInfo fi)
+ {
+ var handle = handlesManager.GetHandle(fi.fh);
+ if (handle is null || !handle.FileAccess.HasFlag(FileAccess.Write))
+ return -EBADF;
+
+ // Lock on the handle's stream. The kernel can issue concurrent operations
+ // on the same handle, and setting the position and writing must be atomic
+ lock (handle.Stream)
+ {
+ if (handle.FileMode == FileMode.Append)
+ offset = (ulong)handle.Stream.Length;
+
+ // No SetLength needed here as the plaintext stream extends itself and
+ // fills any gap with zeros when writing past the end of the file
+ handle.Stream.Position = (long)offset;
+ handle.Stream.Write(buffer);
+ }
+
+ return buffer.Length;
+ }
+
+ protected override unsafe string? GetCiphertextPath(ReadOnlySpan plaintextName)
+ {
+ fixed (byte* plaintextNamePtr = plaintextName)
+ {
+ var directoryId = new byte[FileSystem.Constants.DIRECTORY_ID_SIZE];
+ return NativePathHelpers.GetCiphertextPath(Encoding.UTF8.GetString(plaintextNamePtr, plaintextName.Length), specifics, directoryId);
+ }
+ }
+
+ ///
+ /// Opens the ciphertext file and forces buffered data to permanent storage (F_FULLFSYNC).
+ ///
+ private static int FlushCiphertextToDisk(string ciphertextPath)
+ {
+ try
+ {
+ using var fileStream = new FileStream(ciphertextPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete);
+ fileStream.Flush(flushToDisk: true);
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ return -MapExceptionToErrno(ex);
+ }
+ }
+
+ private static byte[] NullTerminate(ReadOnlySpan value)
+ {
+ var buffer = new byte[value.Length + 1];
+ value.CopyTo(buffer);
+
+ return buffer;
+ }
+
+ private static int MapExceptionToErrno(Exception exception)
+ {
+ return exception switch
+ {
+ FileNotFoundException or DirectoryNotFoundException => ENOENT,
+ UnauthorizedAccessException => EACCES,
+ PathTooLongException => ENAMETOOLONG,
+ IOException => EIO,
+ _ => EIO
+ };
+ }
+ }
+}
diff --git a/src/Core/SecureFolderFS.Core.MacFuse/Constants.cs b/src/Core/SecureFolderFS.Core.MacFuse/Constants.cs
new file mode 100644
index 000000000..3771b42ea
--- /dev/null
+++ b/src/Core/SecureFolderFS.Core.MacFuse/Constants.cs
@@ -0,0 +1,11 @@
+namespace SecureFolderFS.Core.MacFuse
+{
+ public static class Constants
+ {
+ public static class FileSystem
+ {
+ public const string FS_ID = "MAC_FUSE";
+ public const string FS_NAME = "macFUSE";
+ }
+ }
+}
diff --git a/src/Core/SecureFolderFS.Core.MacFuse/MacFuseFileSystem.Availability.cs b/src/Core/SecureFolderFS.Core.MacFuse/MacFuseFileSystem.Availability.cs
new file mode 100644
index 000000000..90ae673a7
--- /dev/null
+++ b/src/Core/SecureFolderFS.Core.MacFuse/MacFuseFileSystem.Availability.cs
@@ -0,0 +1,24 @@
+using FuseSharp;
+using SecureFolderFS.Storage.Enums;
+using SecureFolderFS.Storage.VirtualFileSystem;
+
+namespace SecureFolderFS.Core.MacFuse
+{
+ ///
+ public sealed partial class MacFuseFileSystem
+ {
+ ///
+ public partial async Task GetStatusAsync(CancellationToken cancellationToken)
+ {
+ await Task.CompletedTask;
+ try
+ {
+ return Fuse.CheckDependencies() ? FileSystemAvailability.Available : FileSystemAvailability.ModuleUnavailable;
+ }
+ catch (TypeInitializationException)
+ {
+ return FileSystemAvailability.ModuleUnavailable;
+ }
+ }
+ }
+}
diff --git a/src/Core/SecureFolderFS.Core.MacFuse/MacFuseFileSystem.Helpers.cs b/src/Core/SecureFolderFS.Core.MacFuse/MacFuseFileSystem.Helpers.cs
new file mode 100644
index 000000000..5d1bee06f
--- /dev/null
+++ b/src/Core/SecureFolderFS.Core.MacFuse/MacFuseFileSystem.Helpers.cs
@@ -0,0 +1,93 @@
+using System.Text;
+using FuseSharp;
+using FuseSharp.Native;
+using SecureFolderFS.Storage.VirtualFileSystem;
+
+namespace SecureFolderFS.Core.MacFuse
+{
+ ///
+ public sealed partial class MacFuseFileSystem
+ {
+ private static string MountDirectory { get; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), nameof(SecureFolderFS), "mount");
+
+ /// Whether a filesystem is mounted in the specified directory.
+ private static unsafe bool IsMountPoint(string directory)
+ {
+ var parent = Directory.GetParent(directory)?.FullName;
+ if (parent is null)
+ return false;
+
+ StatVfs directoryStat = default;
+ fixed (byte* pathPtr = Encoding.UTF8.GetBytes(directory))
+ {
+ if (LibC.statvfs(pathPtr, &directoryStat) == -1)
+ {
+ // A dead FUSE mount point cannot be stat-ed but is still a mount point
+ return LibC.errno is LibC.ENXIO or LibC.EIO;
+ }
+ }
+
+ StatVfs parentStat = default;
+ fixed (byte* parentPathPtr = Encoding.UTF8.GetBytes(parent))
+ {
+ if (LibC.statvfs(parentPathPtr, &parentStat) == -1)
+ return false;
+ }
+
+ return directoryStat.f_fsid != parentStat.f_fsid;
+ }
+
+ private static string GetFreeMountPoint(string vaultName)
+ {
+ var mountPoint = Path.Combine(MountDirectory, vaultName);
+
+ var i = 1;
+ while (IsMountPoint(mountPoint))
+ {
+ mountPoint = Path.Combine(MountDirectory, $"{vaultName} ({i++})");
+ }
+
+ return mountPoint;
+ }
+
+ ///
+ /// Removes mount points without a connected transport endpoint in the default mount directory.
+ ///
+ private static void Cleanup()
+ {
+ foreach (var directory in Directory.GetDirectories(MountDirectory))
+ {
+ Cleanup(directory);
+ }
+ }
+
+ ///
+ /// Unmounts and deletes the specified directory if it is a mount point without a connected transport endpoint.
+ /// Otherwise, nothing happens.
+ ///
+ private static void Cleanup(string directory)
+ {
+ try
+ {
+ Directory.EnumerateFileSystemEntries(directory);
+ }
+ catch (IOException)
+ {
+ Fuse.LazyUnmount(directory);
+ Directory.Delete(directory);
+ }
+ }
+
+ /// Whether the allow_other macFUSE tunable is enabled.
+ private static unsafe bool CanAllowOtherUsers()
+ {
+ var value = 0;
+ var length = (nuint)sizeof(int);
+
+ if (LibC.sysctlbyname("vfs.generic.macfuse.tunables.allow_other", &value, &length, null, 0) == -1)
+ return false;
+
+ return value != 0;
+ }
+ }
+}
diff --git a/src/Core/SecureFolderFS.Core.MacFuse/MacFuseFileSystem.cs b/src/Core/SecureFolderFS.Core.MacFuse/MacFuseFileSystem.cs
new file mode 100644
index 000000000..ff00f03ae
--- /dev/null
+++ b/src/Core/SecureFolderFS.Core.MacFuse/MacFuseFileSystem.cs
@@ -0,0 +1,77 @@
+using OwlCore.Storage;
+using SecureFolderFS.Core.Cryptography;
+using SecureFolderFS.Core.FileSystem;
+using SecureFolderFS.Core.FileSystem.Extensions;
+using SecureFolderFS.Core.FileSystem.Storage;
+using SecureFolderFS.Core.MacFuse.AppModels;
+using SecureFolderFS.Core.MacFuse.Callbacks;
+using SecureFolderFS.Core.MacFuse.OpenHandles;
+using SecureFolderFS.Shared.ComponentModel;
+using SecureFolderFS.Storage.Enums;
+using SecureFolderFS.Storage.SystemStorageEx;
+using SecureFolderFS.Storage.VirtualFileSystem;
+
+namespace SecureFolderFS.Core.MacFuse
+{
+ ///
+ public sealed partial class MacFuseFileSystem : IFileSystemInfo
+ {
+ ///
+ public string Id { get; } = Constants.FileSystem.FS_ID;
+
+ ///
+ public string Name { get; } = Constants.FileSystem.FS_NAME;
+
+ ///
+ public partial Task GetStatusAsync(CancellationToken cancellationToken = default);
+
+ ///
+ public Task GetVolumeNameAsync(string candidateName, CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(candidateName);
+ }
+
+ ///
+ public async Task MountAsync(IFolder folder, IDisposable unlockContract, IDictionary options, CancellationToken cancellationToken = default)
+ {
+ await Task.CompletedTask;
+ if (unlockContract is not IWrapper wrapper)
+ throw new ArgumentException($"The {nameof(unlockContract)} is invalid.");
+
+ var fuseOptions = MacFuseOptions.ToOptions(options.AppendContract(unlockContract));
+ if (!Directory.Exists(MountDirectory))
+ Directory.CreateDirectory(MountDirectory);
+
+ if (fuseOptions is { AllowOtherUserAccess: true, AllowRootUserAccess: true })
+ throw new ArgumentException($"{nameof(MacFuseOptions)}.{nameof(fuseOptions.AllowOtherUserAccess)} and {nameof(MacFuseOptions)}.{nameof(fuseOptions.AllowRootUserAccess)} are mutually exclusive.");
+
+ if ((fuseOptions.AllowOtherUserAccess || fuseOptions.AllowRootUserAccess) && !CanAllowOtherUsers())
+ throw new ArgumentException($"{nameof(fuseOptions.AllowOtherUserAccess)} has been specified but the allow_other macFUSE tunable is not enabled.");
+
+ var mountPoint = fuseOptions.MountPoint;
+ if (mountPoint == null)
+ Cleanup();
+ else
+ Cleanup(mountPoint);
+
+ if (mountPoint != null && IsMountPoint(mountPoint))
+ throw new ArgumentException("A filesystem is already mounted in the specified path.");
+
+ mountPoint ??= GetFreeMountPoint(fuseOptions.VolumeName);
+ if (!Directory.Exists(mountPoint))
+ Directory.CreateDirectory(mountPoint);
+
+ var specifics = FileSystemSpecifics.CreateNew(wrapper.Inner, folder, fuseOptions);
+ fuseOptions.SetupValidators(specifics);
+
+ var handlesManager = new MacFuseHandlesManager(specifics.StreamsAccess, specifics.Options);
+ var fuseCallbacks = new OnDeviceMacFuse(specifics, handlesManager);
+ var fuseWrapper = new MacFuseWrapper(fuseCallbacks);
+ fuseWrapper.StartFileSystem(mountPoint, fuseOptions);
+
+ var virtualizedRoot = new SystemFolderEx(mountPoint);
+ var plaintextRoot = new CryptoFolder(Path.DirectorySeparatorChar.ToString(), specifics.ContentFolder, specifics);
+ return new MacFuseVfsRoot(fuseWrapper, virtualizedRoot, plaintextRoot, specifics);
+ }
+ }
+}
diff --git a/src/Core/SecureFolderFS.Core.MacFuse/MacFuseVfsRoot.cs b/src/Core/SecureFolderFS.Core.MacFuse/MacFuseVfsRoot.cs
new file mode 100644
index 000000000..79d05dafd
--- /dev/null
+++ b/src/Core/SecureFolderFS.Core.MacFuse/MacFuseVfsRoot.cs
@@ -0,0 +1,49 @@
+using OwlCore.Storage;
+using SecureFolderFS.Core.FileSystem;
+using SecureFolderFS.Storage.VirtualFileSystem;
+
+namespace SecureFolderFS.Core.MacFuse
+{
+ ///
+ internal sealed class MacFuseVfsRoot : VfsRoot
+ {
+ private readonly MacFuseWrapper _fuseWrapper;
+ private bool _disposed;
+
+ ///
+ public override string FileSystemName { get; } = Constants.FileSystem.FS_NAME;
+
+ public MacFuseVfsRoot(MacFuseWrapper fuseWrapper, IFolder virtualizedRoot, IFolder plaintextRoot, FileSystemSpecifics specifics)
+ : base(virtualizedRoot, plaintextRoot, specifics)
+ {
+ _fuseWrapper = fuseWrapper;
+ }
+
+ ///
+ public override async ValueTask DisposeAsync()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = await _fuseWrapper.CloseFileSystemAsync();
+ if (_disposed)
+ {
+ FileSystemManager.Instance.FileSystems.Remove(this);
+ await base.DisposeAsync();
+
+ try
+ {
+ // Remove the now-empty mount point directory. Delete is non-recursive,
+ // so a directory which is unexpectedly not empty is left untouched
+ Directory.Delete(VirtualizedRoot.Id, recursive: false);
+ }
+ catch (IOException)
+ {
+ }
+ catch (UnauthorizedAccessException)
+ {
+ }
+ }
+ }
+ }
+}
diff --git a/src/Core/SecureFolderFS.Core.MacFuse/MacFuseWrapper.cs b/src/Core/SecureFolderFS.Core.MacFuse/MacFuseWrapper.cs
new file mode 100644
index 000000000..17cb4b46e
--- /dev/null
+++ b/src/Core/SecureFolderFS.Core.MacFuse/MacFuseWrapper.cs
@@ -0,0 +1,57 @@
+using FuseSharp;
+using SecureFolderFS.Core.MacFuse.AppModels;
+using SecureFolderFS.Core.MacFuse.Callbacks;
+
+namespace SecureFolderFS.Core.MacFuse
+{
+ internal sealed class MacFuseWrapper
+ {
+ private readonly BaseMacFuseCallbacks _fuseCallbacks;
+ private IFuseMount? _fuseMount;
+
+ public MacFuseWrapper(BaseMacFuseCallbacks fuseCallbacks)
+ {
+ _fuseCallbacks = fuseCallbacks;
+ }
+
+ public void StartFileSystem(string mountPoint, MacFuseOptions mountOptions)
+ {
+ var rawOptions = $"default_permissions,fsname={nameof(SecureFolderFS)},volname={SanitizeOptionValue(mountOptions.VolumeName)}";
+
+ // Extended attributes are forwarded to the ciphertext folder,
+ // so AppleDouble (._) companion files are not needed
+ rawOptions += ",noappledouble";
+
+ if (mountOptions.AllowOtherUserAccess)
+ rawOptions += ",allow_other";
+ else if (mountOptions.AllowRootUserAccess)
+ rawOptions += ",allow_root";
+
+ if (mountOptions.IsReadOnly)
+ rawOptions += ",ro";
+
+ if (mountOptions.PrintDebugInformation)
+ rawOptions += ",debug";
+
+ _fuseCallbacks.FuseOptions = mountOptions;
+ _fuseMount = Fuse.Mount(mountPoint, _fuseCallbacks, new MountOptions()
+ {
+ Options = rawOptions
+ });
+ }
+
+ public Task CloseFileSystemAsync()
+ {
+ if (_fuseMount is null)
+ throw new InvalidOperationException("The filesystem has not been started.");
+
+ return _fuseMount.UnmountAsync(force: true);
+ }
+
+ private static string SanitizeOptionValue(string value)
+ {
+ // Commas separate mount options and cannot appear inside a value
+ return value.Replace(',', '_');
+ }
+ }
+}
diff --git a/src/Core/SecureFolderFS.Core.MacFuse/OpenHandles/MacFuseFileHandle.cs b/src/Core/SecureFolderFS.Core.MacFuse/OpenHandles/MacFuseFileHandle.cs
new file mode 100644
index 000000000..c26d6fae9
--- /dev/null
+++ b/src/Core/SecureFolderFS.Core.MacFuse/OpenHandles/MacFuseFileHandle.cs
@@ -0,0 +1,22 @@
+using SecureFolderFS.Core.FileSystem.OpenHandles;
+
+namespace SecureFolderFS.Core.MacFuse.OpenHandles
+{
+ ///
+ internal sealed class MacFuseFileHandle : FileHandle
+ {
+ public FileAccess FileAccess { get; }
+
+ public FileMode FileMode { get; }
+
+ public string Directory { get; }
+
+ public MacFuseFileHandle(Stream stream, FileAccess fileAccess, FileMode fileMode, string directory)
+ : base(stream)
+ {
+ FileAccess = fileAccess;
+ FileMode = fileMode;
+ Directory = directory;
+ }
+ }
+}
diff --git a/src/Core/SecureFolderFS.Core.MacFuse/OpenHandles/MacFuseHandlesManager.cs b/src/Core/SecureFolderFS.Core.MacFuse/OpenHandles/MacFuseHandlesManager.cs
new file mode 100644
index 000000000..63750fd2b
--- /dev/null
+++ b/src/Core/SecureFolderFS.Core.MacFuse/OpenHandles/MacFuseHandlesManager.cs
@@ -0,0 +1,101 @@
+using SecureFolderFS.Core.FileSystem.Extensions;
+using SecureFolderFS.Core.FileSystem.OpenHandles;
+using SecureFolderFS.Core.FileSystem.Streams;
+using SecureFolderFS.Storage.VirtualFileSystem;
+
+namespace SecureFolderFS.Core.MacFuse.OpenHandles
+{
+ ///
+ internal sealed class MacFuseHandlesManager : BaseHandlesManager
+ {
+ public MacFuseHandlesManager(StreamsAccess streamsAccess, VirtualFileSystemOptions fileSystemOptions)
+ : base(streamsAccess, fileSystemOptions)
+ {
+ }
+
+ ///
+ /// Provides an enumerable collection of all currently active handles managed by the instance.
+ ///
+ ///
+ /// The handles are returned as a snapshot of the internal collection to prevent race conditions
+ /// when accessing or enumerating the data. Any modifications to the collection are thread-safe
+ /// and do not affect the snapshot returned by this property.
+ ///
+ public IEnumerable OpenHandles
+ {
+ get
+ {
+ // Return a snapshot - the live collection could be mutated
+ // by another thread while the caller is enumerating it
+ lock (handles)
+ return handles.Values.ToArray();
+ }
+ }
+
+ ///
+ public override ulong OpenFileHandle(string ciphertextPath, FileMode mode, FileAccess access, FileShare share, FileOptions options)
+ {
+ // Make sure the handles manager was not disposed
+ if (disposed)
+ return FileSystem.Constants.INVALID_HANDLE;
+
+ if (fileSystemOptions.IsReadOnly && mode.IsWriteFlag())
+ return FileSystem.Constants.INVALID_HANDLE;
+
+ // Open ciphertext stream
+ var ciphertextStream = new FileStream(ciphertextPath, new FileStreamOptions()
+ {
+ // A file cannot be opened with both FileMode.Append and FileMode.Read, but opening it with
+ // FileMode.Write would cause an error when writing, as the stream needs to be readable.
+ Mode = mode == FileMode.Append ? FileMode.Open : mode,
+
+ // Writes require read access as well (chunks are read-modify-write), but read-only
+ // opens must not request write access. Otherwise, files with read-only permissions
+ // (or vaults on read-only media) could not be opened at all
+ Access = access == FileAccess.Read ? FileAccess.Read : FileAccess.ReadWrite,
+ Share = share | FileShare.Delete,
+ Options = options
+ });
+
+ // Open plaintext stream on top of ciphertext stream
+ var plaintextStream = streamsAccess.TryOpenPlaintextStream(ciphertextPath, ciphertextStream);
+ if (plaintextStream is null)
+ return FileSystem.Constants.INVALID_HANDLE;
+
+ // Flush ChunkAccess if the opened to Truncate
+ if (mode == FileMode.Truncate)
+ plaintextStream.Flush();
+
+ // Create handle
+ var fileHandle = new MacFuseFileHandle(plaintextStream, access, mode, Path.GetDirectoryName(ciphertextPath)!);
+ var handle = handlesGenerator.ThreadSafeIncrement();
+
+ lock (handles)
+ handles.TryAdd(handle, fileHandle);
+
+ return handle;
+ }
+
+ ///
+ public override ulong OpenDirectoryHandle(string ciphertextPath)
+ {
+ // Not supported, return invalid handle
+ return FileSystem.Constants.INVALID_HANDLE;
+ }
+
+ ///
+ public override THandle? GetHandle(ulong handleId)
+ where THandle : class
+ {
+ lock (handles)
+ return base.GetHandle(handleId);
+ }
+
+ ///
+ public override void CloseHandle(ulong handle)
+ {
+ lock (handles)
+ base.CloseHandle(handle);
+ }
+ }
+}
diff --git a/src/Core/SecureFolderFS.Core.MacFuse/SecureFolderFS.Core.MacFuse.csproj b/src/Core/SecureFolderFS.Core.MacFuse/SecureFolderFS.Core.MacFuse.csproj
new file mode 100644
index 000000000..5060ca03b
--- /dev/null
+++ b/src/Core/SecureFolderFS.Core.MacFuse/SecureFolderFS.Core.MacFuse.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+ <_Parameter1>macos
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Platforms/SecureFolderFS.Cli/CliVaultFileSystemService.cs b/src/Platforms/SecureFolderFS.Cli/CliVaultFileSystemService.cs
index c6c017695..be42a7f41 100644
--- a/src/Platforms/SecureFolderFS.Cli/CliVaultFileSystemService.cs
+++ b/src/Platforms/SecureFolderFS.Cli/CliVaultFileSystemService.cs
@@ -20,7 +20,10 @@ public override async IAsyncEnumerable GetFileSystemsAsync([Enu
// Keep ordering aligned with desktop targets: WebDAV first, then native adapters.
yield return new CliWebDavFileSystem();
- yield return new FuseFileSystem();
+ if (System.OperatingSystem.IsMacOS())
+ yield return new SecureFolderFS.Core.MacFuse.MacFuseFileSystem();
+ else
+ yield return new FuseFileSystem();
#if SFFS_WINDOWS_FS
yield return new SecureFolderFS.Core.WinFsp.WinFspFileSystem();
diff --git a/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj b/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj
index ff9ced8fa..59f753706 100644
--- a/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj
+++ b/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj
@@ -13,6 +13,7 @@
+
diff --git a/src/Platforms/SecureFolderFS.UI/Assets/AppAssets/FileSystems/mac_fuse.png b/src/Platforms/SecureFolderFS.UI/Assets/AppAssets/FileSystems/mac_fuse.png
new file mode 100644
index 000000000..8cd6d8044
Binary files /dev/null and b/src/Platforms/SecureFolderFS.UI/Assets/AppAssets/FileSystems/mac_fuse.png differ
diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultFileSystemService.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultFileSystemService.cs
index 35ed9d036..c7e41d26f 100644
--- a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultFileSystemService.cs
+++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultFileSystemService.cs
@@ -13,7 +13,9 @@
using SecureFolderFS.UI.ServiceImplementation;
using static SecureFolderFS.Sdk.Constants.DataSources;
-#if !__UNO_SKIA_MACOS__
+#if __UNO_SKIA_MACOS__
+using SecureFolderFS.Core.MacFuse;
+#else
using System;
using SecureFolderFS.Uno.Platforms.Desktop.ViewModels;
#endif
@@ -29,7 +31,9 @@ public override async IAsyncEnumerable GetFileSystemsAsync([Enu
await Task.CompletedTask;
yield return new SkiaWebDavFileSystem();
-#if !__UNO_SKIA_MACOS__
+#if __UNO_SKIA_MACOS__
+ yield return new MacFuseFileSystem();
+#else
// Inside a Flatpak sandbox the FUSE userspace may appear available, but mounts
// are confined to the sandbox's mount namespace and invisible to the host
if (!FuseInstallationViewModel.IsSandboxed)
diff --git a/src/Platforms/SecureFolderFS.Uno/SecureFolderFS.Uno.csproj b/src/Platforms/SecureFolderFS.Uno/SecureFolderFS.Uno.csproj
index a77f984d0..e2cf67010 100644
--- a/src/Platforms/SecureFolderFS.Uno/SecureFolderFS.Uno.csproj
+++ b/src/Platforms/SecureFolderFS.Uno/SecureFolderFS.Uno.csproj
@@ -86,6 +86,7 @@
+
diff --git a/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoFileExplorerService.cs b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoFileExplorerService.cs
index a5f8d99e0..884c98d84 100644
--- a/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoFileExplorerService.cs
+++ b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoFileExplorerService.cs
@@ -85,7 +85,10 @@ public async Task TryOpenInFileExplorerAsync(IFolder folder, CancellationT
}
#if __MACOS__ || __MACCATALYST__ || __UNO_SKIA_MACOS__
- Process.Start("sh", ["-c", $"open {folder.Id}"]);
+ // Pass the path as a discrete argument instead of interpolating it into a shell
+ // command. The FUSE mount point lives under "Application Support", whose space
+ // would otherwise be split by the shell and cause 'open' to fail
+ Process.Start("/usr/bin/open", [folder.Id]);
return true;
#elif WINDOWS
await global::Windows.System.Launcher.LaunchFolderPathAsync(folder.Id).AsTask(cancellationToken);
diff --git a/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoMediaService.cs b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoMediaService.cs
index a4eb5201b..e1db07dcd 100644
--- a/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoMediaService.cs
+++ b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoMediaService.cs
@@ -57,10 +57,12 @@ public Task GetImageFromResourceAsync(string resourceName, CancellationT
"DOKANY" => new ImageResource("ms-appx:///Assets/AppAssets/FileSystems/dokany.png"),
"WINFSP" => new ImageResource("ms-appx:///Assets/AppAssets/FileSystems/winfsp.png"),
"WEBDAV" => new ImageResource("ms-appx:///Assets/AppAssets/FileSystems/webdav.png"),
+ "MAC_FUSE" => new ImageResource("ms-appx:///Assets/AppAssets/FileSystems/mac_fuse.png"),
#else
"DOKANY" => new ImageResource("ms-appx:///Assets/AppAssets/FileSystems/dokany.png"),
"WINFSP" => new ImageResource("ms-appx:///Assets/AppAssets/FileSystems/winfsp.png"),
"WEBDAV" => new ImageResource("ms-appx:///Assets/AppAssets/FileSystems/webdav.png"),
+ "MAC_FUSE" => new ImageResource("ms-appx:///Assets/AppAssets/FileSystems/mac_fuse.png"),
#endif
_ => new ImageResource(resourceName, true)
diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml.cs
index 48e9a15cd..1c7004af0 100644
--- a/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Uno/UserControls/InterfaceRoot/MainWindowRootControl.xaml.cs
@@ -14,13 +14,12 @@
using SecureFolderFS.Sdk.Extensions;
using SecureFolderFS.Sdk.Messages;
using SecureFolderFS.Sdk.Services;
-using SecureFolderFS.Sdk.ViewModels.Views.Root;
using SecureFolderFS.Sdk.ViewModels.Views.Host;
using SecureFolderFS.Sdk.ViewModels.Views.Overlays;
+using SecureFolderFS.Sdk.ViewModels.Views.Root;
using SecureFolderFS.Sdk.ViewModels.Views.Vault;
using SecureFolderFS.Shared;
using SecureFolderFS.Shared.Extensions;
-using SecureFolderFS.Shared.Models;
using SecureFolderFS.UI.Helpers;
using SecureFolderFS.Uno.Helpers;
using Uno.UI;
@@ -84,10 +83,12 @@ private async Task EnsureRootAsync()
if (!settingsService.AppSettings.WasIntroduced)
{
var overlayService = DI.Service();
- await overlayService.ShowAsync(new IntroductionOverlayViewModel().WithInitAsync());
-
- settingsService.AppSettings.WasIntroduced = true;
- await settingsService.AppSettings.SaveAsync();
+ var result = await overlayService.ShowAsync(new IntroductionOverlayViewModel().WithInitAsync());
+ if (result.Positive())
+ {
+ settingsService.AppSettings.WasIntroduced = true;
+ await settingsService.AppSettings.SaveAsync();
+ }
}
if (!ViewModel.VaultCollectionModel.IsEmpty()) // Has vaults
diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/IntroductionOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/IntroductionOverlayViewModel.cs
index de98990dc..a1c397574 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/IntroductionOverlayViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/IntroductionOverlayViewModel.cs
@@ -109,6 +109,7 @@ private async Task OpenSettingsAsync()
partial void OnCurrentIndexChanged(int value)
{
+ _ = value;
CurrentStep = $"{CurrentIndex+1}/{SlidesCount}";
}
}
diff --git a/tests/SecureFolderFS.Tests/FileSystemTests/MacFuseTests.cs b/tests/SecureFolderFS.Tests/FileSystemTests/MacFuseTests.cs
new file mode 100644
index 000000000..a74d095fb
--- /dev/null
+++ b/tests/SecureFolderFS.Tests/FileSystemTests/MacFuseTests.cs
@@ -0,0 +1,191 @@
+using FluentAssertions;
+using NUnit.Framework;
+using SecureFolderFS.Core.MacFuse;
+using SecureFolderFS.Storage.SystemStorageEx;
+using SecureFolderFS.Storage.VirtualFileSystem;
+using SecureFolderFS.Tests.Models;
+
+namespace SecureFolderFS.Tests.FileSystemTests
+{
+ ///
+ /// End-to-end tests which mount a real vault through macFUSE and exercise it with regular file APIs.
+ ///
+ [TestFixture]
+ [Platform("MacOsX")]
+ public class MacFuseTests : BaseFileSystemTests
+ {
+ private IVfsRoot? _storageRoot;
+ private string? _vaultPath;
+
+ [SetUp]
+ public async Task Initialize()
+ {
+ if (OperatingSystem.IsMacOS() && FuseSharp.Fuse.CheckDependencies())
+ {
+ // The FUSE file system operates on raw file system paths,
+ // so the vault must reside on disk instead of in memory
+ _vaultPath = Directory.CreateTempSubdirectory("MacFuseTests_").FullName;
+ _storageRoot = await MountVault(new MacFuseFileSystem(), new MockVaultOptions()
+ {
+ VaultFolder = new SystemFolderEx(_vaultPath)
+ });
+ }
+ else
+ Assert.Ignore("macFUSE is not installed.");
+
+ // The mount point must be a real mounted volume. Without this the tests would
+ // still pass by reading and writing the raw (unmounted) mount point directory
+ var mountPoint = _storageRoot.VirtualizedRoot.Id;
+ DriveInfo.GetDrives().Should().Contain(x => x.RootDirectory.FullName.TrimEnd('/') == mountPoint.TrimEnd('/'));
+ }
+
+ [TearDown]
+ public async Task Cleanup()
+ {
+ if (_storageRoot is null)
+ return;
+
+ var mountPoint = _storageRoot.VirtualizedRoot.Id;
+ await _storageRoot.DisposeAsync();
+
+ // After a clean unmount the empty mount point directory is removed. Files written
+ // past the file system (plaintext leakage) would leave the directory behind
+ DriveInfo.GetDrives().Should().NotContain(x => x.RootDirectory.FullName.TrimEnd('/') == mountPoint.TrimEnd('/'));
+ Directory.Exists(mountPoint).Should().BeFalse();
+
+ if (_vaultPath is not null)
+ Directory.Delete(_vaultPath, recursive: true);
+ }
+
+ [Test]
+ public async Task Mount_WriteFile_ReadBack_ThroughKernel()
+ {
+ ArgumentNullException.ThrowIfNull(_storageRoot);
+ var mountPoint = _storageRoot.VirtualizedRoot.Id;
+
+ Directory.Exists(mountPoint).Should().BeTrue();
+
+ var filePath = Path.Combine(mountPoint, "hello.txt");
+ await File.WriteAllTextAsync(filePath, "Hello from macFUSE!");
+
+ var contents = await File.ReadAllTextAsync(filePath);
+ contents.Should().Be("Hello from macFUSE!");
+ }
+
+ [Test]
+ public async Task Mount_CreateDirectory_Enumerate_Rename_Delete()
+ {
+ ArgumentNullException.ThrowIfNull(_storageRoot);
+ var mountPoint = _storageRoot.VirtualizedRoot.Id;
+
+ var directoryPath = Path.Combine(mountPoint, "folder");
+ Directory.CreateDirectory(directoryPath);
+
+ var filePath = Path.Combine(directoryPath, "data.bin");
+ var payload = Enumerable.Range(0, 1024 * 128).Select(static i => (byte)i).ToArray();
+ await File.WriteAllBytesAsync(filePath, payload);
+
+ (await File.ReadAllBytesAsync(filePath)).Should().Equal(payload);
+ Directory.EnumerateFiles(directoryPath).Select(Path.GetFileName).Should().ContainSingle(static x => x == "data.bin");
+
+ var renamedPath = Path.Combine(directoryPath, "renamed.bin");
+ File.Move(filePath, renamedPath);
+ File.Exists(renamedPath).Should().BeTrue();
+ File.Exists(filePath).Should().BeFalse();
+
+ File.Delete(renamedPath);
+ Directory.Delete(directoryPath);
+ Directory.Exists(directoryPath).Should().BeFalse();
+ }
+
+ [Test]
+ public async Task Mount_FinderCopy_PreservesFinderInfoAndContent()
+ {
+ // Regression for the Finder "error -50 / zero-byte copy" bug. macFUSE's kernel extension
+ // tags the com.apple.FinderInfo setxattr of a Finder drag-copy with internal VFS flags
+ // (XATTR_NOSECURITY/XATTR_NODEFAULT). The setxattr(2) syscall rejects those with EINVAL, so
+ // re-issuing them verbatim on the backing file made Finder report -50 and (depending on
+ // copyfile's ordering) leave the copied file empty. Only a real Finder copy reproduces the
+ // exact flags, so this drives Finder via AppleScript.
+ ArgumentNullException.ThrowIfNull(_storageRoot);
+ var mountPoint = _storageRoot.VirtualizedRoot.Id;
+
+ var payload = Enumerable.Range(0, 100_000).Select(static i => (byte)(i % 251)).ToArray();
+ var src = Path.Combine(Path.GetTempPath(), $"macfuse-finder-{Guid.NewGuid():N}.bin");
+ await File.WriteAllBytesAsync(src, payload);
+
+ // Give the source a non-zero FinderInfo so Finder copies a meaningful one (an all-zero
+ // FinderInfo is treated as absent by macOS and would not be persisted)
+ var finderInfo = Enumerable.Range(1, 32).Select(static i => (byte)i).ToArray();
+ SetXAttr(src, "com.apple.FinderInfo", finderInfo);
+
+ try
+ {
+ var script = $"tell application \"Finder\" to duplicate (POSIX file \"{src}\") to (POSIX file \"{mountPoint}\")";
+ var p = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(
+ "/usr/bin/osascript", ["-e", script]) { RedirectStandardError = true, RedirectStandardOutput = true })!;
+ await p.WaitForExitAsync();
+ var stderr = (await p.StandardError.ReadToEndAsync()).Trim();
+
+ var dest = Path.Combine(mountPoint, Path.GetFileName(src));
+
+ // Only skip when Finder automation is genuinely blocked (TCC). A copy error such as
+ // "-50" is the regression itself and must fail the test, not skip it.
+ var automationBlocked = stderr.Contains("-1743") || stderr.Contains("Not authorized")
+ || stderr.Contains("assistive access") || stderr.Contains("isn't running");
+ if (automationBlocked)
+ Assert.Ignore($"Finder automation unavailable: '{stderr}'.");
+
+ p.ExitCode.Should().Be(0, $"Finder copy into the vault must succeed, but failed: '{stderr}'");
+ File.Exists(dest).Should().BeTrue("the Finder-copied file must exist");
+
+ // The data fork must be fully copied, not left empty
+ new FileInfo(dest).Length.Should().Be(payload.Length, "the Finder-copied file must not be left empty");
+ (await File.ReadAllBytesAsync(dest)).Should().Equal(payload);
+
+ // Finder writes com.apple.FinderInfo during the copy - the setxattr that failed with
+ // EINVAL (Finder error -50) before the flag mask was applied
+ var destFinderInfo = new byte[32];
+ GetXAttr(dest, "com.apple.FinderInfo", destFinderInfo)
+ .Should().Be(32, "Finder must be able to write FinderInfo on the copied file");
+ destFinderInfo.Should().Equal(finderInfo);
+
+ File.Delete(dest);
+ }
+ finally
+ {
+ File.Delete(src);
+ }
+ }
+
+ [System.Runtime.InteropServices.DllImport("libSystem.dylib", EntryPoint = "getxattr", CharSet = System.Runtime.InteropServices.CharSet.Ansi, SetLastError = true)]
+ private static extern nint NativeGetXAttr(string path, string name, byte[] value, nuint size, uint position, int options);
+
+ [System.Runtime.InteropServices.DllImport("libSystem.dylib", EntryPoint = "setxattr", CharSet = System.Runtime.InteropServices.CharSet.Ansi, SetLastError = true)]
+ private static extern int NativeSetXAttr(string path, string name, byte[] value, nuint size, uint position, int options);
+
+ private static int GetXAttr(string path, string name, byte[] value) => (int)NativeGetXAttr(path, name, value, (nuint)value.Length, 0, 0);
+
+ private static int SetXAttr(string path, string name, byte[] value) => NativeSetXAttr(path, name, value, (nuint)value.Length, 0, 0);
+
+ [Test]
+ public async Task Mount_Truncate_And_Append()
+ {
+ ArgumentNullException.ThrowIfNull(_storageRoot);
+ var mountPoint = _storageRoot.VirtualizedRoot.Id;
+
+ var filePath = Path.Combine(mountPoint, "resize.txt");
+ await File.WriteAllTextAsync(filePath, "0123456789");
+
+ using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.ReadWrite))
+ stream.SetLength(5);
+
+ (await File.ReadAllTextAsync(filePath)).Should().Be("01234");
+
+ await File.AppendAllTextAsync(filePath, "ABC");
+ (await File.ReadAllTextAsync(filePath)).Should().Be("01234ABC");
+
+ File.Delete(filePath);
+ }
+ }
+}