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