diff --git a/lib/nwebdav b/lib/nwebdav index e1df3f4a1..1577da3af 160000 --- a/lib/nwebdav +++ b/lib/nwebdav @@ -1 +1 @@ -Subproject commit e1df3f4a1c80aef525814c0c193ed56251224be2 +Subproject commit 1577da3af639e1b00fef2f1276ed2feec5e522f4 diff --git a/src/Core/SecureFolderFS.Core.Dokany/AppModels/DokanyOptions.cs b/src/Core/SecureFolderFS.Core.Dokany/AppModels/DokanyOptions.cs index c660e54b9..07db21040 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/AppModels/DokanyOptions.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/AppModels/DokanyOptions.cs @@ -33,7 +33,7 @@ public static DokanyOptions ToOptions(IDictionary options) 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)) ?? true, + IsCachingFileNames = (bool?)options.Get(nameof(IsCachingFileNames)) ?? false, IsCachingDirectoryIds = (bool?)options.Get(nameof(IsCachingDirectoryIds)) ?? true, RecycleBinSize = (long?)options.Get(nameof(RecycleBinSize)) ?? 0L, diff --git a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs index 98fbeef5f..9a3283132 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/BaseDokanyCallbacks.cs @@ -1,10 +1,4 @@ -using DokanNet; -using SecureFolderFS.Core.Dokany.Helpers; -using SecureFolderFS.Core.FileSystem; -using SecureFolderFS.Core.FileSystem.AppModels; -using SecureFolderFS.Core.FileSystem.Exceptions; -using SecureFolderFS.Core.FileSystem.OpenHandles; -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -12,6 +6,13 @@ using System.Runtime.CompilerServices; using System.Security.AccessControl; using System.Security.Cryptography; +using DokanNet; +using SecureFolderFS.Core.Dokany.Helpers; +using SecureFolderFS.Core.FileSystem; +using SecureFolderFS.Core.FileSystem.AppModels; +using SecureFolderFS.Core.FileSystem.Exceptions; +using SecureFolderFS.Core.FileSystem.Helpers; +using SecureFolderFS.Core.FileSystem.OpenHandles; using FileAccess = DokanNet.FileAccess; namespace SecureFolderFS.Core.Dokany.Callbacks @@ -64,12 +65,20 @@ public virtual NtStatus FlushFileBuffers(string fileName, IDokanFileInfo info) try { - fileHandle.Stream.Flush(); + lock (fileHandle.Stream) + fileHandle.Stream.Flush(); + return Trace(DokanResult.Success, fileName, info); } - catch (IOException) + catch (IOException ioEx) { - return DokanResult.DiskFull; + if (ErrorHandlingHelpers.IsDiskFullException(ioEx)) + return Trace(DokanResult.DiskFull, fileName, info); + + if (DokanyErrorHelpers.NtStatusFromException(ioEx, out var ntStatus)) + return Trace((NtStatus)ntStatus, fileName, info); + + return Trace(DokanResult.Unsuccessful, fileName, info); } } @@ -88,7 +97,9 @@ public virtual NtStatus SetEndOfFile(string fileName, long length, IDokanFileInf if (handlesManager.GetHandle(GetContextValue(info)) is not { } fileHandle) return Trace(DokanResult.InvalidHandle, fileName, info); - fileHandle.Stream.SetLength(length); + lock (fileHandle.Stream) + fileHandle.Stream.SetLength(length); + return Trace(DokanResult.Success, fileName, info); } @@ -131,7 +142,6 @@ public virtual NtStatus FindStreams(string fileName, out IList } /// - [MethodImpl(MethodImplOptions.Synchronized)] public virtual unsafe NtStatus ReadFile(string fileName, IntPtr buffer, uint bufferLength, out int bytesRead, long offset, IDokanFileInfo info) { @@ -164,20 +174,25 @@ public virtual unsafe NtStatus ReadFile(string fileName, IntPtr buffer, uint buf try { - // Check EOF - if (offset >= fileHandle.Stream.Length) + // 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 (fileHandle.Stream) { - bytesRead = 0; - return NtStatus.EndOfFile; + // Check EOF + if (offset >= fileHandle.Stream.Length) + { + bytesRead = 0; + return NtStatus.EndOfFile; + } + + // Align position + fileHandle.Stream.Position = offset; + + // Read file + var bufferSpan = new Span(buffer.ToPointer(), (int)bufferLength); + bytesRead = fileHandle.Stream.Read(bufferSpan); } - // Align position - fileHandle.Stream.Position = offset; - - // Read file - var bufferSpan = new Span(buffer.ToPointer(), (int)bufferLength); - bytesRead = fileHandle.Stream.Read(bufferSpan); - return Trace(DokanResult.Success, fileName, info); } catch (PathTooLongException) @@ -203,7 +218,6 @@ public virtual unsafe NtStatus ReadFile(string fileName, IntPtr buffer, uint buf } /// - [MethodImpl(MethodImplOptions.Synchronized)] public virtual unsafe NtStatus WriteFile(string fileName, IntPtr buffer, uint bufferLength, out int bytesWritten, long offset, IDokanFileInfo info) { if (specifics.Options.IsReadOnly) @@ -228,7 +242,9 @@ public virtual unsafe NtStatus WriteFile(string fileName, IntPtr buffer, uint bu if (handlesManager.GetHandle(GetContextValue(info)) is not { } fileHandle) { // Invalid handle... - contextHandle = handlesManager.OpenFileHandle(ciphertextPath, appendToFile ? FileMode.Append : FileMode.Open, System.IO.FileAccess.ReadWrite, FileShare.Read, FileOptions.None); + // FileMode.Append cannot be combined with FileAccess.ReadWrite - open normally + // and seek to the end instead (the append position is aligned below) + contextHandle = handlesManager.OpenFileHandle(ciphertextPath, appendToFile ? FileMode.OpenOrCreate : FileMode.Open, System.IO.FileAccess.ReadWrite, FileShare.Read, FileOptions.None); fileHandle = handlesManager.GetHandle(contextHandle); openedNewHandle = true; } @@ -242,19 +258,24 @@ public virtual unsafe NtStatus WriteFile(string fileName, IntPtr buffer, uint bu try { - // Align for Paging I/O - var alignedBytesToCopy = AlignSizeForPagingIo((int)bufferLength, offset, fileHandle.Stream.Length, info); + // Lock on the handle's stream as the kernel can issue concurrent operations + // on the same handle, and setting the position and writing must be atomic + lock (fileHandle.Stream) + { + // Align for Paging I/O + var alignedBytesToCopy = AlignSizeForPagingIo((int)bufferLength, offset, fileHandle.Stream.Length, info); - // Align position for offset - var alignedPosition = appendToFile ? fileHandle.Stream.Length : offset; + // Align position for offset + var alignedPosition = appendToFile ? fileHandle.Stream.Length : offset; - // Align position - fileHandle.Stream.Position = alignedPosition; + // Align position + fileHandle.Stream.Position = alignedPosition; - // Write file - var bufferSpan = new ReadOnlySpan(buffer.ToPointer(), alignedBytesToCopy); - fileHandle.Stream.Write(bufferSpan); - bytesWritten = alignedBytesToCopy; + // Write file + var bufferSpan = new ReadOnlySpan(buffer.ToPointer(), alignedBytesToCopy); + fileHandle.Stream.Write(bufferSpan); + bytesWritten = alignedBytesToCopy; + } return Trace(DokanResult.Success, fileName, info); } @@ -376,6 +397,11 @@ protected static int AlignSizeForPagingIo(int bufferLength, long offset, long st return bufferLength; var longDistanceToEnd = streamLength - offset; + + // Paging I/O must not write beyond the end of the file + if (longDistanceToEnd <= 0L) + return 0; + if (longDistanceToEnd > int.MaxValue) return bufferLength; @@ -394,7 +420,7 @@ protected static NtStatus Trace(NtStatus result, string fileName, IDokanFileInfo if (Debugger.IsAttached) return result; - if (!Core.FileSystem.Constants.OPT_IN_FOR_OPTIONAL_DEBUG_TRACING) + if (!FileSystem.Constants.OPT_IN_FOR_OPTIONAL_DEBUG_TRACING) return result; if (DisallowedTraceMethods.Contains(methodName)) @@ -413,7 +439,7 @@ protected static NtStatus Trace(NtStatus result, string? fileName, IDokanFileInf return result; #endif - if (!Core.FileSystem.Constants.OPT_IN_FOR_OPTIONAL_DEBUG_TRACING) + if (!FileSystem.Constants.OPT_IN_FOR_OPTIONAL_DEBUG_TRACING) return result; if (!Debugger.IsAttached) @@ -434,7 +460,7 @@ protected static NtStatus Trace(NtStatus result, string? fileName, IDokanFileInf private static string[] DisallowedTraceMethods { get; } = { - "GetVolumeInformation" + nameof(GetVolumeInformation) }; } } diff --git a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs index 4d1308fc1..4a05e6716 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs @@ -1,4 +1,11 @@ -using DokanNet; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Cryptography; +using DokanNet; using OwlCore.Storage; using SecureFolderFS.Core.Dokany.Helpers; using SecureFolderFS.Core.Dokany.OpenHandles; @@ -13,15 +20,6 @@ using SecureFolderFS.Core.FileSystem.Helpers.RecycleBin.Native; using SecureFolderFS.Core.FileSystem.OpenHandles; using SecureFolderFS.Storage.VirtualFileSystem; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using System.Security.AccessControl; -using System.Security.Cryptography; using FileAccess = DokanNet.FileAccess; namespace SecureFolderFS.Core.Dokany.Callbacks @@ -518,15 +516,26 @@ public override NtStatus DeleteDirectory(string fileName, IDokanFileInfo info) if (ciphertextPath is null) return Trace(NtStatus.ObjectPathInvalid, fileName, info); - using var directoryEnumerator = Directory.EnumerateFileSystemEntries(ciphertextPath).GetEnumerator(); - while (directoryEnumerator.MoveNext()) + try { - // Check for any files except core files - canDelete &= PathHelpers.IsCoreName(Path.GetFileName(directoryEnumerator.Current)); + using var directoryEnumerator = Directory.EnumerateFileSystemEntries(ciphertextPath).GetEnumerator(); + while (directoryEnumerator.MoveNext()) + { + // Check for any files except core files + canDelete &= PathHelpers.IsCoreName(Path.GetFileName(directoryEnumerator.Current)); - // If the flag changed (directory is not empty), break the loop - if (!canDelete) - break; + // If the flag changed (directory is not empty), break the loop + if (!canDelete) + break; + } + } + catch (DirectoryNotFoundException) + { + return Trace(DokanResult.PathNotFound, fileName, info); + } + catch (UnauthorizedAccessException) + { + return Trace(DokanResult.AccessDenied, fileName, info); } var result = canDelete ? DokanResult.Success : DokanResult.DirectoryNotEmpty; @@ -575,9 +584,9 @@ public override NtStatus MoveFile(string oldName, string newName, bool replace, return Trace(DokanResult.AccessDenied, fileNameCombined, info); } - // File - File.Delete(newCiphertextPath); - File.Move(oldCiphertextPath, newCiphertextPath); + // Replace the destination file atomically. A separate delete and move + // could lose the destination file if the operation is interrupted in between + File.Replace(oldCiphertextPath, newCiphertextPath, null, true); return Trace(DokanResult.Success, fileNameCombined, info); } @@ -758,7 +767,6 @@ public override NtStatus SetFileSecurity(string fileName, FileSystemSecurity sec } /// - [MethodImpl(MethodImplOptions.Synchronized)] public override NtStatus ReadFile(string fileName, IntPtr buffer, uint bufferLength, out int bytesRead, long offset, IDokanFileInfo info) { try @@ -775,18 +783,14 @@ public override NtStatus ReadFile(string fileName, IntPtr buffer, uint bufferLen throw; } - catch (Exception ex) + catch (Exception) { - _ = ex; bytesRead = 0; - Debugger.Break(); - return Trace(DokanResult.InternalError, fileName, info); } } /// - [MethodImpl(MethodImplOptions.Synchronized)] public override NtStatus WriteFile(string fileName, IntPtr buffer, uint bufferLength, out int bytesWritten, long offset, IDokanFileInfo info) { try @@ -803,12 +807,9 @@ public override NtStatus WriteFile(string fileName, IntPtr buffer, uint bufferLe throw; } - catch (Exception ex) + catch (Exception) { - _ = ex; bytesWritten = 0; - - Debugger.Break(); return Trace(DokanResult.InternalError, fileName, info); } } diff --git a/src/Core/SecureFolderFS.Core.Dokany/DokanyFileSystem.cs b/src/Core/SecureFolderFS.Core.Dokany/DokanyFileSystem.cs index 71daa46b9..01a85a91c 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/DokanyFileSystem.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/DokanyFileSystem.cs @@ -54,7 +54,7 @@ public async Task MountAsync(IFolder folder, IDisposable unlockContrac if (dokanyOptions.MountPoint is null) throw new DirectoryNotFoundException("No available free mount points for vault file system."); - var handlesManager = new DokanyHandlesManager(specifics.StreamsAccess, specifics.Options); + var handlesManager = new DokanyHandlesManager(wrapper.Inner, specifics.StreamsAccess, specifics.Options); var volumeModel = new VolumeModel(specifics.Options.VolumeName, Constants.Dokan.FS_TYPE_ID); var dokanyCallbacks = new OnDeviceDokany(specifics, handlesManager, volumeModel); var dokanyWrapper = new DokanyWrapper(dokanyCallbacks); diff --git a/src/Core/SecureFolderFS.Core.Dokany/OpenHandles/DokanyFileHandle.cs b/src/Core/SecureFolderFS.Core.Dokany/OpenHandles/DokanyFileHandle.cs index cca1c49ac..5ace9b529 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/OpenHandles/DokanyFileHandle.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/OpenHandles/DokanyFileHandle.cs @@ -1,4 +1,5 @@ using Microsoft.Win32.SafeHandles; +using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Core.Dokany.UnsafeNative; using SecureFolderFS.Core.FileSystem.OpenHandles; using SecureFolderFS.Shared.ComponentModel; @@ -11,9 +12,12 @@ namespace SecureFolderFS.Core.Dokany.OpenHandles /// internal sealed class DokanyFileHandle : FileHandle { - public DokanyFileHandle(Stream stream) + private readonly Security _security; + + public DokanyFileHandle(Stream stream, Security security) : base(stream) { + _security = security; } /// @@ -46,14 +50,47 @@ public bool SetFileTime(ref long creationTime, ref long lastAccessTime, ref long public void Lock(long position, long length) { if (Stream is IWrapper { Inner: FileStream fileStream }) - fileStream.Lock(position, length); + { + var (ciphertextPosition, ciphertextLength) = TranslateToCiphertextRange(position, length); + fileStream.Lock(ciphertextPosition, ciphertextLength); + } } /// public void Unlock(long position, long length) { if (Stream is IWrapper { Inner: FileStream fileStream }) - fileStream.Unlock(position, length); + { + var (ciphertextPosition, ciphertextLength) = TranslateToCiphertextRange(position, length); + fileStream.Unlock(ciphertextPosition, ciphertextLength); + } + } + + /// + /// Translates a plaintext byte range to the ciphertext range covering the affected chunks. + /// + /// + /// Plaintext offsets cannot be applied to the ciphertext file directly as the file starts + /// with the header, and each chunk carries additional overhead. Locking untranslated + /// offsets would lock the header (offset 0) and misalign every subsequent range. + /// The translation is deterministic, so a matching releases the exact same range. + /// + private (long Position, long Length) TranslateToCiphertextRange(long plaintextPosition, long plaintextLength) + { + var chunkPlaintextSize = (long)_security.ContentCrypt.ChunkPlaintextSize; + var chunkCiphertextSize = (long)_security.ContentCrypt.ChunkCiphertextSize; + var headerCiphertextSize = (long)_security.HeaderCrypt.HeaderCiphertextSize; + + // Always cover at least the chunk containing the start position + var firstChunk = plaintextPosition / chunkPlaintextSize; + var lastChunkExclusive = plaintextLength <= 0L + ? firstChunk + 1L + : (plaintextPosition + plaintextLength + chunkPlaintextSize - 1L) / chunkPlaintextSize; + + var ciphertextPosition = headerCiphertextSize + firstChunk * chunkCiphertextSize; + var ciphertextLength = (lastChunkExclusive - firstChunk) * chunkCiphertextSize; + + return (ciphertextPosition, ciphertextLength); } } } diff --git a/src/Core/SecureFolderFS.Core.Dokany/OpenHandles/DokanyHandlesManager.cs b/src/Core/SecureFolderFS.Core.Dokany/OpenHandles/DokanyHandlesManager.cs index c853fbf36..edcfc4dcc 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/OpenHandles/DokanyHandlesManager.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/OpenHandles/DokanyHandlesManager.cs @@ -1,17 +1,21 @@ -using SecureFolderFS.Core.FileSystem.Extensions; +using System.IO; +using SecureFolderFS.Core.Cryptography; +using SecureFolderFS.Core.FileSystem.Extensions; using SecureFolderFS.Core.FileSystem.OpenHandles; using SecureFolderFS.Core.FileSystem.Streams; using SecureFolderFS.Storage.VirtualFileSystem; -using System.IO; namespace SecureFolderFS.Core.Dokany.OpenHandles { /// internal sealed class DokanyHandlesManager : BaseHandlesManager { - public DokanyHandlesManager(StreamsAccess streamsAccess, VirtualFileSystemOptions fileSystemOptions) + private readonly Security _security; + + public DokanyHandlesManager(Security security, StreamsAccess streamsAccess, VirtualFileSystemOptions fileSystemOptions) : base(streamsAccess, fileSystemOptions) { + _security = security; } /// @@ -37,7 +41,7 @@ public override ulong OpenFileHandle(string ciphertextPath, FileMode mode, FileA plaintextStream.Flush(); // Create handle - var fileHandle = new DokanyFileHandle(plaintextStream); + var fileHandle = new DokanyFileHandle(plaintextStream, _security); var handleId = handlesGenerator.ThreadSafeIncrement(); // Lock collection and add handle diff --git a/src/Core/SecureFolderFS.Core.FUSE/AppModels/FuseOptions.cs b/src/Core/SecureFolderFS.Core.FUSE/AppModels/FuseOptions.cs index 0b1a6e204..f6ee1ace3 100644 --- a/src/Core/SecureFolderFS.Core.FUSE/AppModels/FuseOptions.cs +++ b/src/Core/SecureFolderFS.Core.FUSE/AppModels/FuseOptions.cs @@ -58,7 +58,7 @@ public static FuseOptions ToOptions(IDictionary options) 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)) ?? true, + IsCachingFileNames = (bool?)options.Get(nameof(IsCachingFileNames)) ?? false, IsCachingDirectoryIds = (bool?)options.Get(nameof(IsCachingDirectoryIds)) ?? true, RecycleBinSize = (long?)options.Get(nameof(RecycleBinSize)) ?? 0L, @@ -66,7 +66,7 @@ public static FuseOptions ToOptions(IDictionary options) 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(AllowOtherUserAccess)) ?? false + PrintDebugInformation = (bool?)options.Get(nameof(PrintDebugInformation)) ?? false }; } } diff --git a/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs b/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs index 659bc0f7d..f09d7a998 100644 --- a/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs +++ b/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs @@ -1,11 +1,10 @@ +using System.Text; 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.FUSE.OpenHandles; using SecureFolderFS.Core.FUSE.UnsafeNative; -using System.Runtime.CompilerServices; -using System.Text; -using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract; using Tmds.Fuse; using Tmds.Linux; using static SecureFolderFS.Core.FUSE.UnsafeNative.UnsafeNativeApis; @@ -83,7 +82,6 @@ public override unsafe int Create(ReadOnlySpan path, mode_t mode, ref Fuse } } - [MethodImpl(MethodImplOptions.Synchronized)] public override int FAllocate(ReadOnlySpan path, int mode, ulong offset, long length, ref FuseFileInfo fi) { if (FuseOptions!.IsReadOnly) @@ -97,39 +95,53 @@ public override int FAllocate(ReadOnlySpan path, int mode, ulong offset, l if (handle is null || !handle.FileAccess.HasFlag(FileAccess.Write)) return -EBADF; + // Only the default mode (extend allocation and size) is supported. + // FALLOC_FL_KEEP_SIZE and other flags arrive in 'mode', not in fi.flags if (mode != 0) return -EOPNOTSUPP; var newLength = (long)offset + length; - if ((fi.flags & FALLOC_FL_KEEP_SIZE) == 0 && newLength > handle.Stream.Length) - handle.Stream.SetLength(newLength); + lock (handle.Stream) + { + if (newLength > handle.Stream.Length) + handle.Stream.SetLength(newLength); + } return 0; } - [MethodImpl(MethodImplOptions.Synchronized)] public override int Flush(ReadOnlySpan path, ref FuseFileInfo fi) { var handle = handlesManager.GetHandle(fi.fh); - if (handle is null || !handle.FileAccess.HasFlag(FileAccess.Write)) + if (handle is null) return -EBADF; - handle.Stream.Flush(); + // 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; } - [MethodImpl(MethodImplOptions.Synchronized)] public override unsafe int FSync(ReadOnlySpan path, bool onlyData, ref FuseFileInfo fi) { var handle = handlesManager.GetHandle(fi.fh); - if (handle is null || !handle.FileAccess.HasFlag(FileAccess.Write)) + 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; - handle.Stream.Flush(); + lock (handle.Stream) + handle.Stream.Flush(); if (onlyData) return 0; @@ -149,20 +161,20 @@ public override unsafe int FSync(ReadOnlySpan path, bool onlyData, ref Fus } } - [MethodImpl(MethodImplOptions.Synchronized)] public override unsafe int FSyncDir(ReadOnlySpan path, bool onlyData, ref FuseFileInfo fi) { - if (FuseOptions!.IsReadOnly) - return -EROFS; - 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 FuseFileHandle fuseFileHandle && Path.GetDirectoryName(ciphertextPath)!.StartsWith(fuseFileHandle.Directory)) - fuseFileHandle.Stream.Flush(); + if (handle is FuseFileHandle { Stream.CanWrite: true } fuseFileHandle && fuseFileHandle.Directory.StartsWith(ciphertextPath, StringComparison.Ordinal)) + { + lock (fuseFileHandle.Stream) + fuseFileHandle.Stream.Flush(); + } } if (onlyData) @@ -198,8 +210,17 @@ public override unsafe int GetAttr(ReadOnlySpan path, ref stat stat, FuseF if (LibC.stat(ciphertextPathPtr, statPtr) == -1) return -errno; - if (File.Exists(ciphertextPath)) + // 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 (File.Exists(ciphertextPath)) + { stat.st_size = Math.Max(0, specifics.Security.ContentCrypt.CalculatePlaintextSize(stat.st_size - specifics.Security.HeaderCrypt.HeaderCiphertextSize)); + } return 0; } @@ -292,10 +313,10 @@ public override int Open(ReadOnlySpan path, ref FuseFileInfo fi) var mode = FileMode.Open; if ((fi.flags & O_APPEND) != 0) mode = FileMode.Append; - else if ((fi.flags & O_CREAT) != 0) - mode = FileMode.Create; else if ((fi.flags & O_TRUNC) != 0) - mode = FileMode.Truncate; + 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) @@ -310,10 +331,19 @@ public override int Open(ReadOnlySpan path, ref FuseFileInfo fi) if ((fi.flags & O_TMPFILE) != 0) options |= FileOptions.DeleteOnClose; - if (FuseOptions!.IsReadOnly && (mode == FileMode.Create || mode == FileMode.Truncate || (fi.flags & O_WRONLY) != 0 || (fi.flags & O_RDWR) != 0)) + 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; - var access = mode == FileMode.Append ? FileAccess.Write : FileAccess.ReadWrite; + // 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; @@ -337,18 +367,22 @@ public override int OpenDir(ReadOnlySpan path, ref FuseFileInfo fi) return 0; } - [MethodImpl(MethodImplOptions.Synchronized)] 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; - if ((long)offset > handle.Stream.Length) - return 0; + // 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); + handle.Stream.Position = (long)offset; + return handle.Stream.Read(buffer); + } } public override int ReadDir(ReadOnlySpan path, ulong offset, ReadDirFlags flags, DirectoryContent content, ref FuseFileInfo fi) @@ -363,18 +397,21 @@ public override int ReadDir(ReadOnlySpan path, ulong offset, ReadDirFlags var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security); foreach (var entry in Directory.GetFileSystemEntries(ciphertextPath)) { - if (PathHelpers.IsCoreName(entry)) + var ciphertextName = Path.GetFileName(entry); + if (PathHelpers.IsCoreName(ciphertextName)) continue; - var ciphertextName = Path.GetFileName(entry); + // 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; } - [MethodImpl(MethodImplOptions.Synchronized)] public override void Release(ReadOnlySpan path, ref FuseFileInfo fi) { handlesManager.CloseHandle(fi.fh); @@ -487,7 +524,6 @@ public override unsafe int StatFS(ReadOnlySpan path, ref statvfs statfs) return 0; } - [MethodImpl(MethodImplOptions.Synchronized)] public override int Truncate(ReadOnlySpan path, ulong length, FuseFileInfoRef fiRef) { if (FuseOptions!.IsReadOnly) @@ -504,13 +540,14 @@ public override int Truncate(ReadOnlySpan path, ulong length, FuseFileInfo return -ENOENT; FuseFileHandle? handle; + var temporaryHandleId = FileSystem.Constants.INVALID_HANDLE; if (fiRef.IsNull) { - var newHandle = handlesManager.OpenFileHandle(ciphertextPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite, FileOptions.None); - if (newHandle == FileSystem.Constants.INVALID_HANDLE) + temporaryHandleId = handlesManager.OpenFileHandle(ciphertextPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite, FileOptions.None); + if (temporaryHandleId == FileSystem.Constants.INVALID_HANDLE) return -EIO; - handle = handlesManager.GetHandle(newHandle)!; + handle = handlesManager.GetHandle(temporaryHandleId)!; } else { @@ -519,11 +556,23 @@ public override int Truncate(ReadOnlySpan path, ulong length, FuseFileInfo return -EBADF; } - var position = handle.Stream.Position; - handle.Stream.SetLength((long)length); - handle.Stream.Position = position; + try + { + lock (handle.Stream) + { + var position = handle.Stream.Position; + handle.Stream.SetLength((long)length); + handle.Stream.Position = position; + } - return 0; + return 0; + } + finally + { + // Close the temporary handle + if (temporaryHandleId != FileSystem.Constants.INVALID_HANDLE) + handlesManager.CloseHandle(temporaryHandleId); + } } /// @@ -569,13 +618,14 @@ public override unsafe int UpdateTimestamps(ReadOnlySpan path, ref timespe return -errno; var result = futimens(*(int*)fd, times); - UnsafeNativeApis.CloseDir(fd); + CloseDir(fd); if (result == -1) return -errno; return 0; } + if (File.Exists(ciphertextPath)) { var fd = open(ciphertextPathPtr, O_WRONLY); @@ -595,21 +645,24 @@ public override unsafe int UpdateTimestamps(ReadOnlySpan path, ref timespe } } - [MethodImpl(MethodImplOptions.Synchronized)] 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; - if (handle.FileMode == FileMode.Append) - offset = (ulong)handle.Stream.Length; - - if ((long)offset + buffer.Length > handle.Stream.Length) - handle.Stream.SetLength((long)offset + buffer.Length); + // 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; - handle.Stream.Position = (long)offset; - handle.Stream.Write(buffer); + // 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; } diff --git a/src/Core/SecureFolderFS.Core.FUSE/OpenHandles/FuseHandlesManager.cs b/src/Core/SecureFolderFS.Core.FUSE/OpenHandles/FuseHandlesManager.cs index 6b60addc9..bb7f32cb3 100644 --- a/src/Core/SecureFolderFS.Core.FUSE/OpenHandles/FuseHandlesManager.cs +++ b/src/Core/SecureFolderFS.Core.FUSE/OpenHandles/FuseHandlesManager.cs @@ -13,12 +13,22 @@ public FuseHandlesManager(StreamsAccess streamsAccess, VirtualFileSystemOptions { } + /// + /// 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; + return handles.Values.ToArray(); } } @@ -38,7 +48,11 @@ public override ulong OpenFileHandle(string ciphertextPath, FileMode mode, FileA // 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, - Access = FileAccess.ReadWrite, + + // 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 }); diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Buffers/HeaderBuffer.cs b/src/Core/SecureFolderFS.Core.FileSystem/Buffers/HeaderBuffer.cs index 2df82eea3..8e5b32627 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Buffers/HeaderBuffer.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Buffers/HeaderBuffer.cs @@ -10,6 +10,15 @@ public sealed class HeaderBuffer : BufferHolder /// public bool IsHeaderReady { get; set; } + /// + /// Gets the synchronization root that guards header initialization. + /// + /// + /// The header buffer is shared by all streams opened on the same file, + /// so reading or creating the header must be synchronized across streams. + /// + public object SyncRoot { get; } = new(); + public HeaderBuffer(byte[] buffer) : base(buffer) { diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/CachingChunkAccess.cs b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/CachingChunkAccess.cs index bd4a8a27d..b0e37feec 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/CachingChunkAccess.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/CachingChunkAccess.cs @@ -18,8 +18,18 @@ public override bool FlushAvailable { get { + // Hold the cache lock for the entire operation lock (_chunkCache) - return _chunkCache.Count > 0; + { + // Only chunks that were actually modified need flushing + foreach (var item in _chunkCache) + { + if (item.Value.WasModified) + return true; + } + + return false; + } } } @@ -32,90 +42,109 @@ public CachingChunkAccess(ChunkReader chunkReader, ChunkWriter chunkWriter, ICon /// public override int CopyFromChunk(long chunkNumber, Span destination, int offsetInChunk) { - // Get chunk - var plaintextChunk = GetChunk(chunkNumber); - if (plaintextChunk is null) - return -1; + // Hold the cache lock for the entire operation + lock (_chunkCache) + { + // Get chunk + var plaintextChunk = GetChunk(chunkNumber); + if (plaintextChunk is null) + return -1; - // Copy from chunk - var count = Math.Min(plaintextChunk.ActualLength - offsetInChunk, destination.Length); - if (count < 0) - return -1; + // Copy from chunk + var count = Math.Min(plaintextChunk.ActualLength - offsetInChunk, destination.Length); + if (count < 0) + return -1; - plaintextChunk.Buffer.AsSpan(offsetInChunk, count).CopyTo(destination); + plaintextChunk.Buffer.AsSpan(offsetInChunk, count).CopyTo(destination); - return count; + return count; + } } /// public override int CopyToChunk(long chunkNumber, ReadOnlySpan source, int offsetInChunk) { - // Get chunk - var plaintextChunk = GetChunk(chunkNumber); - if (plaintextChunk is null) - return -1; + // Hold the cache lock for the entire operation + lock (_chunkCache) + { + // Get chunk + var plaintextChunk = GetChunk(chunkNumber); + if (plaintextChunk is null) + return -1; - // Update state of chunk - plaintextChunk.WasModified = true; + // Update state of chunk + plaintextChunk.WasModified = true; - // Copy to chunk - var count = Math.Min(contentCrypt.ChunkPlaintextSize - offsetInChunk, source.Length); - if (count < 0) - return -1; + // Copy to chunk + var count = Math.Min(contentCrypt.ChunkPlaintextSize - offsetInChunk, source.Length); + if (count < 0) + return -1; - var destination = plaintextChunk.Buffer.AsSpan(offsetInChunk, count); - source.Slice(0, count).CopyTo(destination); + var destination = plaintextChunk.Buffer.AsSpan(offsetInChunk, count); + source.Slice(0, count).CopyTo(destination); - // Update actual length - plaintextChunk.ActualLength = Math.Max(plaintextChunk.ActualLength, count + offsetInChunk); + // Update actual length + plaintextChunk.ActualLength = Math.Max(plaintextChunk.ActualLength, count + offsetInChunk); - return count; + return count; + } } /// public override void SetChunkLength(long chunkNumber, int length, bool includeCurrentLength = false) { - // Get chunk - var plaintextChunk = GetChunk(chunkNumber); - if (plaintextChunk is null) - return; + // Hold the cache lock for the entire operation + lock (_chunkCache) + { + // Get chunk + var plaintextChunk = GetChunk(chunkNumber); + if (plaintextChunk is null) + return; - // Add read length of existing chunk data to the full length if specified - length += includeCurrentLength ? plaintextChunk.ActualLength : 0; - length = Math.Max(length, 0); + // Add read length of existing chunk data to the full length if specified + length += includeCurrentLength ? plaintextChunk.ActualLength : 0; + length = Math.Max(length, 0); - // Determine whether to extend or truncate the chunk - if (length < plaintextChunk.ActualLength) - { - // Truncate chunk - plaintextChunk.ActualLength = Math.Min(plaintextChunk.ActualLength, length); - } - else if (plaintextChunk.ActualLength < length) - { - // Extend chunk - plaintextChunk.ActualLength = Math.Min(length, contentCrypt.ChunkPlaintextSize); - } - else - return; // Ignore resizing the same length + // Determine whether to extend or truncate the chunk + if (length < plaintextChunk.ActualLength) + { + // Truncate chunk + plaintextChunk.ActualLength = Math.Min(plaintextChunk.ActualLength, length); + } + else if (plaintextChunk.ActualLength < length) + { + // Extend chunk + plaintextChunk.ActualLength = Math.Min(length, contentCrypt.ChunkPlaintextSize); + } + else + return; // Ignore resizing the same length - plaintextChunk.WasModified = true; + plaintextChunk.WasModified = true; + } } /// public override void Flush() { + // Hold the cache lock for the entire operation lock (_chunkCache) { foreach (var item in _chunkCache) { if (item.Value.WasModified) + { chunkWriter.WriteChunk(item.Key, item.Value.Buffer.AsSpan(0, item.Value.ActualLength)); + + // Mark the chunk as clean so subsequent flushes don't rewrite it + item.Value.WasModified = false; + } } } } private ChunkBuffer? GetChunk(long chunkNumber) { + // Hold the cache lock for the entire operation lock (_chunkCache) { if (!_chunkCache.TryGetValue(chunkNumber, out var plaintextChunk)) @@ -147,6 +176,7 @@ public override void Flush() private void SetChunk(long chunkNumber, ChunkBuffer plaintextChunk) { + // Hold the cache lock for the entire operation lock (_chunkCache) { if (_chunkCache.Count >= FileSystem.Constants.Caching.RECOMMENDED_SIZE_CHUNKS) @@ -171,6 +201,17 @@ public override void Dispose() { lock (_chunkCache) { + try + { + // Flush outstanding modified chunks so data is not lost when + // the chunk access is disposed without a prior flush + Flush(); + } + catch (Exception) + { + // Dispose must not throw (the backing stream may already be unavailable) + } + base.Dispose(); _chunkCache.Clear(); } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkAccess.cs b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkAccess.cs index f82c17561..faa37ce8d 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkAccess.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkAccess.cs @@ -16,6 +16,16 @@ internal class ChunkAccess : IDisposable protected readonly IContentCrypt contentCrypt; protected readonly IFileSystemStatistics fileSystemStatistics; + /// + /// The synchronization root guarding chunk operations. + /// + /// + /// A chunk access instance can be shared by multiple streams of the same file, + /// and the reader/writer also share the position of one ciphertext stream, + /// so chunk operations must not interleave. + /// + protected readonly object chunkLock = new(); + /// /// Determines whether there are outstanding chunks ready to be flushed to disk. /// @@ -42,24 +52,28 @@ public virtual int CopyFromChunk(long chunkNumber, Span destination, int o var plaintextChunk = ArrayPool.Shared.Rent(contentCrypt.ChunkPlaintextSize); try { - // ArrayPool may return a larger array than requested - var realPlaintextChunk = plaintextChunk.AsSpan(0, contentCrypt.ChunkPlaintextSize); + // Hold the cache lock for the entire operation + lock (chunkLock) + { + // ArrayPool may return a larger array than requested + var realPlaintextChunk = plaintextChunk.AsSpan(0, contentCrypt.ChunkPlaintextSize); - // Read chunk - var read = chunkReader.ReadChunk(chunkNumber, realPlaintextChunk); + // Read chunk + var read = chunkReader.ReadChunk(chunkNumber, realPlaintextChunk); - // Check for any errors - if (read < 0) - return read; + // Check for any errors + if (read < 0) + return read; - // Copy from chunk - var count = Math.Min(read - offsetInChunk, destination.Length); - if (count <= 0) - return 0; + // Copy from chunk + var count = Math.Min(read - offsetInChunk, destination.Length); + if (count <= 0) + return 0; - realPlaintextChunk.Slice(offsetInChunk, count).CopyTo(destination); + realPlaintextChunk.Slice(offsetInChunk, count).CopyTo(destination); - return count; + return count; + } } finally { @@ -84,28 +98,32 @@ public virtual int CopyToChunk(long chunkNumber, ReadOnlySpan source, int var plaintextChunk = ArrayPool.Shared.Rent(contentCrypt.ChunkPlaintextSize); try { - // ArrayPool may return larger array than requested - var realPlaintextChunk = plaintextChunk.AsSpan(0, contentCrypt.ChunkPlaintextSize); + // Hold the cache lock for the entire operation + lock (chunkLock) + { + // ArrayPool may return larger array than requested + var realPlaintextChunk = plaintextChunk.AsSpan(0, contentCrypt.ChunkPlaintextSize); - // Read chunk - var read = chunkReader.ReadChunk(chunkNumber, realPlaintextChunk); + // Read chunk + var read = chunkReader.ReadChunk(chunkNumber, realPlaintextChunk); - // Check for any errors - if (read < 0) - return read; + // Check for any errors + if (read < 0) + return read; - // Copy to chunk - var count = Math.Min(contentCrypt.ChunkPlaintextSize - offsetInChunk, source.Length); - if (count <= 0) - return 0; + // Copy to chunk + var count = Math.Min(contentCrypt.ChunkPlaintextSize - offsetInChunk, source.Length); + if (count <= 0) + return 0; - var destination = realPlaintextChunk.Slice(offsetInChunk, count); - source.Slice(0, count).CopyTo(destination); + var destination = realPlaintextChunk.Slice(offsetInChunk, count); + source.Slice(0, count).CopyTo(destination); - // Write to chunk - chunkWriter.WriteChunk(chunkNumber, destination); + // Write to chunk + chunkWriter.WriteChunk(chunkNumber, destination); - return count; + return count; + } } finally { @@ -129,41 +147,45 @@ public virtual void SetChunkLength(long chunkNumber, int length, bool includeCur var plaintextChunk = ArrayPool.Shared.Rent(contentCrypt.ChunkPlaintextSize); try { - // ArrayPool may return larger array than requested - var realPlaintextChunk = plaintextChunk.AsSpan(0, contentCrypt.ChunkPlaintextSize); - - // Read chunk - var read = chunkReader.ReadChunk(chunkNumber, realPlaintextChunk); - - // Check for any errors - if (read < 0) - throw new CryptographicException(); - - // Add read length of existing chunk data to the full length if specified - length += includeCurrentLength ? read : 0; - length = Math.Max(length, 0); - - Span newPlaintextChunk; - - // Determine whether to extend or truncate the chunk - if (length < read) + // Hold the cache lock for the entire operation + lock (chunkLock) { - // Truncate chunk - newPlaintextChunk = realPlaintextChunk.Slice(0, Math.Min(read, length)); + // ArrayPool may return larger array than requested + var realPlaintextChunk = plaintextChunk.AsSpan(0, contentCrypt.ChunkPlaintextSize); + + // Read chunk + var read = chunkReader.ReadChunk(chunkNumber, realPlaintextChunk); + + // Check for any errors + if (read < 0) + throw new CryptographicException(); + + // Add read length of existing chunk data to the full length if specified + length += includeCurrentLength ? read : 0; + length = Math.Max(length, 0); + + Span newPlaintextChunk; + + // Determine whether to extend or truncate the chunk + if (length < read) + { + // Truncate chunk + newPlaintextChunk = realPlaintextChunk.Slice(0, Math.Min(read, length)); + } + else if (read < length) + { + // Clear residual data from ArrayPool and append zeros + realPlaintextChunk.Slice(read).Clear(); + + // Extend chunk + newPlaintextChunk = realPlaintextChunk.Slice(0, Math.Min(length, contentCrypt.ChunkPlaintextSize)); + } + else + return; // Ignore resizing the same length + + // Save newly modified chunk + chunkWriter.WriteChunk(chunkNumber, newPlaintextChunk); } - else if (read < length) - { - // Clear residual data from ArrayPool and append zeros - realPlaintextChunk.Slice(read).Clear(); - - // Extend chunk - newPlaintextChunk = realPlaintextChunk.Slice(0, Math.Min(length, contentCrypt.ChunkPlaintextSize)); - } - else - return; // Ignore resizing the same length - - // Save newly modified chunk - chunkWriter.WriteChunk(chunkNumber, newPlaintextChunk); } finally { diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkReader.cs b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkReader.cs index 19828376d..53eb7cfb7 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkReader.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkReader.cs @@ -1,6 +1,5 @@ using System; using System.Buffers; -using System.Diagnostics; using System.IO; using System.Security.Cryptography; using SecureFolderFS.Core.Cryptography; @@ -91,10 +90,7 @@ public int ReadChunk(long chunkNumber, Span plaintextChunk) // Check if the chunk is authentic if (!result) - { - Debugger.Break(); return -1; - } return read - (ciphertextSize - plaintextSize); } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkWriter.cs b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkWriter.cs index d272446e1..785b42fa5 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkWriter.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Chunks/ChunkWriter.cs @@ -56,13 +56,15 @@ public void WriteChunk(long chunkNumber, ReadOnlySpan plaintextChunk) _fileSystemStatistics.BytesEncrypted?.Report(plaintextChunk.Length); - // Check position bounds - if (streamPosition > _ciphertextStream.Length) - return; + // Extend the stream when the chunk starts beyond the current end. + // The zero-filled region decrypts as valid zero chunks, so out-of-order + // chunk writes must not be dropped as that would silently lose data + if (_ciphertextStream.CanSeek && streamPosition > _ciphertextStream.Length) + _ciphertextStream.SetLength(streamPosition); // Set the correct stream position if (!_ciphertextStream.TrySetPositionOrAdvance(streamPosition)) - return; + throw new IOException($"The stream position could not be set to the chunk at {streamPosition}."); // Write to stream at the correct chunk _ciphertextStream.Write(realCiphertextChunk); diff --git a/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFile.cs b/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFile.cs index 1fb2ad119..4565b6d12 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFile.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFile.cs @@ -18,12 +18,18 @@ internal sealed class OpenCryptFile : IDisposable private readonly Action _notifyClosed; private readonly OpenCryptFileManager _cryptFileManager; private readonly Dictionary _openedStreams; + private readonly object _streamsLock = new(); /// /// Gets the unique ID of the file. /// public string Id { get; } + /// + /// Gets the value that determines whether this instance has been disposed of. + /// + public bool IsDisposed { get; private set; } + /// /// Gets the buffer that holds the header information for the cryptographic file. /// @@ -51,50 +57,66 @@ public OpenCryptFile( /// A new instance of . public PlaintextStream OpenStream(Stream ciphertextStream) { - if (_openedStreams.TryGetValue(ciphertextStream, out var existing)) - _openedStreams[ciphertextStream] = (existing.RefCount + 1, existing.ChunkAccess); - else + lock (_streamsLock) { - var chunkAccess = _cryptFileManager.CreateChunkAccess(ciphertextStream, HeaderBuffer); - _openedStreams.Add(ciphertextStream, (1, chunkAccess)); - } + if (IsDisposed) + ObjectDisposedException.ThrowIf(IsDisposed, this); - return new PlaintextStream(ciphertextStream, _security, _openedStreams[ciphertextStream].ChunkAccess, HeaderBuffer, NotifyClosed); + if (_openedStreams.TryGetValue(ciphertextStream, out var existing)) + _openedStreams[ciphertextStream] = (existing.RefCount + 1, existing.ChunkAccess); + else + { + var chunkAccess = _cryptFileManager.CreateChunkAccess(ciphertextStream, HeaderBuffer); + _openedStreams.Add(ciphertextStream, (1, chunkAccess)); + } + + return new PlaintextStream(ciphertextStream, _security, _openedStreams[ciphertextStream].ChunkAccess, HeaderBuffer, NotifyClosed); + } } private void NotifyClosed(Stream ciphertextStream) { - if (_openedStreams.TryGetValue(ciphertextStream, out var existing)) + lock (_streamsLock) { - // Make sure to remove it and update the reference count - if (--existing.RefCount <= 0) + if (_openedStreams.TryGetValue(ciphertextStream, out var existing)) { - // Dispose of the stream - _openedStreams.Remove(ciphertextStream); - existing.ChunkAccess.Dispose(); - ciphertextStream.Dispose(); + // Make sure to remove it and update the reference count + if (--existing.RefCount <= 0) + { + // Dispose of the stream + _openedStreams.Remove(ciphertextStream); + existing.ChunkAccess.Dispose(); + ciphertextStream.Dispose(); + } + else + _openedStreams[ciphertextStream] = (existing.RefCount, existing.ChunkAccess); } - else - _openedStreams[ciphertextStream] = (existing.RefCount, existing.ChunkAccess); - } - // Notify closed if no streams left - if (_openedStreams.IsEmpty()) - { - _notifyClosed(Id); - Dispose(); + // Notify closed if no streams left + if (_openedStreams.IsEmpty()) + { + _notifyClosed(Id); + Dispose(); + } } } /// public void Dispose() { - foreach (var (stream, (_, chunkAccess)) in _openedStreams) + lock (_streamsLock) { - chunkAccess.Dispose(); - stream.Dispose(); + if (IsDisposed) + return; + + IsDisposed = true; + foreach (var (stream, (_, chunkAccess)) in _openedStreams) + { + chunkAccess.Dispose(); + stream.Dispose(); + } + _openedStreams.Clear(); } - _openedStreams.Clear(); } } } \ No newline at end of file diff --git a/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFileManager.cs b/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFileManager.cs index 182241a0a..05ee2a388 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFileManager.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/CryptFiles/OpenCryptFileManager.cs @@ -57,6 +57,30 @@ public OpenCryptFile NewCryptFile(string id, BufferHolder headerBuffer) return cryptFile; } + /// + /// Atomically gets an existing or creates a new one. + /// + /// The unique ID of the file. + /// The factory invoked to create the plaintext header buffer for a new file. + /// An instance of . + /// + /// Getting and creating must happen under one lock, otherwise two threads opening the same + /// file simultaneously would create two instances with independent header buffers. + /// + public OpenCryptFile GetOrCreate(string id, Func headerBufferFactory) + { + lock (_openCryptFiles) + { + if (_openCryptFiles.TryGetValue(id, out var existing) && !existing.IsDisposed) + return existing; + + var cryptFile = new OpenCryptFile(id, _security, headerBufferFactory.Invoke(_security), this, NotifyClosed); + _openCryptFiles[id] = cryptFile; + + return cryptFile; + } + } + /// /// Creates a new bound exclusively to . /// diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileHeaderExtensions.cs b/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileHeaderExtensions.cs index 83c92ec58..64d49b00a 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileHeaderExtensions.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Extensions/FileHeaderExtensions.cs @@ -18,34 +18,42 @@ public static bool ReadHeader(this HeaderBuffer headerBuffer, Stream ciphertextS if (!ciphertextStream.CanRead) throw FileSystemExceptions.StreamNotReadable; - // Allocate ciphertext header - Span ciphertextHeader = stackalloc byte[security.HeaderCrypt.HeaderCiphertextSize]; - - // Read header - int read; - if (ciphertextStream.CanSeek && ciphertextStream.Position != 0L) - { - var ciphertextPosition = ciphertextStream.Position; - ciphertextStream.Position = 0L; - - read = ciphertextStream.Read(ciphertextHeader); - ciphertextStream.Position = ciphertextPosition; - } - else + // The header buffer is shared by all streams of the same file, so a lock is needed + lock (headerBuffer.SyncRoot) { - // Non-seekable streams must be at position 0 - header is always read first sequentially. - // There is no way to rewind, so we simply read and continue. - read = ciphertextStream.Read(ciphertextHeader); + // Re-check after lock + if (headerBuffer.IsHeaderReady) + return true; + + // Allocate ciphertext header + Span ciphertextHeader = stackalloc byte[security.HeaderCrypt.HeaderCiphertextSize]; + + // Read header + int read; + if (ciphertextStream.CanSeek && ciphertextStream.Position != 0L) + { + var ciphertextPosition = ciphertextStream.Position; + ciphertextStream.Position = 0L; + + read = ciphertextStream.Read(ciphertextHeader); + ciphertextStream.Position = ciphertextPosition; + } + else + { + // Non-seekable streams must be at position 0 - header is always read first sequentially. + // There is no way to rewind, so we simply read and continue. + read = ciphertextStream.Read(ciphertextHeader); + } + + // Check if the read amount is correct + if (read < ciphertextHeader.Length) + return false; + + // Decrypt header + headerBuffer.IsHeaderReady = security.HeaderCrypt.DecryptHeader(ciphertextHeader, headerBuffer); + + return headerBuffer.IsHeaderReady; } - - // Check if the read amount is correct - if (read < ciphertextHeader.Length) - return false; - - // Decrypt header - headerBuffer.IsHeaderReady = security.HeaderCrypt.DecryptHeader(ciphertextHeader, headerBuffer); - - return headerBuffer.IsHeaderReady; } } } \ No newline at end of file diff --git a/src/Core/SecureFolderFS.Core.FileSystem/FileNames/NameWithDirectoryId.cs b/src/Core/SecureFolderFS.Core.FileSystem/FileNames/NameWithDirectoryId.cs index 3450ed992..7617c0037 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/FileNames/NameWithDirectoryId.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/FileNames/NameWithDirectoryId.cs @@ -20,7 +20,14 @@ public bool Equals(NameWithDirectoryId? other) if (other is null) return false; - return DirectoryId.AsSpan() == other.DirectoryId.AsSpan() && FileName == other.FileName; + // Compare the Directory ID contents (the span equality operator only compares references) + return DirectoryId.AsSpan().SequenceEqual(other.DirectoryId.AsSpan()) && FileName == other.FileName; + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as NameWithDirectoryId); } /// @@ -29,8 +36,8 @@ public override int GetHashCode() unchecked { var hash = 17; - hash *= 23 + FileName.GetHashCode(); - hash *= 23 + ComputeHash(DirectoryId); + hash = hash * 23 + FileName.GetHashCode(); + hash = hash * 23 + ComputeHash(DirectoryId); return hash; } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Directory.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Directory.cs index d1efc6036..69deac032 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Directory.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Directory.cs @@ -1,10 +1,10 @@ -using OwlCore.Storage; -using SecureFolderFS.Shared.Models; -using SecureFolderFS.Storage.Extensions; using System; using System.IO; using System.Threading; using System.Threading.Tasks; +using OwlCore.Storage; +using SecureFolderFS.Shared.Models; +using SecureFolderFS.Storage.Extensions; namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract { @@ -22,7 +22,8 @@ public static async Task GetDirectoryIdAsync( var directoryIdFile = await folderOfDirectoryId.GetFileByNameAsync(Constants.Names.DIRECTORY_ID_FILENAME, cancellationToken).ConfigureAwait(false); await using var directoryIdStream = await directoryIdFile.OpenStreamAsync(FileAccess.Read, FileShare.Read, cancellationToken).ConfigureAwait(false); - var read = await directoryIdStream.ReadAsync(directoryId, cancellationToken).ConfigureAwait(false); + // A single call to ReadAsync is not guaranteed to fill the buffer + var read = await directoryIdStream.ReadAtLeastAsync(directoryId, Constants.DIRECTORY_ID_SIZE, false, cancellationToken).ConfigureAwait(false); if (read < Constants.DIRECTORY_ID_SIZE) throw new IOException($"The data inside Directory ID file is of incorrect size: {read}."); @@ -53,20 +54,26 @@ public static async Task GetDirectoryIdAsync( var directoryIdFile = await folderOfDirectoryId.GetFileByNameAsync(Constants.Names.DIRECTORY_ID_FILENAME, cancellationToken).ConfigureAwait(false); await using var directoryIdStream = await directoryIdFile.OpenStreamAsync(FileAccess.Read, FileShare.Read, cancellationToken).ConfigureAwait(false); + // A single call to ReadAsync is not guaranteed to fill the buffer int read; if (specifics.DirectoryIdCache.IsAvailable) { cachedId = new(Constants.DIRECTORY_ID_SIZE); - read = await directoryIdStream.ReadAsync(cachedId.Buffer, cancellationToken).ConfigureAwait(false); - specifics.DirectoryIdCache.CacheSet(Path.Combine(folderOfDirectoryId.Id, Constants.Names.DIRECTORY_ID_FILENAME), cachedId); + read = await directoryIdStream.ReadAtLeastAsync(cachedId.Buffer, Constants.DIRECTORY_ID_SIZE, false, cancellationToken).ConfigureAwait(false); + // Validate the Directory ID before it is added to the cache + if (read < Constants.DIRECTORY_ID_SIZE) + throw new IOException($"The data inside Directory ID file is of incorrect size: {read}."); + + specifics.DirectoryIdCache.CacheSet(Path.Combine(folderOfDirectoryId.Id, Constants.Names.DIRECTORY_ID_FILENAME), cachedId); cachedId.Buffer.CopyTo(directoryId); } else - read = await directoryIdStream.ReadAsync(directoryId, cancellationToken).ConfigureAwait(false); - - if (read < Constants.DIRECTORY_ID_SIZE) - throw new IOException($"The data inside Directory ID file is of incorrect size: {read}."); + { + read = await directoryIdStream.ReadAtLeastAsync(directoryId, Constants.DIRECTORY_ID_SIZE, false, cancellationToken).ConfigureAwait(false); + if (read < Constants.DIRECTORY_ID_SIZE) + throw new IOException($"The data inside Directory ID file is of incorrect size: {read}."); + } // The Directory ID is not empty - return true return true; diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Directory.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Directory.cs index 31dbb8f62..75533c37b 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Directory.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Directory.cs @@ -24,12 +24,13 @@ public static bool GetDirectoryId(string ciphertextFolderPath, FileSystemSpecifi return true; } - // Read the Directory ID from the file + // Read the Directory ID from the file. + // A single call to ReadAsync is not guaranteed to fill the buffer using var directoryIdStream = File.Open(directoryIdPath, FileMode.Open, FileAccess.Read, FileShare.Read); if (specifics.DirectoryIdCache.IsAvailable) { cachedDirectoryId = new(Constants.DIRECTORY_ID_SIZE); - var read = directoryIdStream.Read(cachedDirectoryId); + var read = directoryIdStream.ReadAtLeast(cachedDirectoryId.Buffer, Constants.DIRECTORY_ID_SIZE, false); if (read < Constants.DIRECTORY_ID_SIZE) throw new IOException($"The data inside the Directory ID file is of incorrect size: {read}."); @@ -38,7 +39,7 @@ public static bool GetDirectoryId(string ciphertextFolderPath, FileSystemSpecifi } else { - var read = directoryIdStream.Read(directoryId); + var read = directoryIdStream.ReadAtLeast(directoryId, Constants.DIRECTORY_ID_SIZE, false); if (read < Constants.DIRECTORY_ID_SIZE) throw new IOException($"The data inside the Directory ID file is of incorrect size: {read}."); } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.cs index 63805511c..463cc06fd 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.cs @@ -1,4 +1,4 @@ -using System.IO; +using System; namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Native { @@ -9,7 +9,13 @@ public static partial class NativePathHelpers { public static string MakeRelative(string fullPath, string basePath) { - return PathHelpers.EnsureNoLeadingPathSeparator(Path.DirectorySeparatorChar + fullPath.Replace(basePath, string.Empty)); + // Only strip the base path when it is an actual prefix of the full path. + // Replacing all occurrences could corrupt paths that contain the base path elsewhere + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (fullPath.StartsWith(basePath, comparison)) + fullPath = fullPath.Substring(basePath.Length); + + return PathHelpers.EnsureNoLeadingPathSeparator(fullPath); } } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/PathHelpers.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/PathHelpers.cs index 3e939fd5e..fbd66a0d3 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/PathHelpers.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/PathHelpers.cs @@ -32,7 +32,7 @@ public static string EnsureNoLeadingPathSeparator(string path) .Select(item => $"{item}:") .FirstOrDefault(); } - else if (OperatingSystem.IsMacCatalyst() || OperatingSystem.IsMacOS()) + else if (OperatingSystem.IsMacOS()) { return $"{Path.DirectorySeparatorChar}{Path.Combine("Volumes", nameHint)}{Path.DirectorySeparatorChar}"; } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs index a284f7255..f61894f32 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs @@ -54,7 +54,7 @@ public virtual async Task RenameAsync(IStorableChild storable, s var newCiphertextName = await AbstractPathHelpers.EncryptNameAsync(newName, Inner, specifics, cancellationToken); var renamedCiphertextItem = await renamableFolder.RenameAsync(ciphertextItem, newCiphertextName, cancellationToken); - var plaintextId = Path.Combine(Inner.Id, newName); + var plaintextId = Path.Combine(Id, newName); return renamedCiphertextItem switch { IFile file => new CryptoFile(plaintextId, file, specifics, this), diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoStorable.cs b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoStorable.cs index eb645ab0f..e3dbe9620 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoStorable.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoStorable.cs @@ -44,8 +44,8 @@ protected CryptoStorable(string plaintextId, TCapability inner, FileSystemSpecif if (Inner is not IStorableChild storableChild) throw new NotSupportedException("Retrieving the parent folder is not supported."); - // Make sure we don't go outside the root - if (storableChild.Id == specifics.ContentFolder.Id || !specifics.ContentFolder.Id.Contains(storableChild.Id)) + // Make sure we don't go outside the root (the item must be located inside the content folder) + if (storableChild.Id == specifics.ContentFolder.Id || !storableChild.Id.StartsWith(specifics.ContentFolder.Id, StringComparison.Ordinal)) return null; var ciphertextParent = await storableChild.GetParentAsync(cancellationToken); diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoSizeOfProperty.cs b/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoSizeOfProperty.cs index 76c34f7e9..ff5184d85 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoSizeOfProperty.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Storage/StorageProperties/CryptoSizeOfProperty.cs @@ -1,3 +1,4 @@ +using System; using System.Threading; using System.Threading.Tasks; using SecureFolderFS.Storage.StorageProperties; @@ -34,7 +35,8 @@ public CryptoSizeOfProperty(string id, FileSystemSpecifics specifics, ISizeOfPro if (ciphertextSize is null) return null; - return _specifics.Security.ContentCrypt.CalculatePlaintextSize(ciphertextSize.Value - _specifics.Security.HeaderCrypt.HeaderCiphertextSize); + // Clamp to zero in case a new file may not have its header written yet + return _specifics.Security.ContentCrypt.CalculatePlaintextSize(Math.Max(0L, ciphertextSize.Value - _specifics.Security.HeaderCrypt.HeaderCiphertextSize)); } } } \ No newline at end of file diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs b/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs index 65ea8bf2d..ec776929c 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Streams/PlaintextStream.cs @@ -1,4 +1,9 @@ -using SecureFolderFS.Core.Cryptography; +using System; +using System.Buffers; +using System.IO; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Core.FileSystem.Buffers; using SecureFolderFS.Core.FileSystem.Chunks; using SecureFolderFS.Core.FileSystem.Extensions; @@ -6,11 +11,6 @@ using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Helpers; using SecureFolderFS.Storage.VirtualFileSystem; -using System; -using System.IO; -using System.Runtime.CompilerServices; -using System.Security.Cryptography; -using System.Threading; namespace SecureFolderFS.Core.FileSystem.Streams { @@ -21,7 +21,6 @@ internal sealed class PlaintextStream : Stream, IWrapper private readonly ChunkAccess _chunkAccess; private readonly HeaderBuffer _headerBuffer; private readonly Action _notifyStreamClosed; - private readonly Lock _writeLock = new(); private long _length; private long _position; @@ -142,21 +141,35 @@ public override void Write(ReadOnlySpan buffer) if (CanSeek && Position > Length) { - // Write gap - var gapLength = Position - Length; - - // Generate cryptographically secure random bytes for gap filling - var secureNoise = new byte[gapLength]; - RandomNumberGenerator.Fill(secureNoise); + // Fill the gap between the current length and the write position with zeros. + // Zeros keep sparse semantics consistent with SetLength-based extension, + // where a zeroed region also reads back as zeros. + var writePosition = Position; + var gapBuffer = ArrayPool.Shared.Rent(_security.ContentCrypt.ChunkPlaintextSize); + try + { + Array.Clear(gapBuffer, 0, gapBuffer.Length); + + var gapPosition = Length; + while (gapPosition < writePosition) + { + var gapPart = (int)Math.Min(writePosition - gapPosition, gapBuffer.Length); + WriteInternal(gapBuffer.AsSpan(0, gapPart), gapPosition); + gapPosition += gapPart; + } + } + finally + { + ArrayPool.Shared.Return(gapBuffer); + } - // Write contents of a secure noise array - WriteInternal(secureNoise, Length); - } - else - { - // Write contents - WriteInternal(buffer, Position); + // WriteInternal advances the position by the amount written - restore + // it so the actual contents are written at the requested position + _position = writePosition; } + + // Write contents + WriteInternal(buffer, Position); } /// @@ -316,7 +329,9 @@ private bool TryWriteHeader() if (_headerBuffer.IsHeaderReady) return true; - lock (_writeLock) + // The header buffer is shared by all streams of the same file, + // so lock on the buffer's synchronization root + lock (_headerBuffer.SyncRoot) { // Re-check after lock if (_headerBuffer.IsHeaderReady) diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Streams/StreamsAccess.cs b/src/Core/SecureFolderFS.Core.FileSystem/Streams/StreamsAccess.cs index 0ff268594..54660a146 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Streams/StreamsAccess.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Streams/StreamsAccess.cs @@ -31,7 +31,7 @@ public Stream OpenPlaintextStream(string id, Stream wrappedStream, bool takeFail try { // Get or create the encrypted file from the file system - var openCryptFile = _cryptFileManager.TryGet(id) ?? _cryptFileManager.NewCryptFile(id, new HeaderBuffer(_security.HeaderCrypt.HeaderPlaintextSize)); + var openCryptFile = _cryptFileManager.GetOrCreate(id, static security => new HeaderBuffer(security.HeaderCrypt.HeaderPlaintextSize)); // Open a new stream for that file registering the existing ciphertext stream return openCryptFile.OpenStream(wrappedStream); @@ -62,7 +62,7 @@ public Stream OpenPlaintextStream(string id, Stream wrappedStream, Stream? heade try { // Get or create the encrypted file from the file system - var openCryptFile = _cryptFileManager.TryGet(id) ?? _cryptFileManager.NewCryptFile(id, new HeaderBuffer(_security.HeaderCrypt.HeaderPlaintextSize)); + var openCryptFile = _cryptFileManager.GetOrCreate(id, static security => new HeaderBuffer(security.HeaderCrypt.HeaderPlaintextSize)); // Check if the header is ready or if it can be read at a later time var headerBuffer = openCryptFile.HeaderBuffer; diff --git a/src/Core/SecureFolderFS.Core.FileSystem/UniversalCache.cs b/src/Core/SecureFolderFS.Core.FileSystem/UniversalCache.cs index 0b60b3d43..0773921bb 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/UniversalCache.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/UniversalCache.cs @@ -10,6 +10,7 @@ public class UniversalCache : IDisposable protected readonly Dictionary cache; protected readonly IProgress? cacheStatistics; protected readonly object threadLock = new(); + protected readonly int capacity; /// /// Determines whether caching is enabled. @@ -25,6 +26,7 @@ public UniversalCache(int capacity, IProgress? cacheStatistics) { this.cache = capacity < 0 ? new() : new(capacity); this.cacheStatistics = cacheStatistics; + this.capacity = capacity; IsAvailable = capacity < 0 || capacity > 0; } @@ -65,6 +67,14 @@ public virtual bool CacheSet(TKey key, TValue value, bool skipExistingCheck = fa lock (threadLock) { + // Evict an arbitrary entry when the capacity is reached (Dictionary enumeration order is not guaranteed) + if (capacity > 0 && cache.Count >= capacity && !cache.ContainsKey(key)) + { + using var enumerator = cache.Keys.GetEnumerator(); + if (enumerator.MoveNext()) + cache.Remove(enumerator.Current); + } + if (!skipExistingCheck) return cache.TryAdd(key, value); diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/AppModels/StreamedMediaSource.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/AppModels/StreamedMediaSource.cs new file mode 100644 index 000000000..76423b3b9 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/AppModels/StreamedMediaSource.cs @@ -0,0 +1,61 @@ +using Android.Media; +using Stream = System.IO.Stream; + +namespace SecureFolderFS.Core.MobileFS.AppModels +{ + /// + internal sealed class StreamedMediaSource : MediaDataSource + { + private readonly Stream _sourceStream; + + /// + public override long Size + { + get + { + try + { + // The contract specifies -1 when the size is unknown + return _sourceStream.CanSeek ? _sourceStream.Length : -1L; + } + catch (Exception) + { + return -1L; + } + } + } + + public StreamedMediaSource(Stream sourceStream) + { + _sourceStream = sourceStream; + } + + /// + public override int ReadAt(long position, byte[]? buffer, int offset, int size) + { + try + { + if (buffer is null || size <= 0) + return 0; + + _sourceStream.Position = position; + + // The contract specifies -1 for the end of the stream. Returning 0 would + // indicate that no data was read yet, causing the extractor to retry forever + var read = _sourceStream.Read(buffer, offset, size); + return read == 0 ? -1 : read; + } + catch (Exception) + { + // Errors must not propagate across the JNI boundary + return -1; + } + } + + /// + public override void Close() + { + _sourceStream.Dispose(); + } + } +} diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Helpers.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Helpers.cs index cdb2660fe..49ed950a2 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Helpers.cs +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Helpers.cs @@ -1,4 +1,4 @@ -using Android.Database; +using Android.Database; using Android.Provider; using Android.Webkit; using OwlCore.Storage; @@ -18,30 +18,28 @@ private bool AddRoot(MatrixCursor matrix, SafRoot safRoot, int iconRid) if (row is null) return false; - var rootFolderId = GetDocumentIdForStorable(safRoot.StorageRoot.PlaintextRoot, safRoot.RootId); + var rootFolderId = BuildDocumentId(safRoot, safRoot.StorageRoot.PlaintextRoot); row.Add(Root.ColumnRootId, safRoot.RootId); row.Add(Root.ColumnDocumentId, rootFolderId); row.Add(Root.ColumnTitle, safRoot.StorageRoot.Options.VolumeName); row.Add(Root.ColumnIcon, iconRid); - row.Add(Root.ColumnFlags, (int)(DocumentRootFlags.LocalOnly | DocumentRootFlags.SupportsCreate)); + + // SupportsIsChild is required for ACTION_OPEN_DOCUMENT_TREE to work against this root + row.Add(Root.ColumnFlags, (int)(DocumentRootFlags.LocalOnly | DocumentRootFlags.SupportsCreate | DocumentRootFlags.SupportsIsChild)); return true; } - private async Task AddDocumentAsync(MatrixCursor matrix, IStorable storable, string? documentId) + private async Task AddDocumentAsync(MatrixCursor matrix, IStorable storable, SafRoot safRoot, string documentId) { - documentId ??= GetDocumentIdForStorable(storable, null); var row = matrix.NewRow(); if (row is null) return false; - var safRoot = _rootCollection?.GetSafRootForStorable(storable); - if (safRoot is null) - return false; - AddDocumentId(); AddDisplayName(); await AddSizeAsync(); + await AddLastModifiedAsync(); AddMimeType(); AddFlags(); @@ -58,6 +56,15 @@ async Task AddSizeAsync() var size = await file.GetSizeAsync(); row.Add(Document.ColumnSize, size); } + async Task AddLastModifiedAsync() + { + if (storable is not ILastModifiedAt lastModifiedAt) + return; + + var dateModified = await SafetyHelpers.NoFailureAsync(() => lastModifiedAt.LastModifiedAt.GetValueAsync()); + if (dateModified is not null) + row.Add(Document.ColumnLastModified, new DateTimeOffset(dateModified.Value.ToUniversalTime()).ToUnixTimeMilliseconds()); + } void AddFlags() { var baseFlags = (DocumentContractFlags)0; @@ -95,20 +102,47 @@ void AddDisplayName() => row.Add(Document.ColumnDisplayName, string.IsNullOrEmpt : storable.Name); } - private string? GetDocumentIdForStorable(IStorable storable, string? rootId) + /// + /// Builds the document ID (in 'rootId:plaintextPath' format) of under . + /// + private static string BuildDocumentId(SafRoot safRoot, IStorable storable) { - var safRoot = rootId is not null - ? _rootCollection?.GetSafRootForRootId(rootId) - : _rootCollection?.GetSafRootForStorable(storable); - - if (safRoot is null) - return null; - return storable.Id == safRoot.StorageRoot.PlaintextRoot.Id ? $"{safRoot.RootId}:" : $"{safRoot.RootId}:{storable.Id}"; } + /// + /// Gets the document ID of the parent of the item identified by . + /// + private static string? GetParentDocumentId(string documentId) + { + var split = documentId.Split(':', 2); + if (split.Length < 2) + return null; + + var path = split[1].TrimEnd('/'); + var separatorIndex = path.LastIndexOf('/'); + + return separatorIndex <= 0 ? $"{split[0]}:" : $"{split[0]}:{path[..separatorIndex]}"; + } + + /// + /// Resolves the that the document identified by belongs to. + /// + /// + /// The root is always derived from the document ID. It must never be inferred from a storable's + /// path, because plaintext paths are not unique across roots (every root starts at '/'). + /// + private SafRoot? GetSafRootForDocumentId(string documentId) + { + var split = documentId.Split(':', 2); + if (split.Length < 2) + return null; + + return _rootCollection?.GetSafRootForRootId(split[0]); + } + private IStorable? GetStorableForDocumentId(string documentId) { if (_rootCollection is null) @@ -137,6 +171,19 @@ void AddDisplayName() => row.Add(Document.ColumnDisplayName, string.IsNullOrEmpt return SafetyHelpers.NoFailureResult(() => safRoot.StorageRoot.PlaintextRoot.GetItemByRelativePathAsync(path).ConfigureAwait(false).GetAwaiter().GetResult()); } + /// + /// Notifies content observers that the children of have changed. + /// + private void NotifyChildDocumentsChange(string? parentDocumentId) + { + if (parentDocumentId is null) + return; + + var childrenUri = BuildChildDocumentsUri(Constants.Android.FileSystem.AUTHORITY, parentDocumentId); + if (childrenUri is not null) + Context?.ContentResolver?.NotifyChange(childrenUri, null); + } + private static string GetMimeForStorable(IStorable storable) { if (storable is IFolder) @@ -148,7 +195,7 @@ private static string GetMimeForStorable(IStorable storable) return "application/octet-stream"; // Remove the starting dot - return MimeTypeMap.Singleton?.GetMimeTypeFromExtension(extension.Substring(1)) ?? string.Empty; + return MimeTypeMap.Singleton?.GetMimeTypeFromExtension(extension.Substring(1).ToLowerInvariant()) ?? "application/octet-stream"; } } } diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs index f1a6682aa..41369fded 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/FileSystemProvider.Main.cs @@ -1,4 +1,4 @@ -using Android.App; +using Android.App; using Android.Content; using Android.Content.Res; using Android.Database; @@ -8,7 +8,6 @@ using Microsoft.Maui.Platform; using OwlCore.Storage; using SecureFolderFS.Core.MobileFS.Platforms.Android.Helpers; -using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Enums; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Helpers; @@ -16,7 +15,6 @@ using SecureFolderFS.Storage.Renamable; using static SecureFolderFS.Core.MobileFS.Platforms.Android.FileSystem.Projections; using Point = Android.Graphics.Point; -using Uri = Android.Net.Uri; namespace SecureFolderFS.Core.MobileFS.Platforms.Android.FileSystem { @@ -58,6 +56,36 @@ public override bool OnCreate() return matrix; } + /// + public override bool IsChildDocument(string? parentDocumentId, string? documentId) + { + // Required for ACTION_OPEN_DOCUMENT_TREE - the system validates every access + // through a tree grant by checking the ancestry of the target document + parentDocumentId = parentDocumentId == "null" ? null : parentDocumentId; + documentId = documentId == "null" ? null : documentId; + if (parentDocumentId is null || documentId is null) + return false; + + var parentSplit = parentDocumentId.Split(':', 2); + var childSplit = documentId.Split(':', 2); + if (parentSplit.Length < 2 || childSplit.Length < 2) + return false; + + // Both documents must belong to the same root + if (parentSplit[0] != childSplit[0]) + return false; + + var parentPath = parentSplit[1].TrimEnd('/'); + var childPath = childSplit[1]; + + // The root folder is an ancestor of every document within it + if (parentPath.Length == 0) + return true; + + return childPath.StartsWith(parentPath, StringComparison.Ordinal) + && (childPath.Length == parentPath.Length || childPath[parentPath.Length] == '/'); + } + /// public override string? CreateDocument(string? parentDocumentId, string? mimeType, string? displayName) { @@ -69,19 +97,28 @@ public override bool OnCreate() if (parentStorable is not IModifiableFolder parentFolder) return null; - var safRoot = _rootCollection?.GetSafRootForStorable(parentStorable); + var safRoot = GetSafRootForDocumentId(parentDocumentId); if (safRoot is null) return null; if (safRoot.StorageRoot.Options.IsReadOnly) return null; + // De-duplicate the display name, as creating over an existing document + // would return the existing item and its contents would get overwritten + var finalName = displayName; + var counter = 1; + while (counter < 32 && parentFolder.TryGetFirstByNameAsync(finalName).ConfigureAwait(false).GetAwaiter().GetResult() is not null) + finalName = $"{Path.GetFileNameWithoutExtension(displayName)} ({counter++}){Path.GetExtension(displayName)}"; + var createdItem = (IStorableChild)(mimeType switch { - DocumentsContract.Document.MimeTypeDir => parentFolder.CreateFolderAsync(displayName).ConfigureAwait(false).GetAwaiter().GetResult(), - _ => parentFolder.CreateFileAsync(displayName).ConfigureAwait(false).GetAwaiter().GetResult() + DocumentsContract.Document.MimeTypeDir => parentFolder.CreateFolderAsync(finalName).ConfigureAwait(false).GetAwaiter().GetResult(), + _ => parentFolder.CreateFileAsync(finalName).ConfigureAwait(false).GetAwaiter().GetResult() }); + NotifyChildDocumentsChange(parentDocumentId); + var rootId = parentDocumentId.Split(':', 2)[0]; return $"{rootId}:{createdItem.Id}"; } @@ -95,11 +132,11 @@ public override bool OnCreate() var file = GetStorableForDocumentId(documentId); if (file is not IChildFile childFile) - return null; + throw new Java.IO.FileNotFoundException($"No document found for '{documentId}'."); - var safRoot = _rootCollection?.GetSafRootForStorable(file); + var safRoot = GetSafRootForDocumentId(documentId); if (safRoot is null) - return null; + throw new Java.IO.FileNotFoundException($"No root found for '{documentId}'."); var fileAccess = ToFileAccess(mode); if (safRoot.StorageRoot.Options.IsReadOnly && fileAccess.HasFlag(FileAccess.Write)) @@ -109,24 +146,30 @@ public override bool OnCreate() if (stream is null) return null; - var parcelFileMode = ToParcelFileMode(mode); - if (safRoot.StorageRoot.Options.IsReadOnly && parcelFileMode is ParcelFileMode.WriteOnly or ParcelFileMode.ReadWrite) - return null; + // The 'w' family of modes replaces the existing content + if (IsTruncateMode(mode)) + stream.SetLength(0L); var handlerThread = new HandlerThread("ProxyFD-" + documentId); handlerThread.Start(); return _storageManager.OpenProxyFileDescriptor( - parcelFileMode, + ToParcelFileMode(mode), new ReadWriteCallbacks(stream, handlerThread), new Handler(handlerThread.Looper!)); + static bool IsTruncateMode(string? fileMode) + { + return fileMode is "w" or "wt" or "rwt"; + } + static ParcelFileMode ToParcelFileMode(string? fileMode) { return fileMode switch { "r" => ParcelFileMode.ReadOnly, - "w" => ParcelFileMode.WriteOnly, - "rw" => ParcelFileMode.ReadWrite, + "w" or "wt" => ParcelFileMode.WriteOnly, + "wa" => ParcelFileMode.WriteOnly | ParcelFileMode.Append, + "rw" or "rwt" => ParcelFileMode.ReadWrite, _ => throw new ArgumentException($"Unsupported mode: {fileMode}.") }; } @@ -136,8 +179,9 @@ static FileAccess ToFileAccess(string? fileMode) return fileMode switch { "r" => FileAccess.Read, - "w" => FileAccess.Write, - "rw" => FileAccess.ReadWrite, + // Writes require read access as well (encrypted chunks are read-modify-write) + "w" or "wt" or "wa" => FileAccess.ReadWrite, + "rw" or "rwt" => FileAccess.ReadWrite, _ => throw new ArgumentException($"Unsupported mode: {fileMode}.") }; } @@ -152,10 +196,11 @@ static FileAccess ToFileAccess(string? fileMode) return matrix; var storable = GetStorableForDocumentId(documentId); - if (storable is null) - return matrix; + var safRoot = GetSafRootForDocumentId(documentId); + if (storable is null || safRoot is null) + throw new Java.IO.FileNotFoundException($"No document found for '{documentId}'."); - AddDocumentAsync(matrix, storable, documentId).ConfigureAwait(false).GetAwaiter().GetResult(); + AddDocumentAsync(matrix, storable, safRoot, documentId).ConfigureAwait(false).GetAwaiter().GetResult(); return matrix; } @@ -168,15 +213,24 @@ static FileAccess ToFileAccess(string? fileMode) return matrix; var parent = GetStorableForDocumentId(parentDocumentId); - if (parent is not IFolder folder) - return matrix; + var safRoot = GetSafRootForDocumentId(parentDocumentId); + if (parent is not IFolder folder || safRoot is null) + throw new Java.IO.FileNotFoundException($"No document found for '{parentDocumentId}'."); var items = folder.GetItemsAsync().ToArrayAsyncImpl().ConfigureAwait(false).GetAwaiter().GetResult(); foreach (var item in items) { - AddDocumentAsync(matrix, item, null).ConfigureAwait(false).GetAwaiter().GetResult(); + // Build the child ID from the parent's root. Inferring the root from + // the item is ambiguous when multiple vaults are unlocked + var childDocumentId = BuildDocumentId(safRoot, item); + AddDocumentAsync(matrix, item, safRoot, childDocumentId).ConfigureAwait(false).GetAwaiter().GetResult(); } + // Register for change notifications so file managers refresh automatically + var childrenUri = DocumentsContract.BuildChildDocumentsUri(Constants.Android.FileSystem.AUTHORITY, parentDocumentId); + if (childrenUri is not null && Context?.ContentResolver is { } contentResolver) + matrix.SetNotificationUri(contentResolver, childrenUri); + return matrix; } @@ -195,9 +249,9 @@ public override void DeleteDocument(string? documentId) var storable = GetStorableForDocumentId(documentId); if (storable is not IStorableChild storableChild) - return; + throw new Java.IO.FileNotFoundException($"No document found for '{documentId}'."); - var safRoot = _rootCollection?.GetSafRootForStorable(storable); + var safRoot = GetSafRootForDocumentId(documentId); if (safRoot is null) return; @@ -213,6 +267,7 @@ public override void DeleteDocument(string? documentId) // Perform deletion modifiableFolder.DeleteAsync(storableChild).ConfigureAwait(false).GetAwaiter().GetResult(); + NotifyChildDocumentsChange(GetParentDocumentId(documentId)); } /// @@ -228,7 +283,7 @@ public override void DeleteDocument(string? documentId) if (destinationStorable is not IModifiableFolder destinationFolder) return null; - var safRoot = _rootCollection?.GetSafRootForStorable(destinationStorable); + var safRoot = GetSafRootForDocumentId(targetParentDocumentId); if (safRoot is null) return null; @@ -245,13 +300,19 @@ public override void DeleteDocument(string? documentId) case IChildFile file: { var movedFile = destinationFolder.MoveFileImmediatelyFrom(file, sourceParentFolder, false, CancellationToken.None).ConfigureAwait(false).GetAwaiter().GetResult(); - return Path.Combine(targetParentDocumentId, movedFile.Name); + NotifyChildDocumentsChange(sourceParentDocumentId); + NotifyChildDocumentsChange(targetParentDocumentId); + + return BuildDocumentId(safRoot, movedFile); } case IModifiableFolder folder: { var movedFolder = destinationFolder.MoveFromAsync(folder, sourceParentFolder, false, CancellationToken.None).ConfigureAwait(false).GetAwaiter().GetResult(); - return Path.Combine(targetParentDocumentId, movedFolder.Name); + NotifyChildDocumentsChange(sourceParentDocumentId); + NotifyChildDocumentsChange(targetParentDocumentId); + + return BuildDocumentId(safRoot, movedFolder); } default: return null; @@ -270,9 +331,9 @@ public override void DeleteDocument(string? documentId) var storable = GetStorableForDocumentId(documentId); if (storable is not IStorableChild storableChild) - return null; + throw new Java.IO.FileNotFoundException($"No document found for '{documentId}'."); - var safRoot = _rootCollection?.GetSafRootForStorable(storable); + var safRoot = GetSafRootForDocumentId(documentId); if (safRoot is null) return null; @@ -284,13 +345,10 @@ public override void DeleteDocument(string? documentId) return null; var renamedItem = renamableFolder.RenameAsync(storableChild, displayName).ConfigureAwait(false).GetAwaiter().GetResult(); - if (renamedItem is IWrapper { Inner: IWrapper fileUriWrapper }) - return fileUriWrapper.Inner.ToString(); - - if (renamedItem is IWrapper { Inner: IWrapper folderUriWrapper }) - return folderUriWrapper.Inner.ToString(); + NotifyChildDocumentsChange(GetParentDocumentId(documentId)); - throw new InvalidOperationException($"{nameof(renamedItem)} does not implement {nameof(IWrapper)}."); + // The contract expects the new document ID (never an underlying URI) + return BuildDocumentId(safRoot, renamedItem); } /// @@ -305,7 +363,7 @@ public override void DeleteDocument(string? documentId) if (destinationStorable is not IModifiableFolder destinationFolder) return null; - var safRoot = _rootCollection?.GetSafRootForStorable(destinationStorable); + var safRoot = GetSafRootForDocumentId(targetParentDocumentId); if (safRoot is null) return null; @@ -318,13 +376,17 @@ public override void DeleteDocument(string? documentId) case IFile file: { var copiedFile = destinationFolder.CreateCopyOfAsync(file, false).ConfigureAwait(false).GetAwaiter().GetResult(); - return Path.Combine(targetParentDocumentId, copiedFile.Name); + NotifyChildDocumentsChange(targetParentDocumentId); + + return BuildDocumentId(safRoot, copiedFile); } case IFolder folder: { var copiedFolder = destinationFolder.CreateCopyOfAsync(folder, false).ConfigureAwait(false).GetAwaiter().GetResult(); - return Path.Combine(targetParentDocumentId, copiedFolder.Name); + NotifyChildDocumentsChange(targetParentDocumentId); + + return BuildDocumentId(safRoot, copiedFolder); } default: return null; @@ -348,13 +410,26 @@ public override void DeleteDocument(string? documentId) try { + if (signal?.IsCanceled ?? false) + return null; + // Honor sizeHint from the caller instead of always using a fixed size var size = sizeHint is not null ? (uint)Math.Max(sizeHint.X, sizeHint.Y) : 300U; + // Capture at one second rather than the very first frame, which is often + // black (fade-ins). ClosestSync clamps the position for shorter videos using var inputStream = file.OpenReadAsync().ConfigureAwait(false).GetAwaiter().GetResult(); - var thumbnailStream = ThumbnailHelpers.GenerateImageThumbnailAsync(inputStream, size).ConfigureAwait(false).GetAwaiter().GetResult(); + var thumbnailStream = typeHint is TypeHint.Media + ? ThumbnailHelpers.GenerateVideoThumbnailAsync(inputStream, TimeSpan.FromSeconds(1), (int)size, (int)size).ConfigureAwait(false).GetAwaiter().GetResult() + : ThumbnailHelpers.GenerateImageThumbnailAsync(inputStream, size).ConfigureAwait(false).GetAwaiter().GetResult(); + + if (signal?.IsCanceled ?? false) + { + thumbnailStream.Dispose(); + return null; + } var twoWayPipe = ParcelFileDescriptor.CreatePipe(); if (twoWayPipe is null) diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/Projections.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/Projections.cs index a72ccd5f5..cb0dd2c75 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/Projections.cs +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/Projections.cs @@ -19,6 +19,7 @@ internal static class Projections Document.ColumnDisplayName, Document.ColumnMimeType, Document.ColumnSize, + Document.ColumnLastModified, Document.ColumnFlags ]; } diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/ReadWriteCallbacks.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/ReadWriteCallbacks.cs index d54b63028..ccc32b923 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/ReadWriteCallbacks.cs +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/ReadWriteCallbacks.cs @@ -1,4 +1,5 @@ using Android.OS; +using Android.Systems; namespace SecureFolderFS.Core.MobileFS.Platforms.Android.FileSystem { @@ -16,7 +17,14 @@ public ReadWriteCallbacks(Stream stream, HandlerThread? handlerThread = null) /// public override long OnGetSize() { - return _stream.Length; + try + { + return _stream.Length; + } + catch (Exception) + { + throw new ErrnoException(nameof(OnGetSize), OsConstants.Eio); + } } /// @@ -35,9 +43,8 @@ public override int OnRead(long offset, int size, byte[]? data) } catch (Exception) { - // TODO: Implement more exception handlers - return 0; - //throw new ErrnoException(nameof(OnRead), OsConstants.Eio); + // Returning 0 would signal EOF and silently truncate the read + throw new ErrnoException(nameof(OnRead), OsConstants.Eio); } } @@ -57,12 +64,10 @@ public override int OnWrite(long offset, int size, byte[]? data) return size; } - catch (Exception ex) + catch (Exception) { - // TODO: Implement more exception handlers - _ = ex; - return 0; - //throw new ErrnoException(nameof(OnRead), OsConstants.Eio); + // Returning 0 would signal a successful zero-byte write and silently lose data + throw new ErrnoException(nameof(OnWrite), OsConstants.Eio); } } @@ -71,13 +76,12 @@ public override void OnFsync() { try { - _stream.Flush(); + if (_stream.CanWrite) + _stream.Flush(); } - catch (Exception ex) + catch (Exception) { - _ = ex; - // TODO: Implement more exception handlers - //throw new ErrnoException(nameof(OnRead), OsConstants.Eio); + throw new ErrnoException(nameof(OnFsync), OsConstants.Eio); } } diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/RootCollection.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/RootCollection.cs index 492d6e7f9..943ae033d 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/RootCollection.cs +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/FileSystem/RootCollection.cs @@ -1,6 +1,5 @@ using Android.Content; using Android.Provider; -using OwlCore.Storage; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Storage.VirtualFileSystem; using System.Collections.Specialized; @@ -12,13 +11,26 @@ public sealed class RootCollection : IDisposable public static RootCollection? Instance { get; private set; } private readonly Context _context; - - public List Roots { get; } + private readonly List _roots; + private readonly object _rootsLock = new(); + + /// + /// The returned collection is a point-in-time snapshot. Roots are added and removed + /// on vault lock/unlock while provider binder threads enumerate them concurrently. + /// + public IReadOnlyList Roots + { + get + { + lock (_rootsLock) + return _roots.ToArray(); + } + } public RootCollection(Context context) { _context = context; - Roots = new(); + _roots = new(); Instance = this; FileSystemManager.Instance.FileSystems.CollectionChanged += FileSystemManager_CollectionChanged; @@ -26,18 +38,8 @@ public RootCollection(Context context) public SafRoot? GetSafRootForRootId(string rootId) { - return Roots.FirstOrDefault(x => x.RootId == rootId); - } - - public SafRoot? GetSafRootForStorable(IStorable storable) - { - foreach (var safRoot in Roots) - { - if (storable.Id.StartsWith(safRoot.StorageRoot.PlaintextRoot.Id)) - return safRoot; - } - - return null; + lock (_rootsLock) + return _roots.FirstOrDefault(x => x.RootId == rootId); } private void FileSystemManager_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) @@ -50,9 +52,10 @@ private void FileSystemManager_CollectionChanged(object? sender, NotifyCollectio return; // Add to available roots - Roots.Add(new(storageRoot, Guid.NewGuid().ToString())); - NotifySafChange(); + lock (_rootsLock) + _roots.Add(new(storageRoot, Guid.NewGuid().ToString())); + NotifySafChange(); break; } @@ -62,9 +65,10 @@ private void FileSystemManager_CollectionChanged(object? sender, NotifyCollectio return; // Remove from available roots - Roots.RemoveMatch(x => x.StorageRoot == storageRoot); - NotifySafChange(); + lock (_rootsLock) + _roots.RemoveMatch(x => x.StorageRoot == storageRoot); + NotifySafChange(); break; } } diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Helpers/ThumbnailHelpers.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Helpers/ThumbnailHelpers.cs index be5870d16..f9e07dc78 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Helpers/ThumbnailHelpers.cs +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Helpers/ThumbnailHelpers.cs @@ -1,5 +1,6 @@ using Android.Graphics; using Android.Media; +using SecureFolderFS.Core.MobileFS.AppModels; using SecureFolderFS.Storage.Streams; using ExifInterface = AndroidX.ExifInterface.Media.ExifInterface; using Stream = System.IO.Stream; @@ -8,6 +9,14 @@ namespace SecureFolderFS.Core.MobileFS.Platforms.Android.Helpers { public static class ThumbnailHelpers { + private const int IMAGE_THUMBNAIL_QUALITY = 70; + + /// + /// Generates a scaled-down thumbnail from the provided image file stream with a maximum size constraint. + /// + /// The image file stream from which the thumbnail is generated. + /// The maximum size (width or height) that the thumbnail should not exceed. + /// A that represents the asynchronous operation. Value is a stream containing the compressed thumbnail image. public static async Task GenerateImageThumbnailAsync(Stream stream, uint maxSize) { // Read EXIF @@ -46,8 +55,43 @@ public static async Task GenerateImageThumbnailAsync(Stream stream, uint if (bitmap is null) throw new Exception("Failed to decode image."); - using var rotated = ApplyExifOrientation(bitmap, exif); - return await CompressBitmapAsync(rotated).ConfigureAwait(false); + // ApplyExifOrientation returns the same instance when no transformation is + // needed - only dispose the result when a new bitmap was actually created + var oriented = ApplyExifOrientation(bitmap, exif); + try + { + return await CompressBitmapAsync(oriented).ConfigureAwait(false); + } + finally + { + if (!ReferenceEquals(oriented, bitmap)) + oriented.Dispose(); + } + } + + /// + /// Generates a video thumbnail as a compressed stream by capturing a frame at the specified timestamp. + /// + /// The video file stream from which the thumbnail is generated. + /// The time position in the video to capture the thumbnail frame. + /// The width of the thumbnail image. + /// The height of the thumbnail image. + /// A that represents the asynchronous operation. Value is a stream containing the compressed thumbnail image. + public static async Task GenerateVideoThumbnailAsync(Stream stream, TimeSpan captureTime, int width = 320, int height = 240) + { + using var retriever = new MediaMetadataRetriever(); + await retriever.SetDataSourceAsync(new StreamedMediaSource(stream)).ConfigureAwait(false); + + // The retriever expects the timestamp in microseconds, not in ticks (100ns units). + // ClosestSync clamps to the nearest sync frame, so a capture time past the end is safe + var captureTimeUs = captureTime.Ticks / TimeSpan.TicksPerMicrosecond; + + // Use scaled frame for efficiency + using var bitmap = retriever.GetScaledFrameAtTime(captureTimeUs, Option.ClosestSync, width, height); + if (bitmap is null) + throw new NotSupportedException("Could not retrieve scaled frame."); + + return await CompressBitmapAsync(bitmap).ConfigureAwait(false); } private static int CalculateInSampleSize(int width, int height, int reqSize) @@ -59,10 +103,17 @@ private static int CalculateInSampleSize(int width, int height, int reqSize) return inSampleSize; } + /// + /// Applies the EXIF orientation to . + /// + /// + /// The caller retains ownership of . When a transformation is + /// applied, a new bitmap (owned by the caller) is returned; otherwise the same instance. + /// private static Bitmap ApplyExifOrientation(Bitmap bitmap, ExifInterface exif) { var orientation = (Orientation)exif.GetAttributeInt(ExifInterface.TagOrientation, (int)Orientation.Normal); - var matrix = new Matrix(); + using var matrix = new Matrix(); switch (orientation) { @@ -103,19 +154,18 @@ private static Bitmap ApplyExifOrientation(Bitmap bitmap, ExifInterface exif) return bitmap; } - var rotated = Bitmap.CreateBitmap(bitmap, 0, 0, bitmap.Width, bitmap.Height, matrix, true); - bitmap.Dispose(); - return rotated; + return Bitmap.CreateBitmap(bitmap, 0, 0, bitmap.Width, bitmap.Height, matrix, true); } - public static async Task CompressBitmapAsync(Bitmap bitmap) + /// + /// Compresses into an image stream. The caller retains ownership of the bitmap. + /// + private static async Task CompressBitmapAsync(Bitmap bitmap) { - const int IMAGE_THUMBNAIL_QUALITY = 70; - + var format = bitmap.HasAlpha ? Bitmap.CompressFormat.Png! : Bitmap.CompressFormat.Jpeg!; var memoryStream = new MemoryStream(); - await bitmap.CompressAsync(Bitmap.CompressFormat.Jpeg!, IMAGE_THUMBNAIL_QUALITY, memoryStream).ConfigureAwait(false); + await bitmap.CompressAsync(format, IMAGE_THUMBNAIL_QUALITY, memoryStream).ConfigureAwait(false); memoryStream.Position = 0L; - bitmap.Dispose(); return new NonDisposableStream(memoryStream); } diff --git a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Streams/ChannelledStream.cs b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Streams/ChannelledStream.cs index bc5a32813..6a8c6728a 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Streams/ChannelledStream.cs +++ b/src/Core/SecureFolderFS.Core.MobileFS/Platforms/Android/Streams/ChannelledStream.cs @@ -49,11 +49,19 @@ public override int Read(byte[] buffer, int offset, int count) if (offset < 0 || count < 0 || offset + count > buffer.Length) throw new ArgumentOutOfRangeException(); - var byteBuffer = ByteBuffer.Wrap(buffer, offset, count); + // Do NOT use ByteBuffer.Wrap(buffer) here. Managed arrays are marshaled to Java + // by copy, and the copy-back into the managed array happens when the invoked + // method (Wrap) returns, even before the channel has read anything. The channel would + // fill the retained Java-side copy while the managed buffer stays untouched. + using var byteBuffer = ByteBuffer.Allocate(count); var read = _inputChannel.Read(byteBuffer); - if (read < 0) + if (read <= 0) return 0; + // Copy the data back into the managed buffer + byteBuffer.Flip(); + byteBuffer.Get(buffer, offset, read); + return read; } @@ -66,8 +74,13 @@ public override void Write(byte[] buffer, int offset, int count) if (offset < 0 || count < 0 || offset + count > buffer.Length) throw new ArgumentOutOfRangeException(); - var byteBuffer = ByteBuffer.Wrap(buffer, offset, count); - _outputChannel!.Write(byteBuffer); + // Wrap is safe for writing - the managed data is copied into the Java-side + // array when Wrap is invoked, which is all the write direction needs + using var byteBuffer = ByteBuffer.Wrap(buffer, offset, count); + + // A single write is not guaranteed to consume the whole buffer + while (byteBuffer.HasRemaining) + _outputChannel!.Write(byteBuffer); } /// diff --git a/src/Core/SecureFolderFS.Core.MobileFS/SecureFolderFS.Core.MobileFS.csproj b/src/Core/SecureFolderFS.Core.MobileFS/SecureFolderFS.Core.MobileFS.csproj index dfc1bf271..e28495492 100644 --- a/src/Core/SecureFolderFS.Core.MobileFS/SecureFolderFS.Core.MobileFS.csproj +++ b/src/Core/SecureFolderFS.Core.MobileFS/SecureFolderFS.Core.MobileFS.csproj @@ -20,10 +20,4 @@ - - - false - - - diff --git a/src/Core/SecureFolderFS.Core.WebDav/AppModels/WebDavOptions.cs b/src/Core/SecureFolderFS.Core.WebDav/AppModels/WebDavOptions.cs index 2dbef30de..fccac09d4 100644 --- a/src/Core/SecureFolderFS.Core.WebDav/AppModels/WebDavOptions.cs +++ b/src/Core/SecureFolderFS.Core.WebDav/AppModels/WebDavOptions.cs @@ -46,7 +46,7 @@ public static WebDavOptions ToOptions(IDictionary options) 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)) ?? true, + IsCachingFileNames = (bool?)options.Get(nameof(IsCachingFileNames)) ?? false, IsCachingDirectoryIds = (bool?)options.Get(nameof(IsCachingDirectoryIds)) ?? true, RecycleBinSize = (long?)options.Get(nameof(RecycleBinSize)) ?? 0L, diff --git a/src/Core/SecureFolderFS.Core.WebDav/Helpers/PortHelpers.cs b/src/Core/SecureFolderFS.Core.WebDav/Helpers/PortHelpers.cs index 4654f3639..6e2f04cb4 100644 --- a/src/Core/SecureFolderFS.Core.WebDav/Helpers/PortHelpers.cs +++ b/src/Core/SecureFolderFS.Core.WebDav/Helpers/PortHelpers.cs @@ -1,36 +1,98 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Net.NetworkInformation; +using System.Net.Sockets; namespace SecureFolderFS.Core.WebDav.Helpers { internal static class PortHelpers { + /// + /// Checks if the specified is available for binding by attempting + /// to listen on it or verifying it is not in use. + /// + /// The port number to check for availability. + /// + /// true if the port is available for use; otherwise, false. + /// public static bool IsPortAvailable(int port) { - return !GetUnavailablePorts().Contains(port); + if (OperatingSystem.IsAndroid()) + return CanBindPort(port); + + try + { + return !GetUnavailablePorts().Contains(port); + } + catch (Exception) + { + // Enumeration may be unsupported on this platform + return CanBindPort(port); + } } + /// + /// Finds and returns the next available network port starting from the specified . + /// + /// The port number from which to start searching for an available port. + /// The next available port number. public static int GetNextAvailablePort(int startingPort) { - var unavailablePorts = GetUnavailablePorts().ToList(); + if (!OperatingSystem.IsAndroid()) + { + try + { + var unavailablePorts = GetUnavailablePorts().ToHashSet(); + for (var i = startingPort; i <= ushort.MaxValue; i++) + { + if (!unavailablePorts.Contains(i)) + return i; + } + + throw new InvalidOperationException("No available ports."); + } + catch (Exception ex) when (ex is not InvalidOperationException) + { + // Enumeration is unsupported on this platform + } + } + for (var i = startingPort; i <= ushort.MaxValue; i++) - if (!unavailablePorts.Contains(i)) + { + if (CanBindPort(i)) return i; + } throw new InvalidOperationException("No available ports."); } - private static IEnumerable GetUnavailablePorts() + /// + /// Determines whether can be bound by attempting to listen on it. + /// Works on every platform, including those where enumeration is unavailable (e.g. Android). + /// + private static bool CanBindPort(int port) { - if (OperatingSystem.IsAndroid()) - return Enumerable.Empty(); // TODO(u android) + try + { + using var listener = new TcpListener(IPAddress.Loopback, port); + listener.Start(); + listener.Stop(); + return true; + } + catch (SocketException) + { + return false; + } + } + private static IEnumerable GetUnavailablePorts() + { var properties = IPGlobalProperties.GetIPGlobalProperties(); return properties.GetActiveTcpConnections().Select(x => x.LocalEndPoint.Port) .Concat(properties.GetActiveTcpListeners().Select(x => x.Port)) .Concat(properties.GetActiveUdpListeners().Select(x => x.Port)); } } -} \ No newline at end of file +} diff --git a/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs b/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs index e335aae6a..cd982fc1d 100644 --- a/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs +++ b/src/Core/SecureFolderFS.Core.WebDav/WebDavFileSystem.cs @@ -22,6 +22,8 @@ namespace SecureFolderFS.Core.WebDav /// public abstract class WebDavFileSystem : IFileSystemInfo { + private const int MAX_LISTENER_START_ATTEMPTS = 10; + /// public string Id { get; } = Constants.FileSystem.FS_ID; @@ -49,13 +51,12 @@ public virtual async Task MountAsync(IFolder folder, IDisposable unloc if (!PortHelpers.IsPortAvailable(webDavOptions.Port)) webDavOptions.SetPortInternal(PortHelpers.GetNextAvailablePort(webDavOptions.Port)); - var prefix = $"{webDavOptions.Protocol}://{webDavOptions.Domain}:{webDavOptions.Port}/"; - var httpListener = new HttpListener(); - - httpListener.Prefixes.Add(prefix); - httpListener.AuthenticationSchemes = AuthenticationSchemes.Anonymous; + // Start the listener up-front (self-healing onto another port if the bind fails) so a genuine + // failure surfaces to the caller and fails the unlocking operation, instead of leaving a silently-dead server. + // Starting here also fixes webDavOptions.Port to the actually bound port before the platform + // implementation derives the mount URL from it. + var httpListener = StartListener(webDavOptions); - //var store = new EncryptingDiskStore(specifics.ContentFolder.Id, specifics, !specifics.Options.IsReadOnly); var rootFolder = new CryptoFolder(specifics.ContentFolder.Id, specifics.ContentFolder, specifics); var store = new BackedDavStore(rootFolder, !specifics.Options.IsReadOnly); var dispatcher = new WebDavDispatcher(new RootDiskStore(specifics.Options.VolumeName, store), new RequestHandlerProvider(), null); @@ -68,6 +69,38 @@ public virtual async Task MountAsync(IFolder folder, IDisposable unloc cancellationToken); } + /// + /// Creates and starts an for the configured WebDAV endpoint, with a fallback to + /// the next available port if the bind fails (e.g., the port was taken between the availability check and the bind). + /// + /// The WebDAV options. The is updated to the actually bound port. + /// A started bound to the resolved port. + private static HttpListener StartListener(WebDavOptions options) + { + HttpListenerException? lastException = null; + for (var attempt = 0; attempt < MAX_LISTENER_START_ATTEMPTS; attempt++) + { + var listener = new HttpListener(); + listener.Prefixes.Add($"{options.Protocol}://{options.Domain}:{options.Port}/"); + listener.AuthenticationSchemes = AuthenticationSchemes.Anonymous; + + try + { + listener.Start(); + return listener; + } + catch (HttpListenerException ex) + { + // The port became unavailable between the check and the bind, proceed with self-heal onto the next free port + lastException = ex; + listener.Close(); + options.SetPortInternal(PortHelpers.GetNextAvailablePort(options.Port + 1)); + } + } + + throw lastException ?? new HttpListenerException(); + } + /// public abstract Task GetVolumeNameAsync(string candidateName, CancellationToken cancellationToken = default); diff --git a/src/Core/SecureFolderFS.Core.WebDav/WebDavVfsRoot.cs b/src/Core/SecureFolderFS.Core.WebDav/WebDavVfsRoot.cs index 50313765e..b592f778f 100644 --- a/src/Core/SecureFolderFS.Core.WebDav/WebDavVfsRoot.cs +++ b/src/Core/SecureFolderFS.Core.WebDav/WebDavVfsRoot.cs @@ -1,7 +1,7 @@ -using OwlCore.Storage; +using System.Threading.Tasks; +using OwlCore.Storage; using SecureFolderFS.Core.FileSystem; using SecureFolderFS.Storage.VirtualFileSystem; -using System.Threading.Tasks; namespace SecureFolderFS.Core.WebDav { diff --git a/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs b/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs index 267175b94..e1df89d6f 100644 --- a/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs +++ b/src/Core/SecureFolderFS.Core.WebDav/WebDavWrapper.cs @@ -1,16 +1,18 @@ -using NWebDav.Server.Dispatching; -using SecureFolderFS.Core.WebDav.Helpers; -using System; -using System.Diagnostics; +using System; using System.Net; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NWebDav.Server.Dispatching; +using SecureFolderFS.Core.WebDav.Helpers; namespace SecureFolderFS.Core.WebDav { public sealed class WebDavWrapper { - private Thread? _fsThead; + private const int ERROR_OPERATION_ABORTED = 995; + + private Task? _acceptLoopTask; private readonly HttpListener _httpListener; private readonly IRequestDispatcher _requestDispatcher; private readonly CancellationTokenSource _fileSystemCts; @@ -26,27 +28,46 @@ public WebDavWrapper(HttpListener httpListener, IRequestDispatcher requestDispat public void StartFileSystem() { - var ts = new ThreadStart(async () => await EnsureFileSystemAsync()); - _fsThead = new Thread(ts); - _fsThead.Start(); + // The accept loop is fully asynchronous and I/O-bound (each request is dispatched onto the thread pool). + // Running it as a background task keeps the UI responsive without occupying a thread + _acceptLoopTask = Task.Run(EnsureFileSystemAsync); } private async Task EnsureFileSystemAsync() { - try + // The caller starts the listener before mounting, so a bind failure has already surfaced + while (!_fileSystemCts.IsCancellationRequested) { - _httpListener.Start(); - while (!_fileSystemCts.IsCancellationRequested && (await _httpListener.GetContextAsync() is var httpListenerContext)) + HttpListenerContext httpListenerContext; + try { - if (httpListenerContext.Request.IsAuthenticated) - Debugger.Break(); - - await _requestDispatcher.DispatchRequestAsync(httpListenerContext, _fileSystemCts.Token); + httpListenerContext = await _httpListener.GetContextAsync(); } - } - catch (Exception ex) - { - _ = ex; + catch (Exception ex) when (ex is ObjectDisposedException or HttpListenerException { ErrorCode: ERROR_OPERATION_ABORTED }) + { + // The listener was closed, stop accepting requests + break; + } + catch (Exception ex) + { + // A transient error while accepting a request must not kill the accept loop + _requestDispatcher.Logger?.LogError(ex, "Failed to accept an incoming WebDAV request."); + continue; + } + + // Dispatch each request concurrently. The Windows WebDAV redirector interleaves requests + // (PROPFIND, LOCK refreshes) with long-running transfers and times out when they stall. + _ = Task.Run(async () => + { + try + { + await _requestDispatcher.DispatchRequestAsync(httpListenerContext, _fileSystemCts.Token); + } + catch (Exception ex) + { + _requestDispatcher.Logger?.LogError(ex, "Unhandled exception while dispatching a WebDAV request."); + } + }, _fileSystemCts.Token); } } @@ -55,6 +76,19 @@ public async Task CloseFileSystemAsync() _httpListener.Close(); await _fileSystemCts.CancelAsync(); + // Wait for the accept loop to observe the closed listener and unwind + if (_acceptLoopTask is not null) + { + try + { + await _acceptLoopTask; + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) + { + // Expected during teardown + } + } + if (_mountPath is not null) DriveMappingHelpers.DisconnectNetworkDrive(_mountPath, true); diff --git a/src/Core/SecureFolderFS.Core.WinFsp/AppModels/DirectoryEnumerationContext.cs b/src/Core/SecureFolderFS.Core.WinFsp/AppModels/DirectoryEnumerationContext.cs new file mode 100644 index 000000000..852341785 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.WinFsp/AppModels/DirectoryEnumerationContext.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using FileInfo = Fsp.Interop.FileInfo; + +namespace SecureFolderFS.Core.WinFsp.AppModels +{ + /// + /// Holds the state of a single directory enumeration session. + /// + internal sealed class DirectoryEnumerationContext + { + public List<(string PlaintextName, FileInfo FileInfo)> Entries { get; } + + public int Index { get; set; } + + public DirectoryEnumerationContext(List<(string PlaintextName, FileInfo FileInfo)> entries) + { + Entries = entries; + } + } +} \ No newline at end of file diff --git a/src/Core/SecureFolderFS.Core.WinFsp/AppModels/WinFspOptions.cs b/src/Core/SecureFolderFS.Core.WinFsp/AppModels/WinFspOptions.cs index fa8a1a22e..a7e39bfb4 100644 --- a/src/Core/SecureFolderFS.Core.WinFsp/AppModels/WinFspOptions.cs +++ b/src/Core/SecureFolderFS.Core.WinFsp/AppModels/WinFspOptions.cs @@ -33,7 +33,7 @@ public static WinFspOptions ToOptions(IDictionary options) 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)) ?? true, + IsCachingFileNames = (bool?)options.Get(nameof(IsCachingFileNames)) ?? false, IsCachingDirectoryIds = (bool?)options.Get(nameof(IsCachingDirectoryIds)) ?? true, RecycleBinSize = (long?)options.Get(nameof(RecycleBinSize)) ?? 0L, diff --git a/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs b/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs index e3f190434..487c5fcb9 100644 --- a/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs +++ b/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs @@ -1,4 +1,9 @@ -using Fsp; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Security.AccessControl; +using Fsp; using Fsp.Interop; using OwlCore.Storage; using SecureFolderFS.Core.FileSystem; @@ -9,16 +14,13 @@ using SecureFolderFS.Core.FileSystem.Helpers.Paths.Native; using SecureFolderFS.Core.FileSystem.Helpers.RecycleBin.Native; using SecureFolderFS.Core.FileSystem.OpenHandles; +using SecureFolderFS.Core.WinFsp.AppModels; using SecureFolderFS.Core.WinFsp.OpenHandles; using SecureFolderFS.Core.WinFsp.UnsafeNative; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Runtime.CompilerServices; -using System.Security.AccessControl; using FileInfo = Fsp.Interop.FileInfo; +#pragma warning disable CA1416 // Validate platform compatibility + namespace SecureFolderFS.Core.WinFsp.Callbacks { public sealed partial class OnDeviceWinFsp : FileSystemBase, IDisposable @@ -148,75 +150,76 @@ public override bool ReadDirectoryEntry( return false; } - // Default pattern = "*" - Pattern = !string.IsNullOrEmpty(Pattern) ? Pattern.Replace('<', '*').Replace('>', '?').Replace('"', '.') : "*"; + // The directory contents are enumerated once per session and kept in the Context. + // Re-enumerating (and re-decrypting every name) on each produced entry would make + // directory listings quadratic in the number of items. + if (Context is not DirectoryEnumerationContext enumerationContext) + { + // Default pattern: "*" + Pattern = !string.IsNullOrEmpty(Pattern) ? Pattern.Replace('<', '*').Replace('>', '?').Replace('"', '.') : "*"; - var entries = new List<(string plaintextName, FileSystemInfo info)>(); - var dirInfo = dirHandle.DirectoryInfo; + var entries = new List<(string PlaintextName, FileInfo FileInfo)>(); + var dirInfo = dirHandle.DirectoryInfo; - try - { - // Add directory entries - var directoryId = AbstractPathHelpers.AllocateDirectoryId(_specifics.Security, dirInfo.FullName); - var itemsEnumerable = _specifics.Security.NameCrypt is null ? dirInfo.EnumerateFileSystemInfos(Pattern) : dirInfo.EnumerateFileSystemInfos(); - foreach (var item in itemsEnumerable) + try { - if (PathHelpers.IsCoreName(item.Name)) - continue; + // Add directory entries + var directoryId = AbstractPathHelpers.AllocateDirectoryId(_specifics.Security, dirInfo.FullName); + var itemsEnumerable = _specifics.Security.NameCrypt is null ? dirInfo.EnumerateFileSystemInfos(Pattern) : dirInfo.EnumerateFileSystemInfos(); + foreach (var item in itemsEnumerable) + { + if (PathHelpers.IsCoreName(item.Name)) + continue; - var plaintextName = NativePathHelpers.DecryptName(item.Name, dirInfo.FullName, _specifics, directoryId); - if (string.IsNullOrEmpty(plaintextName)) - continue; + var plaintextName = NativePathHelpers.DecryptName(item.Name, dirInfo.FullName, _specifics, directoryId); + if (string.IsNullOrEmpty(plaintextName)) + continue; - if (_specifics.Security.NameCrypt is not null && !UnsafeNativeApis.PathMatchSpec(plaintextName, Pattern)) - continue; + if (_specifics.Security.NameCrypt is not null && !UnsafeNativeApis.PathMatchSpec(plaintextName, Pattern)) + continue; - entries.Add((plaintextName, item)); + entries.Add(item switch + { + System.IO.FileInfo fi => (plaintextName, WinFspFileHandle.ToFileInfo(fi, _specifics.Security)), + DirectoryInfo di => (plaintextName, WinFspDirectoryHandle.ToFileInfo(di)), + _ => throw new ArgumentOutOfRangeException(nameof(item)) + }); + } } - } - catch (Exception) - { - FileName = null; - FileInfo = default; - - return false; - } + catch (Exception) + { + FileName = null; + FileInfo = default; - // Sort alphabetically for consistent enumeration - entries.Sort((a, b) => string.Compare(a.info.Name, b.info.Name, StringComparison.OrdinalIgnoreCase)); + return false; + } - // Determine current enumeration index - int index; - if (Context is null) - { - index = 0; + // Sort by the names reported to the caller for consistent enumeration. + // The marker WinFsp passes when resuming is the last *plaintext* name returned, + // so both sorting and marker matching must use plaintext names. + entries.Sort((a, b) => string.CompareOrdinal(a.PlaintextName, b.PlaintextName)); - // If a marker was provided, start *after* that entry + // If a marker was provided, resume after that entry. When the marker is gone + // (e.g., the item was deleted in the meantime), resume at the next greater name. + var index = 0; if (!string.IsNullOrEmpty(Marker)) { - index = entries.FindIndex(e => string.Equals(e.info.Name, Marker, StringComparison.OrdinalIgnoreCase)); - if (index >= 0) - index++; - else - index = 0; + index = entries.FindIndex(e => string.CompareOrdinal(e.PlaintextName, Marker) > 0); + if (index < 0) + index = entries.Count; } + + Context = enumerationContext = new DirectoryEnumerationContext(entries) { Index = index }; } - else - index = (int)Context; // Produce the next entry, if any - if (index < entries.Count) + if (enumerationContext.Index < enumerationContext.Entries.Count) { - var entry = entries[index]; - FileName = entry.plaintextName; - FileInfo = entry.info switch - { - System.IO.FileInfo fi => WinFspFileHandle.ToFileInfo(fi, _specifics.Security), - DirectoryInfo di => WinFspDirectoryHandle.ToFileInfo(di), - _ => throw new ArgumentOutOfRangeException(nameof(entry.info)) - }; + var entry = enumerationContext.Entries[enumerationContext.Index]; + FileName = entry.PlaintextName; + FileInfo = entry.FileInfo; - Context = index + 1; + enumerationContext.Index++; return true; } @@ -293,6 +296,11 @@ public override int GetSecurityByName( FileAttributes = 0; return Trace(STATUS_OBJECT_PATH_NOT_FOUND, FileName); } + catch (UnauthorizedAccessException) + { + FileAttributes = 0; + return Trace(STATUS_ACCESS_DENIED, FileName); + } } /// @@ -316,7 +324,6 @@ public override int GetSecurity( } /// - [MethodImpl(MethodImplOptions.Synchronized)] public override unsafe int Read( object FileNode, object FileDesc, @@ -332,21 +339,26 @@ public override unsafe int Read( return Trace(STATUS_INVALID_HANDLE); } - // Check EOF - if (Offset >= (ulong)fileHandle.Stream.Length) - { - BytesTransferred = 0u; - return Trace(STATUS_END_OF_FILE); - } - try { - // Align position - fileHandle.Stream.Position = (long)Offset; + // 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 (fileHandle.Stream) + { + // Check EOF + if (Offset >= (ulong)fileHandle.Stream.Length) + { + BytesTransferred = 0u; + return Trace(STATUS_END_OF_FILE); + } - // Read file - var bufferSpan = new Span(Buffer.ToPointer(), (int)Length); - BytesTransferred = (uint)fileHandle.Stream.Read(bufferSpan); + // Align position + fileHandle.Stream.Position = (long)Offset; + + // Read file + var bufferSpan = new Span(Buffer.ToPointer(), (int)Length); + BytesTransferred = (uint)fileHandle.Stream.Read(bufferSpan); + } return Trace(STATUS_SUCCESS); } @@ -361,7 +373,6 @@ public override unsafe int Read( } /// - [MethodImpl(MethodImplOptions.Synchronized)] public override unsafe int Write( object FileNode, object FileDesc, @@ -390,34 +401,39 @@ public override unsafe int Write( return Trace(STATUS_INVALID_HANDLE); } - // Check constrained I/O - if (ConstrainedIo) + try { - // If offset is beyond EOF, no bytes can be written in Constrained I/O mode - if (Offset >= (ulong)fileHandle.Stream.Length) + // 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 (fileHandle.Stream) { - FileInfo = default; - BytesTransferred = 0u; + // Check constrained I/O + if (ConstrainedIo) + { + // If offset is beyond EOF, no bytes can be written in Constrained I/O mode + if (Offset >= (ulong)fileHandle.Stream.Length) + { + FileInfo = default; + BytesTransferred = 0u; - return Trace(STATUS_SUCCESS); - } + return Trace(STATUS_SUCCESS); + } - if ((Offset + Length) > (ulong)fileHandle.Stream.Length) - Length = (uint)((ulong)fileHandle.Stream.Length - Offset); - } + if ((Offset + Length) > (ulong)fileHandle.Stream.Length) + Length = (uint)((ulong)fileHandle.Stream.Length - Offset); + } - try - { - // Align position - fileHandle.Stream.Position = WriteToEndOfFile ? fileHandle.Stream.Length : (long)Offset; + // Align position + fileHandle.Stream.Position = WriteToEndOfFile ? fileHandle.Stream.Length : (long)Offset; - // Write file - var bufferSpan = new ReadOnlySpan(Buffer.ToPointer(), (int)Length); - fileHandle.Stream.Write(bufferSpan); + // Write file + var bufferSpan = new ReadOnlySpan(Buffer.ToPointer(), (int)Length); + fileHandle.Stream.Write(bufferSpan); - // Set transferred bytes and update file info - BytesTransferred = Length; - FileInfo = fileHandle.GetFileInfo(); + // Set transferred bytes and update file info + BytesTransferred = Length; + FileInfo = fileHandle.GetFileInfo(); + } return Trace(STATUS_SUCCESS); } @@ -485,43 +501,39 @@ public override int SetBasicInfo( return Trace(STATUS_ACCESS_DENIED); } - FileAttributes = FileAttributes switch + var handle = _handlesManager.GetHandle(GetContextValue(FileDesc)); + FileSystemInfo? info = handle switch { - Constants.UnsafeNative.INVALID_FILE_ATTRIBUTES => (uint)System.IO.FileAttributes.None, - (uint)System.IO.FileAttributes.None => (uint)System.IO.FileAttributes.Normal, - _ => FileAttributes + WinFspFileHandle fileHandle => fileHandle.FileInfo, + WinFspDirectoryHandle dirHandle => dirHandle.DirectoryInfo, + _ => null }; - switch (_handlesManager.GetHandle(GetContextValue(FileDesc))) + if (info is null) { - case WinFspFileHandle fileHandle: - { - fileHandle.FileInfo.CreationTimeUtc = DateTime.FromFileTimeUtc((long)CreationTime); - fileHandle.FileInfo.LastWriteTimeUtc = DateTime.FromFileTimeUtc((long)LastWriteTime); - fileHandle.FileInfo.LastAccessTimeUtc = DateTime.FromFileTimeUtc((long)LastAccessTime); - fileHandle.FileInfo.Attributes = (FileAttributes)FileAttributes; - FileInfo = fileHandle.GetFileInfo(); + FileInfo = default; + return Trace(STATUS_INVALID_HANDLE); + } - break; - } + // A zero time value and INVALID_FILE_ATTRIBUTES indicate that the field must not be changed + if (FileAttributes != Constants.UnsafeNative.INVALID_FILE_ATTRIBUTES) + info.Attributes = FileAttributes == (uint)System.IO.FileAttributes.None ? System.IO.FileAttributes.Normal : (FileAttributes)FileAttributes; - case WinFspDirectoryHandle dirHandle: - { - dirHandle.DirectoryInfo.CreationTimeUtc = DateTime.FromFileTimeUtc((long)CreationTime); - dirHandle.DirectoryInfo.LastWriteTimeUtc = DateTime.FromFileTimeUtc((long)LastWriteTime); - dirHandle.DirectoryInfo.LastAccessTimeUtc = DateTime.FromFileTimeUtc((long)LastAccessTime); - dirHandle.DirectoryInfo.Attributes = (FileAttributes)FileAttributes; - FileInfo = dirHandle.GetFileInfo(); + if (CreationTime != 0UL) + info.CreationTimeUtc = DateTime.FromFileTimeUtc((long)CreationTime); - break; - } + if (LastAccessTime != 0UL) + info.LastAccessTimeUtc = DateTime.FromFileTimeUtc((long)LastAccessTime); - default: - { - FileInfo = default; - return Trace(STATUS_INVALID_HANDLE); - } - } + if (LastWriteTime != 0UL) + info.LastWriteTimeUtc = DateTime.FromFileTimeUtc((long)LastWriteTime); + + FileInfo = handle switch + { + WinFspFileHandle fileHandle => fileHandle.GetFileInfo(), + WinFspDirectoryHandle dirHandle => dirHandle.GetFileInfo(), + _ => default + }; return Trace(STATUS_SUCCESS); } @@ -549,11 +561,15 @@ public override int SetFileSize( try { - // If the new AllocationSize is less than the current FileSize we must truncate the file - if (!SetAllocationSize || (ulong)fileHandle.Stream.Length > NewSize) - fileHandle.Stream.SetLength((long)NewSize); + lock (fileHandle.Stream) + { + // If the new AllocationSize is less than the current FileSize we must truncate the file + if (!SetAllocationSize || (ulong)fileHandle.Stream.Length > NewSize) + fileHandle.Stream.SetLength((long)NewSize); + + FileInfo = fileHandle.GetFileInfo(); + } - FileInfo = fileHandle.GetFileInfo(); return Trace(STATUS_SUCCESS); } catch (IOException ioEx) @@ -639,8 +655,11 @@ public override int Flush( try { - fileHandle.Stream.Flush(); - FileInfo = fileHandle.GetFileInfo(); + lock (fileHandle.Stream) + { + fileHandle.Stream.Flush(); + FileInfo = fileHandle.GetFileInfo(); + } return Trace(STATUS_SUCCESS); } @@ -680,7 +699,8 @@ public override int Create( return Trace(STATUS_ACCESS_DENIED, FileName); } - IDisposable? handle = null; + IDisposable? handle; + var createdHandleId = FileSystem.Constants.INVALID_HANDLE; try { var ciphertextPath = GetCiphertextPath(FileName); @@ -700,6 +720,7 @@ public override int Create( fileAccess, FileShare.ReadWrite | FileShare.Delete, FileOptions.None); + createdHandleId = handleId; if (_handlesManager.GetHandle(handleId) is not { } fileHandle) { @@ -726,8 +747,7 @@ public override int Create( } } - fileHandle.FileInfo.Attributes = - (System.IO.FileAttributes)(FileAttributes | (uint)System.IO.FileAttributes.Archive); + fileHandle.FileInfo.Attributes = (FileAttributes)(FileAttributes | (uint)System.IO.FileAttributes.Archive); handle = fileHandle; } else @@ -765,6 +785,8 @@ public override int Create( _specifics.DirectoryIdCache.CacheSet(directoryIdPath, new(directoryId)); var handleId = _handlesManager.OpenDirectoryHandle(ciphertextPath); + createdHandleId = handleId; + if (_handlesManager.GetHandle(handleId) is not { } dirHandle) { FileNode = null; @@ -790,7 +812,7 @@ public override int Create( } } - directoryInfo.Attributes = (System.IO.FileAttributes)FileAttributes; + directoryInfo.Attributes = (FileAttributes)FileAttributes; handle = dirHandle; } @@ -820,7 +842,7 @@ public override int Create( FileInfo = default; NormalizedName = null; - handle?.Dispose(); + _handlesManager.CloseHandle(createdHandleId); if (ErrorHandlingHelpers.IsFileAlreadyExistsException(ioEx)) return Trace(STATUS_OBJECT_NAME_COLLISION, FileName); @@ -853,23 +875,40 @@ public override int Overwrite( return Trace(STATUS_INVALID_HANDLE); } - if (ReplaceFileAttributes) - { - fileHandle.FileInfo.Attributes = (System.IO.FileAttributes)(FileAttributes | (uint)System.IO.FileAttributes.Archive); - } - else if (FileAttributes != 0u) + try { - var existingAttributes = fileHandle.FileInfo.Attributes; - existingAttributes |= (System.IO.FileAttributes)FileAttributes; - existingAttributes |= System.IO.FileAttributes.Archive; + if (ReplaceFileAttributes) + { + fileHandle.FileInfo.Attributes = (FileAttributes)(FileAttributes | (uint)System.IO.FileAttributes.Archive); + } + else if (FileAttributes != 0u) + { + var existingAttributes = fileHandle.FileInfo.Attributes; + existingAttributes |= (FileAttributes)FileAttributes; + existingAttributes |= System.IO.FileAttributes.Archive; - fileHandle.FileInfo.Attributes = existingAttributes; + fileHandle.FileInfo.Attributes = existingAttributes; + } + + lock (fileHandle.Stream) + { + fileHandle.Stream.SetLength(0L); + FileInfo = fileHandle.GetFileInfo(); + } + + return Trace(STATUS_SUCCESS); } + catch (Exception ex) + { + FileInfo = default; + if (ex is IOException ioEx && ErrorHandlingHelpers.IsDiskFullException(ioEx)) + return Trace(STATUS_DISK_FULL); - fileHandle.Stream.SetLength(0L); - FileInfo = fileHandle.GetFileInfo(); + if (ErrorHandlingHelpers.Win32ErrorFromException(ex, out var win32error)) + return Trace(NtStatusFromWin32(win32error)); - return Trace(STATUS_SUCCESS); + return Trace(STATUS_ACCESS_DENIED); + } } /// @@ -1025,8 +1064,9 @@ public override int Rename( return Trace(STATUS_ACCESS_DENIED, FileName); } - File.Delete(newCiphertextPath); - File.Move(oldCiphertextPath, newCiphertextPath); + // Replace the destination file atomically. A separate delete and move + // could lose the destination file if the operation is interrupted in between + File.Replace(oldCiphertextPath, newCiphertextPath, null, true); return Trace(STATUS_SUCCESS, FileName); } diff --git a/src/Core/SecureFolderFS.Core.WinFsp/OpenHandles/WinFspFileHandle.cs b/src/Core/SecureFolderFS.Core.WinFsp/OpenHandles/WinFspFileHandle.cs index de015446a..2c595b077 100644 --- a/src/Core/SecureFolderFS.Core.WinFsp/OpenHandles/WinFspFileHandle.cs +++ b/src/Core/SecureFolderFS.Core.WinFsp/OpenHandles/WinFspFileHandle.cs @@ -1,5 +1,6 @@ using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Core.FileSystem.OpenHandles; +using SecureFolderFS.Shared.Helpers; using System; using System.IO; using FileInfo = Fsp.Interop.FileInfo; @@ -22,12 +23,19 @@ public WinFspFileHandle(System.IO.FileInfo fileInfo, Security security, Stream s public FileInfo GetFileInfo() { - return ToFileInfo(FileInfo, _security); + // System.IO.FileInfo caches its state on first access so refresh is needed to avoid returning + // stale metadata after the file was modified through this (or another) handle + FileInfo.Refresh(); + + // The plaintext stream tracks the up-to-date length including data that + // has not been flushed to the ciphertext file yet + var plaintextSize = SafetyHelpers.NoFailureResult(() => Stream.Length); + return ToFileInfo(FileInfo, _security, plaintextSize); } - public static FileInfo ToFileInfo(System.IO.FileInfo fileInfo, Security security) + public static FileInfo ToFileInfo(System.IO.FileInfo fileInfo, Security security, long? plaintextSize = null) { - var size = (ulong)security.ContentCrypt.CalculatePlaintextSize(Math.Max(0L, fileInfo.Length - security.HeaderCrypt.HeaderCiphertextSize)); + var size = (ulong)(plaintextSize ?? security.ContentCrypt.CalculatePlaintextSize(Math.Max(0L, fileInfo.Length - security.HeaderCrypt.HeaderCiphertextSize))); return new() { FileAttributes = (uint)(fileInfo.Attributes == 0 ? FileAttributes.Normal : fileInfo.Attributes), diff --git a/src/Core/SecureFolderFS.Core.WinFsp/SecureFolderFS.Core.WinFsp.csproj b/src/Core/SecureFolderFS.Core.WinFsp/SecureFolderFS.Core.WinFsp.csproj index f4ebea679..fb8bff9bf 100644 --- a/src/Core/SecureFolderFS.Core.WinFsp/SecureFolderFS.Core.WinFsp.csproj +++ b/src/Core/SecureFolderFS.Core.WinFsp/SecureFolderFS.Core.WinFsp.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/Platforms/Directory.Packages.props b/src/Platforms/Directory.Packages.props index dba561f37..f8fb1cb59 100644 --- a/src/Platforms/Directory.Packages.props +++ b/src/Platforms/Directory.Packages.props @@ -11,7 +11,7 @@ - + diff --git a/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs b/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs index 2f2d4bc78..34aab586f 100644 --- a/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs +++ b/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs @@ -77,5 +77,3 @@ public static int HandleException(Exception ex, IConsole console, CliGlobalOptio } } } - - diff --git a/src/Platforms/SecureFolderFS.Cli/CliGlobalOptions.cs b/src/Platforms/SecureFolderFS.Cli/CliGlobalOptions.cs index 87810357e..e3f7290a1 100644 --- a/src/Platforms/SecureFolderFS.Cli/CliGlobalOptions.cs +++ b/src/Platforms/SecureFolderFS.Cli/CliGlobalOptions.cs @@ -14,5 +14,3 @@ public abstract class CliGlobalOptions : ICommand public abstract ValueTask ExecuteAsync(IConsole console); } - - diff --git a/src/Platforms/SecureFolderFS.Cli/CliOutput.cs b/src/Platforms/SecureFolderFS.Cli/CliOutput.cs index 43de10d27..df7d15737 100644 --- a/src/Platforms/SecureFolderFS.Cli/CliOutput.cs +++ b/src/Platforms/SecureFolderFS.Cli/CliOutput.cs @@ -50,4 +50,3 @@ public static string StripIfNeeded(CliGlobalOptions options, string value) [GeneratedRegex("\\x1B\\[[0-9;]*[A-Za-z]")] private static partial Regex AnsiRegex(); } - diff --git a/src/Platforms/SecureFolderFS.Cli/CliTypeActivator.cs b/src/Platforms/SecureFolderFS.Cli/CliTypeActivator.cs deleted file mode 100644 index c823da7f3..000000000 --- a/src/Platforms/SecureFolderFS.Cli/CliTypeActivator.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace SecureFolderFS.Cli; - -internal static class CliTypeActivator -{ - public static object CreateInstance(IServiceProvider serviceProvider, Type type) - { - return ActivatorUtilities.CreateInstance(serviceProvider, type); - } -} - - diff --git a/src/Platforms/SecureFolderFS.Cli/CredentialReader.cs b/src/Platforms/SecureFolderFS.Cli/CredentialReader.cs index e79e9c13f..cc67c0f47 100644 --- a/src/Platforms/SecureFolderFS.Cli/CredentialReader.cs +++ b/src/Platforms/SecureFolderFS.Cli/CredentialReader.cs @@ -98,5 +98,3 @@ public async Task GenerateKeyFileAsync(string outputPath, string vaul return buffer.Length == 0 ? null : buffer.ToString(); } } - - diff --git a/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStreamSource.cs b/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStreamSource.cs index 62c313e43..92bfdf69b 100644 --- a/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStreamSource.cs +++ b/src/Platforms/SecureFolderFS.Maui/AppModels/ImageStreamSource.cs @@ -28,6 +28,18 @@ public ImageStreamSource(Stream inner) }; } + public ImageStreamSource(byte[] imageData) + { + // Serve a fresh stream per request - the platform image loader disposes the + // stream after decoding and may request it again (recycled views, re-layouts). + // A single shared stream would be dead by the time a second request arrives + Inner = new MemoryStream(imageData, writable: false); + Source = new StreamImageSource + { + Stream = _ => Task.FromResult(new MemoryStream(imageData, writable: false)) + }; + } + /// public void Dispose() { diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AppModels/StreamedMediaSource.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AppModels/StreamedMediaSource.cs deleted file mode 100644 index bff7afcbf..000000000 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/AppModels/StreamedMediaSource.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Android.Media; -using Stream = System.IO.Stream; - -namespace SecureFolderFS.Maui.AppModels -{ - /// - internal sealed class StreamedMediaSource : MediaDataSource - { - private readonly Stream _sourceStream; - - /// - public override long Size => _sourceStream.Length; - - public StreamedMediaSource(Stream sourceStream) - { - _sourceStream = sourceStream; - } - - /// - public override int ReadAt(long position, byte[]? buffer, int offset, int size) - { - if (buffer is null) - return 0; - - _sourceStream.Position = position; - return _sourceStream.Read(buffer, offset, size); - } - - /// - public override void Close() - { - _sourceStream.Dispose(); - } - } -} diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs index 14bc7d122..2bafd668d 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidMediaService.cs @@ -1,4 +1,3 @@ -using Android.Media; using OwlCore.Storage; using SecureFolderFS.Core.MobileFS.Platforms.Android.Helpers; using SecureFolderFS.Maui.AppModels; @@ -8,7 +7,6 @@ using SecureFolderFS.Shared.Enums; using SecureFolderFS.Shared.Helpers; using SecureFolderFS.UI; -using Stream = System.IO.Stream; namespace SecureFolderFS.Maui.Platforms.Android.ServiceImplementation { @@ -33,8 +31,10 @@ public override async Task GenerateThumbnailAsync(IFile file, Type case TypeHint.Media: { + // Capture at one second rather than the very first frame, which is often + // black (fade-ins). ClosestSync clamps the position for shorter videos await using var stream = await file.OpenReadAsync(cancellationToken).ConfigureAwait(false); - var imageStream = await GenerateVideoThumbnailAsync(stream, TimeSpan.FromSeconds(0)).ConfigureAwait(false); + var imageStream = await ThumbnailHelpers.GenerateVideoThumbnailAsync(stream, TimeSpan.FromSeconds(1)).ConfigureAwait(false); return new ImageStreamSource(imageStream); } @@ -42,18 +42,5 @@ public override async Task GenerateThumbnailAsync(IFile file, Type default: throw new InvalidOperationException("The provided file type is invalid."); } } - - private static async Task GenerateVideoThumbnailAsync(Stream stream, TimeSpan captureTime) - { - using var retriever = new MediaMetadataRetriever(); - await retriever.SetDataSourceAsync(new StreamedMediaSource(stream)).ConfigureAwait(false); - - // Use scaled frame for efficiency - using var bitmap = retriever.GetScaledFrameAtTime(captureTime.Ticks, Option.ClosestSync, 320, 240); - if (bitmap is null) - throw new NotSupportedException("Could not retrieve scaled frame."); - - return await ThumbnailHelpers.CompressBitmapAsync(bitmap).ConfigureAwait(false); - } } } diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs index 002316b66..0545442bf 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs @@ -28,7 +28,7 @@ public override async IAsyncEnumerable GetCreationAsync await Task.CompletedTask; } - + /// protected override async IAsyncEnumerable GetLoginAsync( IFolder vaultFolder, diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/ShareContentProvider.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/ShareContentProvider.cs index 984bf2093..d20477d49 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/ShareContentProvider.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/ShareContentProvider.cs @@ -28,7 +28,7 @@ public class ShareContentProvider : ContentProvider /// to accommodate apps that open multiple streams (e.g. type sniffing + actual read). /// private static readonly TimeSpan RegistrationTtl = TimeSpan.FromSeconds(20); - + private static readonly Dictionary _registeredFiles = new(); private static readonly Lock _lock = new(); @@ -36,7 +36,7 @@ public class ShareContentProvider : ContentProvider public ShareContentProvider() { } - + /// /// Registers a file for sharing and returns a content URI suitable for use in an Intent. /// The registration is automatically cleaned up after . @@ -195,7 +195,7 @@ public override bool OnCreate() /// public override int Update(Uri uri, ContentValues? values, string? selection, string[]? selectionArgs) => 0; - + private static bool IsBrokenPipe(Java.IO.IOException ex) { var msg = ex.Message; diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFile.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFile.cs index 5df054c6e..c8a1645cb 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFile.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFile.cs @@ -6,6 +6,7 @@ using OwlCore.Storage; using SecureFolderFS.Core.MobileFS.Platforms.Android.Streams; using SecureFolderFS.Maui.Platforms.Android.Storage.StorageProperties; +using SecureFolderFS.Shared.Helpers; using SecureFolderFS.Storage.StorageProperties; using AndroidUri = Android.Net.Uri; @@ -26,11 +27,11 @@ internal sealed class AndroidFile : AndroidStorable, IChildFile, ILastModifiedAt /// public ISizeOfProperty SizeOf => field ??= new AndroidSizeOfProperty(Id, Document ?? throw new ArgumentNullException(nameof(Document))); - public AndroidFile(AndroidUri uri, Activity activity, AndroidFolder? parent = null, AndroidUri? permissionRoot = null, string? bookmarkId = null) + public AndroidFile(AndroidUri uri, Activity activity, AndroidFolder? parent = null, AndroidUri? permissionRoot = null, string? bookmarkId = null, string? name = null) : base(uri, activity, parent, permissionRoot, bookmarkId) { Document = DocumentFile.FromSingleUri(activity, uri); - Name = Document?.Name ?? base.Name; + Name = name ?? Document?.Name ?? base.Name; } /// @@ -47,6 +48,20 @@ public Task OpenStreamAsync(FileAccess accessMode, CancellationToken can if (accessMode == FileAccess.Read) { + // Prefer a file-descriptor-backed stream which is seekable - random access + // is required both by the crypto layer and by consumers seeking within files + var readFd = SafetyHelpers.NoFailureResult(() => activity.ContentResolver?.OpenFileDescriptor(Inner, "r")); + if (readFd is not null) + { + var readChannel = new FileInputStream(readFd.FileDescriptor).Channel; + if (readChannel is not null) + return Task.FromResult(new ChannelledStream(readChannel, null, readFd)); + + readFd.Close(); + } + + // Fall back to a plain (forward-only) input stream for providers + // that cannot supply a seekable file descriptor var inStream = activity.ContentResolver?.OpenInputStream(Inner); if (inStream is null) return Task.FromException(new UnauthorizedAccessException("Could not open input stream.")); @@ -55,7 +70,9 @@ public Task OpenStreamAsync(FileAccess accessMode, CancellationToken can } else { - var fd = activity.ContentResolver?.OpenFileDescriptor(Inner, "rwt"); + // Open in "rw" mode - "rwt" would truncate the existing content on open. + // Truncation is a deliberate operation performed by the caller, never a side effect + var fd = activity.ContentResolver?.OpenFileDescriptor(Inner, "rw"); if (fd is null) return Task.FromException(new UnauthorizedAccessException("Could not open file descriptor.")); diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFolder.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFolder.cs index 69ae27829..9a48ef3de 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFolder.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidFolder.cs @@ -5,6 +5,7 @@ using OwlCore.Storage; using SecureFolderFS.Maui.Platforms.Android.Storage.StorageProperties; using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.Shared.Helpers; using SecureFolderFS.Storage.Renamable; using Activity = Android.App.Activity; using AndroidUri = Android.Net.Uri; @@ -31,11 +32,11 @@ internal sealed class AndroidFolder : AndroidStorable, /// public ILastModifiedAtProperty LastModifiedAt => field ??= new AndroidLastModifiedAtProperty(Id, Document ?? throw new ArgumentNullException(nameof(Document))); - public AndroidFolder(AndroidUri uri, Activity activity, AndroidFolder? parent = null, AndroidUri? permissionRoot = null, string? bookmarkId = null) + public AndroidFolder(AndroidUri uri, Activity activity, AndroidFolder? parent = null, AndroidUri? permissionRoot = null, string? bookmarkId = null, string? name = null) : base(uri, activity, parent, permissionRoot, bookmarkId) { Document = DocumentFile.FromTreeUri(activity, uri); - Name = Document?.Name ?? base.Name; + Name = name ?? Document?.Name ?? base.Name; } /// @@ -52,7 +53,7 @@ public Task RenameAsync(IStorableChild storable, string newName, if (uri is null) return Task.FromException(RenameException); - return Task.FromResult(new AndroidFolder(uri, activity, Parent, permissionRoot)); + return Task.FromResult(new AndroidFolder(uri, activity, this, permissionRoot)); } case AndroidFile file: @@ -64,7 +65,7 @@ public Task RenameAsync(IStorableChild storable, string newName, if (uri is null) return Task.FromException(RenameException); - return Task.FromResult(new AndroidFile(uri, activity, Parent, permissionRoot)); + return Task.FromResult(new AndroidFile(uri, activity, this, permissionRoot)); } default: return Task.FromException(new ArgumentOutOfRangeException(nameof(storable))); @@ -74,6 +75,33 @@ public Task RenameAsync(IStorableChild storable, string newName, /// public async IAsyncEnumerable GetItemsAsync(StorableType type = StorableType.All, [EnumeratorCancellation] CancellationToken cancellationToken = default) { + // Fast path: enumerate children with a single ContentResolver query. + // DocumentFile.ListFiles() plus per-item name lookups cost several + // ContentResolver round-trips per item, making large listings very slow + var children = TryEnumerateChildren(); + if (children is not null) + { + foreach (var (uri, name, isDirectory) in children) + { + cancellationToken.ThrowIfCancellationRequested(); + var result = (IStorableChild?)(type switch + { + StorableType.File when !isDirectory => new AndroidFile(uri, activity, this, permissionRoot, name: name), + StorableType.Folder when isDirectory => new AndroidFolder(uri, activity, this, permissionRoot, name: name), + StorableType.All => isDirectory + ? new AndroidFolder(uri, activity, this, permissionRoot, name: name) + : new AndroidFile(uri, activity, this, permissionRoot, name: name), + _ => null + }); + + if (result is not null) + yield return result; + } + + yield break; + } + + // Fallback: DocumentFile-based enumeration if (Document is null) yield break; @@ -104,6 +132,90 @@ public async IAsyncEnumerable GetItemsAsync(StorableType type = await Task.CompletedTask.ConfigureAwait(false); } + /// + /// Enumerates the children of this folder with a single ContentResolver query. + /// + /// The list of children, or null when the query is unsupported or failed. + private List<(AndroidUri Uri, string Name, bool IsDirectory)>? TryEnumerateChildren() + { + try + { + if (activity.ContentResolver is null) + return null; + + var documentId = DocumentsContract.IsDocumentUri(activity, Inner) + ? DocumentsContract.GetDocumentId(Inner) + : DocumentsContract.GetTreeDocumentId(Inner); + + if (documentId is null) + return null; + + var childrenUri = DocumentsContract.BuildChildDocumentsUriUsingTree(Inner, documentId); + if (childrenUri is null) + return null; + + var projection = new[] + { + DocumentsContract.Document.ColumnDocumentId, + DocumentsContract.Document.ColumnDisplayName, + DocumentsContract.Document.ColumnMimeType + }; + + using var cursor = activity.ContentResolver.Query(childrenUri, projection, null, null, null); + if (cursor is null) + return null; + + var children = new List<(AndroidUri, string, bool)>(cursor.Count); + while (cursor.MoveToNext()) + { + var childDocumentId = cursor.GetString(0); + var childName = cursor.GetString(1); + var childMimeType = cursor.GetString(2); + if (childDocumentId is null || childName is null) + continue; + + var childUri = DocumentsContract.BuildDocumentUriUsingTree(Inner, childDocumentId); + if (childUri is null) + continue; + + var isDirectory = childMimeType == DocumentsContract.Document.MimeTypeDir; + children.Add((childUri, childName, isDirectory)); + } + + return children; + } + catch (Exception) + { + // Not a tree-based document provider or the query failed + return null; + } + } + + /// + /// Finds a direct child by name or null when no child with the given name exists. + /// + private (AndroidUri Uri, string Name, bool IsDirectory)? TryFindChild(string name) + { + var children = TryEnumerateChildren(); + if (children is not null) + { + foreach (var child in children) + { + if (name.Equals(child.Name, StringComparison.Ordinal)) + return child; + } + + return null; + } + + // Fallback: DocumentFile-based lookup + var file = Document?.FindFile(name); + if (file?.Uri is null) + return null; + + return (file.Uri, file.Name ?? name, file.IsDirectory); + } + /// public Task GetFolderWatcherAsync(CancellationToken cancellationToken = default) { @@ -113,41 +225,47 @@ public Task GetFolderWatcherAsync(CancellationToken cancellation /// public async Task DeleteAsync(IStorableChild item, CancellationToken cancellationToken = default) { - await DeleteContents(item); + if (item is not AndroidStorable androidStorable) + throw new ArgumentException($"The {nameof(item)} is not an Android storable.", nameof(item)); + + if (activity.ContentResolver is null) + throw new UnauthorizedAccessException("Could not access Android content resolver."); + + // Fast path: DeleteDocument removes documents (including whole directory + // trees on most providers) with a single call + var deleted = SafetyHelpers.NoFailureResult(() => DocumentsContract.DeleteDocument(activity.ContentResolver, androidStorable.Inner)); + if (deleted) + return; + + // Fallback: delete the folder contents recursively + if (item is not AndroidFolder folderToDelete) + throw new IOException($"Could not delete '{item.Name}'."); + + await DeleteContents(folderToDelete); return; - async Task DeleteContents(IStorableChild storable) + async Task DeleteContents(AndroidFolder storableIsFolder) { - switch (storable) + await foreach (var itemInFolder in storableIsFolder.GetItemsAsync(StorableType.All, cancellationToken).ConfigureAwait(false)) { - case AndroidFile storableIsFile: + switch (itemInFolder) { - if (activity.ContentResolver is not null) - DocumentsContract.DeleteDocument(activity.ContentResolver, storableIsFile.Inner); + case AndroidFolder androidFolder: + await DeleteContents(androidFolder); + break; - break; - } - - case AndroidFolder storableIsFolder: - { - await foreach (var itemInFolder in storableIsFolder.GetItemsAsync(StorableType.All, cancellationToken).ConfigureAwait(false)) + case AndroidFile androidFile: { - switch (itemInFolder) - { - case AndroidFolder androidFolder: - await DeleteContents(androidFolder); - break; - - case AndroidFile androidFile when activity.ContentResolver is not null: - DocumentsContract.DeleteDocument(activity.ContentResolver, androidFile.Inner); - break; - } - } + if (!DocumentsContract.DeleteDocument(activity.ContentResolver, androidFile.Inner)) + throw new IOException($"Could not delete '{androidFile.Name}'."); - DocumentFile.FromTreeUri(activity, storableIsFolder.Inner)?.Delete(); - break; + break; + } } } + + if (DocumentFile.FromTreeUri(activity, storableIsFolder.Inner)?.Delete() != true) + throw new IOException($"Could not delete '{storableIsFolder.Name}'."); } } @@ -165,15 +283,16 @@ public Task CreateFolderAsync(string name, bool overwrite = false, public Task CreateFileAsync(string name, bool overwrite = false, CancellationToken cancellationToken = default) { var mimeType = MimeTypeMap.Singleton?.GetMimeTypeFromExtension(MimeTypeMap.GetFileExtensionFromUrl(name)) ?? "application/octet-stream"; - var existingFile = Document?.FindFile(name); + var existingFile = TryFindChild(name); - if (overwrite && existingFile?.Uri is not null) + if (overwrite && existingFile is not null) { - existingFile.Delete(); + if (activity.ContentResolver is null || !DocumentsContract.DeleteDocument(activity.ContentResolver, existingFile.Value.Uri)) + return Task.FromException(new IOException($"Could not delete the existing file '{name}'.")); } - else if (existingFile?.Uri is not null) + else if (existingFile is not null) { - return Task.FromResult(new AndroidFile(existingFile.Uri, activity, this, permissionRoot)); + return Task.FromResult(new AndroidFile(existingFile.Value.Uri, activity, this, permissionRoot, name: existingFile.Value.Name)); } var newFile = Document?.CreateFile(mimeType, name); @@ -197,13 +316,14 @@ public Task CreateCopyOfAsync(IFile fileToCopy, bool overwrite, stri if (fileToCopy is not AndroidFile androidFile) return fallback(this, fileToCopy, overwrite, newName, cancellationToken); - var existingFile = Document?.FindFile(newName); + var existingFile = TryFindChild(newName); if (existingFile is not null) { if (!overwrite) return Task.FromException(new FileAlreadyExistsException(newName)); - existingFile.Delete(); + if (activity.ContentResolver is null || !DocumentsContract.DeleteDocument(activity.ContentResolver, existingFile.Value.Uri)) + return Task.FromException(new IOException($"Could not delete the existing file '{newName}'.")); } // No-op if source and destination are the same @@ -231,13 +351,14 @@ public Task MoveFromAsync(IChildFile fileToMove, IModifiableFolder s if (androidFile.Id == Path.Combine(Id, newName)) return Task.FromResult(fileToMove); - var existingFile = Document?.FindFile(newName); + var existingFile = TryFindChild(newName); if (existingFile is not null) { if (!overwrite) return Task.FromException(new FileAlreadyExistsException(newName)); - existingFile.Delete(); + if (activity.ContentResolver is null || !DocumentsContract.DeleteDocument(activity.ContentResolver, existingFile.Value.Uri)) + return Task.FromException(new IOException($"Could not delete the existing file '{newName}'.")); } // Fast-path: same folder means this is a pure rename @@ -251,16 +372,12 @@ public Task MoveFromAsync(IChildFile fileToMove, IModifiableFolder s /// public async Task GetFirstByNameAsync(string name, CancellationToken cancellationToken = default) { - var file = Document?.FindFile(name); - if (file?.Uri is not null) + var child = TryFindChild(name); + if (child is not null) { - if (file.IsFile) - return new AndroidFile(file.Uri, activity, this, permissionRoot); - - if (file.IsDirectory) - return new AndroidFolder(file.Uri, activity, this, permissionRoot); - - throw new InvalidOperationException("The found item is neither a file nor a folder."); + return child.Value.IsDirectory + ? new AndroidFolder(child.Value.Uri, activity, this, permissionRoot, name: child.Value.Name) + : new AndroidFile(child.Value.Uri, activity, this, permissionRoot, name: child.Value.Name); } var target = await GetItemsAsync(cancellationToken: cancellationToken) diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidStorable.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidStorable.cs index f4f66ffaa..10e8503fa 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidStorable.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/Android/Storage/AndroidStorable.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using Android.Content; using AndroidX.DocumentFile.Provider; using OwlCore.Storage; @@ -108,7 +107,6 @@ public Task RemoveBookmarkAsync(CancellationToken cancellationToken = default) catch (Exception ex) { _ = ex; - Debugger.Break(); } return null; diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/AppModels/StreamedResourceLoaderDelegate.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/AppModels/StreamedResourceLoaderDelegate.cs new file mode 100644 index 000000000..8ce4dbfb6 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/AppModels/StreamedResourceLoaderDelegate.cs @@ -0,0 +1,78 @@ +using AVFoundation; +using Foundation; + +namespace SecureFolderFS.Maui.Platforms.iOS.AppModels +{ + /// + /// Serves AVFoundation resource loading requests directly from a seekable . + /// + /// + /// This allows AVFoundation to read media with full random access without materializing + /// the (decrypted) content in a temporary file. Byte ranges are essential - many videos + /// keep their metadata ('moov' atom) at the end of the file. + /// + internal sealed class StreamedResourceLoaderDelegate : AVAssetResourceLoaderDelegate + { + // Serve at most this many bytes per request + private const int MAX_CHUNK_LENGTH = 1024 * 1024; + + private readonly Stream _sourceStream; + private readonly string _contentTypeUti; + private readonly object _streamLock = new(); + + public StreamedResourceLoaderDelegate(Stream sourceStream, string contentTypeUti) + { + _sourceStream = sourceStream; + _contentTypeUti = contentTypeUti; + } + + /// + public override bool ShouldWaitForLoadingOfRequestedResource(AVAssetResourceLoader resourceLoader, AVAssetResourceLoadingRequest loadingRequest) + { + try + { + var cir = loadingRequest.ContentInformationRequest; + if (cir is not null) + { + cir.ContentType = _contentTypeUti; + cir.ByteRangeAccessSupported = true; + lock (_streamLock) + cir.ContentLength = _sourceStream.Length; + } + + if (loadingRequest.DataRequest is { } dataRequest) + { + // CurrentOffset tracks the progress of partially-served requests + var offset = dataRequest.CurrentOffset != 0L ? dataRequest.CurrentOffset : dataRequest.RequestedOffset; + var length = (int)Math.Min(dataRequest.RequestedLength, MAX_CHUNK_LENGTH); + + if (length > 0) + { + int read; + var buffer = new byte[length]; + lock (_streamLock) + { + _sourceStream.Position = offset; + read = _sourceStream.ReadAtLeast(buffer, length, throwOnEndOfStream: false); + } + + if (read > 0) + { + using var data = NSData.FromArray(read == buffer.Length ? buffer : buffer[..read]); + dataRequest.Respond(data); + } + } + } + + loadingRequest.FinishLoading(); + return true; + } + catch (Exception) + { + // The source stream may already be closed when late requests arrive + loadingRequest.FinishLoadingWithError(new NSError((NSString)$"{nameof(SecureFolderFS)}.ResourceLoader", -1)); + return true; + } + } + } +} diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs index 0a1d7f843..9016dc693 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSMediaService.cs @@ -1,10 +1,12 @@ using AVFoundation; +using CoreFoundation; using CoreGraphics; using CoreMedia; using Foundation; using ImageIO; using OwlCore.Storage; using SecureFolderFS.Maui.AppModels; +using SecureFolderFS.Maui.Platforms.iOS.AppModels; using SecureFolderFS.Maui.ServiceImplementation; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Shared.ComponentModel; @@ -36,10 +38,12 @@ public override async Task GenerateThumbnailAsync(IFile file, Type case TypeHint.Media: { + // Capture at one second rather than the very first frame, which is often + // black (fade-ins). The generator clamps the position for shorter videos await using var stream = await file.OpenReadAsync(cancellationToken).ConfigureAwait(false); var extension = Path.GetExtension(file.Name); - return await GenerateVideoThumbnailAsync(stream, extension, TimeSpan.FromSeconds(0)).ConfigureAwait(false); + return await GenerateVideoThumbnailAsync(stream, extension, TimeSpan.FromSeconds(1)).ConfigureAwait(false); } default: throw new InvalidOperationException("The provided file type is invalid."); @@ -69,75 +73,77 @@ private static async Task GenerateImageThumbnailAsync(Stream strea throw new Exception("Failed to create thumbnail."); using var image = UIImage.FromImage(cgImage); - using var jpegData = image.AsJPEG(Constants.Browser.IMAGE_THUMBNAIL_QUALITY); - if (jpegData is null) - throw new FormatException("Failed to convert image to JPEG."); - - var memoryStream = new MemoryStream((int)jpegData.Length); - await jpegData.AsStream().CopyToAsync(memoryStream).ConfigureAwait(false); - memoryStream.Position = 0L; + using var encodedData = EncodeImage(image, cgImage.AlphaInfo); + if (encodedData is null) + throw new FormatException("Failed to encode the thumbnail."); - return new ImageStreamSource(new NonDisposableStream(memoryStream)); + return await ToImageStreamAsync(encodedData).ConfigureAwait(false); } private static async Task GenerateVideoThumbnailAsync(Stream stream, string extension, TimeSpan captureTime) { - var tempPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}{extension}"); - try + // Resolve the UTI of the container format for the resource loader + var normalizedExtension = extension.TrimStart('.').ToLowerInvariant(); + var contentType = normalizedExtension switch { - // Only read up to a limited prefix of the file. AVFoundation can typically - // decode the first keyframe from the first few MBs of a well-formed MP4, - // since moov/stbl metadata is usually at the front. - const int maxPrefixBytes = 4 * 1024 * 1024; // 4 MB - await using (var fileStream = File.OpenWrite(tempPath)) - { - var buffer = new byte[81920]; - int totalRead = 0, read; - while (totalRead < maxPrefixBytes && - (read = await stream - .ReadAsync(buffer.AsMemory(0, Math.Min(buffer.Length, maxPrefixBytes - totalRead))) - .ConfigureAwait(false)) > 0) - { - await fileStream.WriteAsync(buffer.AsMemory(0, read)).ConfigureAwait(false); - totalRead += read; - } - } + "mp4" or "m4v" => "public.mpeg-4", + "mov" or "qt" => "com.apple.quicktime-movie", + "mpg" or "mpeg" => "public.mpeg", + "avi" => "public.avi", + "3gp" => "public.3gpp", + _ => "public.movie" + }; - var asset = AVAsset.FromUrl(NSUrl.FromFilename(tempPath)); - var generator = new AVAssetImageGenerator(asset) - { - AppliesPreferredTrackTransform = true, - MaximumSize = new CGSize(320, 240) - }; + // A custom URL scheme forces AVFoundation to consult the resource loader delegate, + // which serves byte ranges directly from the (decrypted) stream. This avoids writing + // plaintext to a temporary file and supports videos with metadata at the end of the file + var url = NSUrl.FromString($"securefolderfs-stream://thumbnail/video.{normalizedExtension}"); + if (url is null) + throw new FormatException("Failed to create the resource URL."); - var actualTime = new CMTime((long)captureTime.TotalSeconds, 1); - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var times = new[] { NSValue.FromCMTime(actualTime) }; + using var loaderQueue = new DispatchQueue($"{nameof(SecureFolderFS)}.{nameof(Maui)}.ResourceLoader"); + using var loaderDelegate = new StreamedResourceLoaderDelegate(stream, contentType); + using var asset = AVUrlAsset.Create(url); + asset.ResourceLoader.SetDelegate(loaderDelegate, loaderQueue); - generator.GenerateCGImagesAsynchronously(times, (_, image, _, _, error) => - { - if (error != null || image is null) - tcs.TrySetException(new FormatException($"Failed to generate thumbnail: {error?.LocalizedDescription}")); - else - tcs.TrySetResult(image); - }); - - using var imageRef = await tcs.Task.ConfigureAwait(false); - using var image = UIImage.FromImage(imageRef); - using var jpegData = image.AsJPEG(Constants.Browser.IMAGE_THUMBNAIL_QUALITY); - if (jpegData is null) - throw new FormatException("Failed to convert image to JPEG."); - - var memoryStream = new MemoryStream(); - await jpegData.AsStream().CopyToAsync(memoryStream).ConfigureAwait(false); - memoryStream.Position = 0L; - - return new ImageStreamSource(new NonDisposableStream(memoryStream)); - } - finally + using var generator = new AVAssetImageGenerator(asset); + generator.AppliesPreferredTrackTransform = true; + generator.MaximumSize = new CGSize(320, 320); + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var times = new[] { NSValue.FromCMTime(CMTime.FromSeconds(captureTime.TotalSeconds, 600)) }; + + generator.GenerateCGImagesAsynchronously(times, (_, image, _, _, error) => { - File.Delete(tempPath); - } + if (error is not null || image is null) + tcs.TrySetException(new FormatException($"Failed to generate thumbnail: {error?.LocalizedDescription}")); + else + tcs.TrySetResult(image); + }); + + using var imageRef = await tcs.Task.ConfigureAwait(false); + using var image = UIImage.FromImage(imageRef); + using var jpegData = image.AsJPEG(Constants.Browser.IMAGE_THUMBNAIL_QUALITY / 100f); + if (jpegData is null) + throw new FormatException("Failed to convert image to JPEG."); + + return await ToImageStreamAsync(jpegData).ConfigureAwait(false); + } + + private static NSData? EncodeImage(UIImage image, CGImageAlphaInfo alphaInfo) + { + // Use PNG for images with transparency. AsJPEG() expects the compression quality in the 0..1f range + var hasAlpha = alphaInfo is not (CGImageAlphaInfo.None or CGImageAlphaInfo.NoneSkipFirst or CGImageAlphaInfo.NoneSkipLast); + return hasAlpha ? image.AsPNG() : image.AsJPEG(Constants.Browser.IMAGE_THUMBNAIL_QUALITY / 100f); + } + + private static async Task ToImageStreamAsync(NSData imageData) + { + var memoryStream = new MemoryStream((int)imageData.Length); + await imageData.AsStream().CopyToAsync(memoryStream).ConfigureAwait(false); + memoryStream.Position = 0L; + + return new ImageStreamSource(new NonDisposableStream(memoryStream)); } } } diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSShareService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSShareService.cs index 840327ea1..9b2cc040d 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSShareService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSShareService.cs @@ -147,5 +147,3 @@ protected override void Dispose(bool disposing) } } } - - diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs index 883f2ce4d..ae8365b10 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs @@ -34,7 +34,7 @@ public override async IAsyncEnumerable GetCreationAsync await Task.CompletedTask; } - + /// protected override async IAsyncEnumerable GetLoginAsync( IFolder vaultFolder, diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFile.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFile.cs index 14514e1c5..e90c5f7e7 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFile.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFile.cs @@ -27,10 +27,11 @@ public IOSFile(NSUrl url, IOSFolder? parent = null, NSUrl? permissionRoot = null /// public Task OpenStreamAsync(FileAccess accessMode, CancellationToken cancellationToken = default) { + // Allow concurrent readers by default (FileShare.Read) var iosStream = accessMode switch { - FileAccess.ReadWrite or FileAccess.Write => new IOSSecurityScopedStream(Inner, permissionRoot, FileAccess.ReadWrite), - _ => new IOSSecurityScopedStream(Inner, permissionRoot, FileAccess.Read) + FileAccess.ReadWrite or FileAccess.Write => new IOSSecurityScopedStream(Inner, permissionRoot, FileAccess.ReadWrite, FileShare.Read), + _ => new IOSSecurityScopedStream(Inner, permissionRoot, FileAccess.Read, FileShare.Read) }; return Task.FromResult(iosStream); diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFolder.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFolder.cs index dfc5069ec..3fe898c6e 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFolder.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSFolder.cs @@ -31,13 +31,13 @@ public IOSFolder(NSUrl url, IOSFolder? parent = null, NSUrl? permissionRoot = nu /// public async IAsyncEnumerable GetItemsAsync(StorableType type = StorableType.All, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - if (!permissionRoot.StartAccessingSecurityScopedResource()) - throw new UnauthorizedAccessException("Could not get iOS items."); - + var accessStarted = permissionRoot.StartAccessingSecurityScopedResource(); var coordinator = new NSFileCoordinator(); try { - var tcs = new TaskCompletionSource>(); + var tcs = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + await using var cancellationRegistration = cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken)); + coordinator.CoordinateRead(Inner, NSFileCoordinatorReadingOptions.WithoutChanges, out var error, @@ -58,20 +58,31 @@ public async IAsyncEnumerable GetItemsAsync(StorableType type = var items = await tcs.Task; foreach (var item in items) - yield return item; + { + // Honor the requested item type + var matchesType = type switch + { + StorableType.File => item is IFile, + StorableType.Folder => item is IFolder, + _ => true + }; + + if (matchesType) + yield return item; + } } finally { coordinator.Dispose(); - permissionRoot.StopAccessingSecurityScopedResource(); + if (accessStarted) + permissionRoot.StopAccessingSecurityScopedResource(); } } /// public async Task GetFirstByNameAsync(string name, CancellationToken cancellationToken = default) { - if (!permissionRoot.StartAccessingSecurityScopedResource()) - throw new UnauthorizedAccessException("Could not get iOS item."); + var accessStarted = permissionRoot.StartAccessingSecurityScopedResource(); try { var isDirectory = false; @@ -84,7 +95,9 @@ public async Task GetFirstByNameAsync(string name, CancellationT } finally { - permissionRoot.StopAccessingSecurityScopedResource(); + if (accessStarted) + permissionRoot.StopAccessingSecurityScopedResource(); + await Task.CompletedTask; } } @@ -101,8 +114,7 @@ public async Task DeleteAsync(IStorableChild item, CancellationToken cancellatio if (item is not IWrapper iosWrapper) throw new ArgumentException("Storable item must wrap an NSUrl.", nameof(item)); - if (!permissionRoot.StartAccessingSecurityScopedResource()) - throw new UnauthorizedAccessException("Could not delete iOS item."); + var accessStarted = permissionRoot.StartAccessingSecurityScopedResource(); try { if (!NSFileManager.DefaultManager.Remove(iosWrapper.Inner, out var error)) @@ -110,7 +122,9 @@ public async Task DeleteAsync(IStorableChild item, CancellationToken cancellatio } finally { - permissionRoot.StopAccessingSecurityScopedResource(); + if (accessStarted) + permissionRoot.StopAccessingSecurityScopedResource(); + await Task.CompletedTask; } } @@ -118,13 +132,22 @@ public async Task DeleteAsync(IStorableChild item, CancellationToken cancellatio /// public async Task CreateFolderAsync(string name, bool overwrite = false, CancellationToken cancellationToken = default) { - if (!permissionRoot.StartAccessingSecurityScopedResource()) - throw new UnauthorizedAccessException("Could not create iOS folder."); + var accessStarted = permissionRoot.StartAccessingSecurityScopedResource(); try { var path = Path.Combine(Id, name); NSFileAttributes? attributes = null; + // Return the existing folder. "False" overwrite contract is get-or-create + var isDirectory = false; + if (NSFileManager.DefaultManager.FileExists(path, ref isDirectory)) + { + if (!isDirectory) + throw new IOException($"A file with the name '{name}' already exists."); + + return new IOSFolder(new NSUrl(path, true), this, permissionRoot); + } + if (NSFileManager.DefaultManager.CreateDirectory(path, false, attributes, out var error)) return new IOSFolder(new NSUrl(path, true), this, permissionRoot); @@ -135,7 +158,9 @@ public async Task CreateFolderAsync(string name, bool overwrite = } finally { - permissionRoot.StopAccessingSecurityScopedResource(); + if (accessStarted) + permissionRoot.StopAccessingSecurityScopedResource(); + await Task.CompletedTask; } } @@ -143,14 +168,17 @@ public async Task CreateFolderAsync(string name, bool overwrite = /// public async Task CreateFileAsync(string name, bool overwrite = false, CancellationToken cancellationToken = default) { - if (!permissionRoot.StartAccessingSecurityScopedResource()) - throw new UnauthorizedAccessException("Could not create iOS file."); + var accessStarted = permissionRoot.StartAccessingSecurityScopedResource(); try { - var path = Path.Combine(Id, name); NSFileAttributes? attributes = null; + // NSFileManager.CreateFile unconditionally replaces existing content, but the + // contract for overwrite=false requires returning the existing file untouched + if (!overwrite && NSFileManager.DefaultManager.FileExists(path)) + return new IOSFile(new NSUrl(path, false), this, permissionRoot); + if (NSFileManager.DefaultManager.CreateFile(path, new NSData(), attributes)) return new IOSFile(new NSUrl(path, false), this, permissionRoot); @@ -158,7 +186,9 @@ public async Task CreateFileAsync(string name, bool overwrite = fals } finally { - permissionRoot.StopAccessingSecurityScopedResource(); + if (accessStarted) + permissionRoot.StopAccessingSecurityScopedResource(); + await Task.CompletedTask; } } @@ -177,8 +207,7 @@ public async Task CreateCopyOfAsync(IFile fileToCopy, bool overwrite if (fileToCopy is not IOSFile iosFile) return await fallback(this, fileToCopy, overwrite, newName, cancellationToken); - if (!permissionRoot.StartAccessingSecurityScopedResource()) - throw new UnauthorizedAccessException("Could not copy iOS file."); + var accessStarted = permissionRoot.StartAccessingSecurityScopedResource(); try { var newPath = Path.Combine(Id, newName); @@ -213,7 +242,9 @@ public async Task CreateCopyOfAsync(IFile fileToCopy, bool overwrite } finally { - permissionRoot.StopAccessingSecurityScopedResource(); + if (accessStarted) + permissionRoot.StopAccessingSecurityScopedResource(); + await Task.CompletedTask; } } @@ -233,8 +264,7 @@ public async Task MoveFromAsync(IChildFile fileToMove, IModifiableFo if (fileToMove is not IOSFile iosFile) return await fallback(this, fileToMove, source, overwrite, newName, cancellationToken); - if (!permissionRoot.StartAccessingSecurityScopedResource()) - throw new UnauthorizedAccessException("Could not move iOS file."); + var accessStarted = permissionRoot.StartAccessingSecurityScopedResource(); try { var newPath = Path.Combine(Id, newName); @@ -269,7 +299,9 @@ public async Task MoveFromAsync(IChildFile fileToMove, IModifiableFo } finally { - permissionRoot.StopAccessingSecurityScopedResource(); + if (accessStarted) + permissionRoot.StopAccessingSecurityScopedResource(); + await Task.CompletedTask; } } @@ -277,8 +309,7 @@ public async Task MoveFromAsync(IChildFile fileToMove, IModifiableFo /// public async Task RenameAsync(IStorableChild storable, string newName, CancellationToken cancellationToken = default) { - if (!permissionRoot.StartAccessingSecurityScopedResource()) - throw new UnauthorizedAccessException("Could not rename iOS item."); + var accessStarted = permissionRoot.StartAccessingSecurityScopedResource(); try { if (storable is not IWrapper iosWrapper) @@ -300,7 +331,9 @@ public async Task RenameAsync(IStorableChild storable, string ne } finally { - permissionRoot.StopAccessingSecurityScopedResource(); + if (accessStarted) + permissionRoot.StopAccessingSecurityScopedResource(); + await Task.CompletedTask; } } diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSSecurityScopedStream.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSSecurityScopedStream.cs index 51932c186..1e648a9dc 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSSecurityScopedStream.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSSecurityScopedStream.cs @@ -12,8 +12,8 @@ The above copyright notice and this permission notice shall be included in all c */ +using System.Runtime.CompilerServices; using Foundation; -using UIKit; namespace SecureFolderFS.Maui.Platforms.iOS.Storage { @@ -22,17 +22,31 @@ internal sealed class IOSSecurityScopedStream : Stream { private readonly NSUrl _url; private readonly FileStream _stream; - private readonly UIDocument _document; + private readonly bool _accessStarted; private readonly NSUrl _securityScopedAncestorUrl; - internal IOSSecurityScopedStream(NSUrl url, NSUrl securityScopedAncestorUrl, FileAccess access, FileShare share = FileShare.None) + internal IOSSecurityScopedStream(NSUrl url, NSUrl securityScopedAncestorUrl, FileAccess access, FileShare share) { - _document = new UIDocument(url); - var path = _document.FileUrl.Path!; _url = url; _securityScopedAncestorUrl = securityScopedAncestorUrl; - _securityScopedAncestorUrl.StartAccessingSecurityScopedResource(); - _stream = File.Open(path, FileMode.Open, access, share); + + // Track whether access was actually granted. Security scope access is reference + // counted per URL, and an unbalanced Stop could revoke access held elsewhere + _accessStarted = _securityScopedAncestorUrl.StartAccessingSecurityScopedResource(); + try + { + var path = url.FilePathUrl?.Path ?? url.Path!; + _stream = File.Open(path, FileMode.Open, access, share); + } + catch (Exception) + { + // Stop the security scope when the stream could not be opened, + // as Dispose will never run for a failed constructor + if (_accessStarted) + _securityScopedAncestorUrl.StopAccessingSecurityScopedResource(); + + throw; + } } /// @@ -75,15 +89,17 @@ public override void Write(byte[] buffer, int offset, int count) => _stream.Write(buffer, offset, count); /// + [MethodImpl(MethodImplOptions.NoOptimization | MethodImplOptions.NoInlining)] protected override void Dispose(bool disposing) { + _ = _url; // _url is kept intentionally base.Dispose(disposing); if (disposing) { _stream.Dispose(); - _document.Dispose(); - _securityScopedAncestorUrl.StopAccessingSecurityScopedResource(); + if (_accessStarted) + _securityScopedAncestorUrl.StopAccessingSecurityScopedResource(); } } } diff --git a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSStorable.cs b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSStorable.cs index a9b6bf1ce..f81229b62 100644 --- a/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSStorable.cs +++ b/src/Platforms/SecureFolderFS.Maui/Platforms/iOS/Storage/IOSStorable.cs @@ -2,7 +2,6 @@ using OwlCore.Storage; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Storage; -using UIKit; using Constants = SecureFolderFS.UI.Constants; namespace SecureFolderFS.Maui.Platforms.iOS.Storage @@ -47,21 +46,24 @@ protected IOSStorable(NSUrl url, IOSFolder? parent = null, NSUrl? permissionRoot /// public virtual async Task AddBookmarkAsync(CancellationToken cancellationToken = default) { + // StartAccessingSecurityScopedResource returns false for URLs that are not + // security-scoped (e.g., items in the app sandbox) - proceed in that case, + // and only balance with a Stop call when access was actually started + var accessStarted = permissionRoot.StartAccessingSecurityScopedResource(); try { - if (!permissionRoot.StartAccessingSecurityScopedResource()) - return; - var nsDataBookmark = Inner.CreateBookmarkData(NSUrlBookmarkCreationOptions.SuitableForBookmarkFile, Array.Empty(), null, out var error); if (error is not null) - return; + throw new NSErrorException(error); var nsEncodedBookmark = nsDataBookmark.GetBase64EncodedString(NSDataBase64EncodingOptions.None); BookmarkId = $"{Constants.STORABLE_BOOKMARK_RID}{nsEncodedBookmark}"; } finally { - permissionRoot.StopAccessingSecurityScopedResource(); + if (accessStarted) + permissionRoot.StopAccessingSecurityScopedResource(); + await Task.CompletedTask; } } @@ -75,10 +77,9 @@ public virtual Task RemoveBookmarkAsync(CancellationToken cancellationToken = de protected static void GetImmediateProperties(NSUrl url, out string? id, out string? name) { - using var document = new UIDocument(url); - id = document.FileUrl?.Path ?? url.FilePathUrl?.Path; - name = !string.IsNullOrEmpty(document.LocalizedName) - ? document.LocalizedName : (Path.GetFileName(id) ?? url.FilePathUrl?.LastPathComponent); + // Resolve the path directly from the URL + id = url.FilePathUrl?.Path ?? url.Path; + name = Path.GetFileName(id?.TrimEnd('/')) ?? url.LastPathComponent; } protected static IStorableChild NewStorage(NSUrl url, IOSFolder? parent = null, NSUrl? permissionRoot = null) diff --git a/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml b/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml index 4471df128..a839d1369 100644 --- a/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Popups/CredentialsPopup.xaml @@ -119,6 +119,7 @@ + public event EventHandler? VaultUnlocked; + /// + public event EventHandler? StateChanged; + public LoginViewModel(IFolder vaultFolder, LoginViewType loginViewMode, KeySequence? keySequence = null) { ServiceProvider = DI.Default; @@ -97,10 +99,15 @@ public async Task InitAsync(CancellationToken cancellationToken = default) _loginSequence = new(loginItems); IsLoginSequence = _loginSequence.Count > 1; - // Set up the first authentication method - var result = ProceedAuthentication(); - if (!result.Successful) - CurrentViewModel = new ErrorViewModel(result); + // Set up the first authentication method. At this point the sequence should advance to the + // first method; a Completed step means the vault has no usable authentication methods + CurrentViewModel = ProceedAuthentication(out var result) switch + { + AuthenticationStep.Faulted => new ErrorViewModel(result), + AuthenticationStep.Completed => new ErrorViewModel(new MessageResult(false, + "No authentication methods available.")), + _ => CurrentViewModel + }; } catch (NotSupportedException) { @@ -156,8 +163,10 @@ private async Task RecoverAccessAsync(string? recoveryKey, CancellationToken can } catch (Exception ex) { - // TODO: Report to user - _ = ex; + // Notify both the current authentication view and any listening host + var result = Result.Failure(ex); + CurrentViewModel?.Report(result); + StateChanged?.Invoke(this, new ErrorReportedEventArgs(result)); } } } @@ -192,9 +201,12 @@ private void RestartLoginProcess() // Dispose built key sequence _keySequence.Dispose(); _loginSequence?.Reset(); - var result = ProceedAuthentication(); - if (!result.Successful) - CurrentViewModel = new ErrorViewModel(result); + CurrentViewModel = ProceedAuthentication(out var result) switch + { + AuthenticationStep.Faulted => new ErrorViewModel(result), + AuthenticationStep.Completed => new ErrorViewModel(new MessageResult(false, "No authentication methods available.")), + _ => CurrentViewModel + }; } private async Task TryUnlockAsync(CancellationToken cancellationToken = default) @@ -228,22 +240,32 @@ private async Task TryUnlockAsync(CancellationToken cancellationToken = de } } - private IResult ProceedAuthentication() + /// + /// Advances the authentication sequence to the next method. + /// + /// + /// When the step is , contains the failure; otherwise . + /// + /// The outcome of advancing the sequence. + private AuthenticationStep ProceedAuthentication(out IResult result) { + result = Result.Success; try { + // MoveNext returning false is the legitimate end-of-sequence signal, not an error if (_loginSequence is null || !_loginSequence.MoveNext()) - return new MessageResult(false, "No authentication methods available."); + return AuthenticationStep.Completed; // Get the appropriate method var viewModel = _loginSequence.Current; CurrentViewModel = viewModel; - return Result.Success; + return AuthenticationStep.Advanced; } catch (Exception ex) { - return Result.Failure(ex); + result = Result.Failure(ex); + return AuthenticationStep.Faulted; } } @@ -288,15 +310,21 @@ private async void CurrentViewModel_CredentialsProvided(object? sender, Credenti // Add authentication _keySequence.Add(e.Authentication); - var result = ProceedAuthentication(); - if (!result.Successful && CurrentViewModel is not ErrorViewModel) + var step = ProceedAuthentication(out var result); + switch (step) { - // Reached the end in which case we should try to unlock the vault - if (!await TryUnlockAsync()) - { - // If login failed, restart the process - RestartLoginProcess(); - } + case AuthenticationStep.Faulted: + // A genuine error while advancing - report it + CurrentViewModel?.Report(result); + break; + + case AuthenticationStep.Completed: + // Reached the end of the sequence - try to unlock the vault + if (!await TryUnlockAsync()) + RestartLoginProcess(); + break; + + // AuthenticationStep.Advanced: the next method's view is now shown, nothing to do } } finally @@ -308,16 +336,12 @@ private async void CurrentViewModel_CredentialsProvided(object? sender, Credenti partial void OnCurrentViewModelChanged(ReportableViewModel? oldValue, ReportableViewModel? newValue) { // Detach old - if (oldValue is not null) - oldValue.StateChanged -= CurrentViewModel_StateChanged; - + oldValue?.StateChanged -= CurrentViewModel_StateChanged; if (oldValue is AuthenticationViewModel oldViewModel) oldViewModel.CredentialsProvided -= CurrentViewModel_CredentialsProvided; // Attach new - if (newValue is not null) - newValue.StateChanged += CurrentViewModel_StateChanged; - + newValue?.StateChanged += CurrentViewModel_StateChanged; if (newValue is AuthenticationViewModel newViewModel) { newViewModel.CredentialsProvided += CurrentViewModel_CredentialsProvided; @@ -331,12 +355,11 @@ partial void OnCurrentViewModelChanged(ReportableViewModel? oldValue, Reportable public void Dispose() { VaultUnlocked = null; + StateChanged = null; _vaultWatcherModel.StateChanged -= VaultWatcherModel_StateChanged; (CurrentViewModel as IDisposable)?.Dispose(); - if (CurrentViewModel is not null) - CurrentViewModel.StateChanged -= CurrentViewModel_StateChanged; - + CurrentViewModel?.StateChanged -= CurrentViewModel_StateChanged; if (CurrentViewModel is AuthenticationViewModel authenticationViewModel) authenticationViewModel.CredentialsProvided -= CurrentViewModel_CredentialsProvided; diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs index d6801df64..47190409c 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/BrowserItemViewModel.cs @@ -74,7 +74,19 @@ protected virtual async Task OpenInExternalAppAsync(CancellationToken cancellati if (Inner is not IFile file) return; - await ShareService.OpenFileWithAsync(file); + try + { + await ShareService.OpenFileWithAsync(file); + } + catch (OperationCanceledException) + { + // Cancellation, nothing to report + } + catch (Exception) + { + if (BrowserViewModel.TransferViewModel is { } transferViewModel) + await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); + } } [RelayCommand] @@ -117,12 +129,12 @@ protected virtual async Task MoveAsync(CancellationToken cancellationToken) if (destinationViewModel is null || destinationViewModel.Inner.Id != destination.Id) return; - if (items.Any(item => destination.Id.Contains(item.Inner.Id, StringComparison.InvariantCultureIgnoreCase))) + if (items.Any(item => IsAncestorOrSelf(destination.Id, item.Inner.Id))) return; - // Ensure the destination has content already loaded + // Ensure the destination has content already loaded before collision checks if (destinationViewModel.Items.IsEmpty()) - _ = destinationViewModel.ListContentsAsync(cts.Token); + await destinationViewModel.ListContentsAsync(cts.Token); await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner), async (item, reporter, token) => { @@ -148,10 +160,13 @@ await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner) }, BrowserViewModel.Layouts.GetSorter()); }, cts.Token); } - catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + catch (OperationCanceledException) + { + // Cancellation, nothing to report + } + catch (Exception) { - _ = ex; - // TODO: Report error + await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); } finally { @@ -190,12 +205,12 @@ protected virtual async Task CopyAsync(CancellationToken cancellationToken) if (destinationViewModel is null || destinationViewModel.Inner.Id != destination.Id) return; - if (items.Any(item => destination.Id.Contains(item.Inner.Id, StringComparison.InvariantCultureIgnoreCase))) + if (items.Any(item => IsAncestorOrSelf(destination.Id, item.Inner.Id))) return; - // Ensure the destination has content already loaded + // Ensure the destination has content already loaded before collision checks if (destinationViewModel.Items.IsEmpty()) - _ = destinationViewModel.ListContentsAsync(cts.Token); + await destinationViewModel.ListContentsAsync(cts.Token); await transferViewModel.TransferAsync(items.Select(x => x.Inner), async (item, reporter, token) => { @@ -205,10 +220,6 @@ await transferViewModel.TransferAsync(items.Select(x => x.Inner), async (item, r // Copy var copiedItem = await modifiableDestination.CreateCopyOfStorableAsync(item, false, availableName, reporter, token); - // Ensure the destination has content already loaded - if (destinationViewModel.Items.IsEmpty()) - _ = destinationViewModel.ListContentsAsync(cts.Token); - // Add to destination destinationViewModel.Items.Insert(copiedItem switch { @@ -218,10 +229,13 @@ await transferViewModel.TransferAsync(items.Select(x => x.Inner), async (item, r }, BrowserViewModel.Layouts.GetSorter()); }, cts.Token); } - catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + catch (OperationCanceledException) + { + // Cancellation, nothing to report + } + catch (Exception) { - _ = ex; - // TODO: Report error + await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); } finally { @@ -272,10 +286,14 @@ await OverlayService.ShowAsync(new MessageOverlayViewModel() UpdateStorable(renamedStorable); _ = InitAsync(cancellationToken); } - catch (Exception ex) + catch (OperationCanceledException) + { + // Cancellation, nothing to report + } + catch (Exception) { - // TODO: Report error - _ = ex; + if (BrowserViewModel.TransferViewModel is { } transferViewModel) + await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); } } @@ -312,23 +330,28 @@ protected virtual async Task DeleteAsync(CancellationToken cancellationToken) if (recycleBin is null) return; - var sizes = new List(); + // Size calculation may take a while for large folders - show a cancellable indeterminate state + transferViewModel.ShowIndeterminate("Calculating".ToLocalized()); + + // Key the sizes by item ID - the transfer callback receives the storable, + // so positional lookups against the view model array would not match + var sizes = new Dictionary(); foreach (var item in items) { - sizes.Add(item.Inner switch + cts.Token.ThrowIfCancellationRequested(); + sizes[item.Inner.Id] = item.Inner switch { IFile file => await file.GetSizeAsync(cts.Token) ?? 0L, IFolder folder => await folder.GetSizeAsync(cts.Token) ?? 0L, _ => 0L - }); + }; } if (BrowserViewModel.Options.IsRecycleBinUnlimited()) { await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner), async (item, token) => { - var idx = Array.IndexOf(items, item); - await recyclableFolder.DeleteAsync(item, sizes[idx], false, token); + await recyclableFolder.DeleteAsync(item, sizes.GetValueOrDefault(item.Id, 0L), false, token); var itemToRemove = ParentFolder.Items.FirstOrDefault(x => x.Inner.Id == item.Id); if (itemToRemove is not null) @@ -339,7 +362,7 @@ await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner) { var occupiedSize = await recycleBin.GetSizeAsync(cts.Token); var availableSize = BrowserViewModel.Options.RecycleBinSize - occupiedSize; - var permanently = availableSize < sizes.Sum(); + var permanently = availableSize < sizes.Values.Sum(); if (permanently) { @@ -356,16 +379,13 @@ await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner) return; } - var idx = 0; await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner), async (item, token) => { - await recyclableFolder.DeleteAsync(item, sizes[idx], permanently, token); + await recyclableFolder.DeleteAsync(item, sizes.GetValueOrDefault(item.Id, 0L), permanently, token); var itemToRemove = ParentFolder.Items.FirstOrDefault(x => x.Inner.Id == item.Id); if (itemToRemove is not null) ParentFolder.Items.RemoveAndGet(itemToRemove)?.Dispose(); - - idx++; }, cts.Token); } } @@ -396,10 +416,13 @@ await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner) }, cts.Token); } } - catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + catch (OperationCanceledException) { - _ = ex; - // TODO: Report error + // Cancellation, nothing to report + } + catch (Exception) + { + await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); } finally { @@ -410,7 +433,7 @@ await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner) [RelayCommand] protected virtual async Task ExportAsync(CancellationToken cancellationToken) { - if (ParentFolder?.Folder is not IModifiableFolder parentModifiableFolder) + if (ParentFolder is null) return; if (BrowserViewModel.TransferViewModel is not { IsProgressing: false } transferViewModel) @@ -424,19 +447,59 @@ protected virtual async Task ExportAsync(CancellationToken cancellationToken) if (destination is not IModifiableFolder destinationFolder) return; - transferViewModel.TransferType = TransferType.Move; - using var cts = transferViewModel.GetCancellation(cancellationToken); - await transferViewModel.TransferAsync(items.Select(x => x.Inner), async (item, reporter, token) => - { - // Copy and delete - await destinationFolder.CreateCopyOfStorableAsync(item, false, reporter, token); - await parentModifiableFolder.DeleteAsync((IStorableChild)item, token); + // Sources in read-only vaults cannot be removed - export becomes a copy + var removeSource = !BrowserViewModel.Options.IsReadOnly && ParentFolder.Folder is IModifiableFolder; - ParentFolder.Items.RemoveMatch(x => x.Inner.Id == item.Id)?.Dispose(); - }, cts.Token); + try + { + transferViewModel.TransferType = removeSource ? TransferType.Move : TransferType.Copy; + using var cts = transferViewModel.GetCancellation(cancellationToken); + await transferViewModel.TransferAsync(items.Select(x => x.Inner), async (item, reporter, token) => + { + // Copy and delete + await destinationFolder.CreateCopyOfStorableAsync(item, false, reporter, token); + if (removeSource && ParentFolder.Folder is IModifiableFolder parentModifiableFolder) + { + await parentModifiableFolder.DeleteAsync((IStorableChild)item, token); + ParentFolder.Items.RemoveMatch(x => x.Inner.Id == item.Id)?.Dispose(); + } + }, cts.Token); + } + catch (OperationCanceledException) + { + // Cancellation, nothing to report + } + catch (Exception) + { + await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); + } + finally + { + await transferViewModel.HideAsync(); + } } [RelayCommand] protected abstract Task OpenAsync(CancellationToken cancellationToken); + + /// + /// Determines whether points to itself + /// or to a descendant of it, respecting path segment boundaries. + /// + /// + /// A plain substring check would misclassify sibling paths that share a prefix (e.g. '/a/bc' and '/a/b'). + /// + private static bool IsAncestorOrSelf(string destinationId, string itemId) + { + if (destinationId.Equals(itemId, StringComparison.OrdinalIgnoreCase)) + return true; + + if (!destinationId.StartsWith(itemId, StringComparison.OrdinalIgnoreCase)) + return false; + + // The prefix must end exactly at a path separator boundary + return itemId.EndsWith('/') || itemId.EndsWith('\\') + || destinationId[itemId.Length] is '/' or '\\'; + } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs index 4f0282e0f..b315f7a40 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FileViewModel.cs @@ -81,9 +81,9 @@ private async Task PerformFileLoadAsync(CancellationToken cancellationToken) if (!SettingsService.UserSettings.AreThumbnailsEnabled || !CanLoadThumbnail()) return; - // Try to get from the cache first + // Try to get from the cache first and reuse the already-fetched modification date instead of querying it again Thumbnail?.Dispose(); - var cacheKey = await ThumbnailCacheModel.GetCacheKeyAsync(File, cancellationToken); + var cacheKey = ThumbnailCacheModel.GetCacheKey(File.Id, LastModified); var cachedStream = await BrowserViewModel.ThumbnailCache.TryGetCachedThumbnailAsync(cacheKey, cancellationToken); if (cachedStream is not null) { @@ -97,9 +97,12 @@ private async Task PerformFileLoadAsync(CancellationToken cancellationToken) if (generatedThumbnail is null) return; - // Show and cache the generated thumbnail + // Cache the thumbnail BEFORE handing the stream to the UI (use await) because both the cache copy + // and the image decoder reposition the same stream, so they must not overlap + await BrowserViewModel.ThumbnailCache.CacheThumbnailAsync(cacheKey, generatedThumbnail, cancellationToken); + + // Show the generated thumbnail Thumbnail = generatedThumbnail; - _ = BrowserViewModel.ThumbnailCache.CacheThumbnailAsync(cacheKey, generatedThumbnail, cancellationToken); } /// @@ -133,41 +136,53 @@ protected override async Task OpenAsync(CancellationToken cancellationToken) if (BrowserViewModel.TransferViewModel?.IsPickingItems() ?? false) return; - using var viewModel = new PreviewerOverlayViewModel(this, ParentFolder); - await viewModel.InitAsync(cancellationToken); - await OverlayService.ShowAsync(viewModel); - - if (BrowserViewModel.Options.IsReadOnly) - return; - - if (viewModel.PreviewerViewModel is IChangeTracker { WasModified: true } and IPersistable persistable) + try { - var messageOverlay = new MessageOverlayViewModel() - { - Title = "UnsavedChanges".ToLocalized(), - Message = "UnsavedChangesDescription".ToLocalized(), - PrimaryText = "Save".ToLocalized(), - SecondaryText = "Cancel".ToLocalized() - }; - - await Task.Delay(700); - var result = await OverlayService.ShowAsync(messageOverlay); - if (!result.Positive()) + using var viewModel = new PreviewerOverlayViewModel(this, ParentFolder); + await viewModel.InitAsync(cancellationToken); + await OverlayService.ShowAsync(viewModel); + + if (BrowserViewModel.Options.IsReadOnly) return; - if (BrowserViewModel.TransferViewModel is { } transferViewModel) + if (viewModel.PreviewerViewModel is IChangeTracker { WasModified: true } and IPersistable persistable) { - transferViewModel.TransferType = TransferType.Save; - using var saveCancellation = transferViewModel.GetCancellation(); - await transferViewModel.PerformOperationAsync(async ct => + var messageOverlay = new MessageOverlayViewModel() { - await persistable.SaveAsync(ct); - }, saveCancellation.Token); + Title = "UnsavedChanges".ToLocalized(), + Message = "UnsavedChangesDescription".ToLocalized(), + PrimaryText = "Save".ToLocalized(), + SecondaryText = "Cancel".ToLocalized() + }; + + await Task.Delay(700, CancellationToken.None); + var result = await OverlayService.ShowAsync(messageOverlay); + if (!result.Positive()) + return; + + if (BrowserViewModel.TransferViewModel is { } transferViewModel) + { + transferViewModel.TransferType = TransferType.Save; + using var saveCancellation = transferViewModel.GetCancellation(); + await transferViewModel.PerformOperationAsync(async ct => + { + await persistable.SaveAsync(ct); + }, saveCancellation.Token); + } + else + await persistable.SaveAsync(cancellationToken); + + await PerformFileLoadAsync(cancellationToken); } - else - await persistable.SaveAsync(cancellationToken); - - await PerformFileLoadAsync(cancellationToken); + } + catch (OperationCanceledException) + { + // Cancellation, nothing to report + } + catch (Exception) + { + if (BrowserViewModel.TransferViewModel is { } transferViewModel) + await transferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs index b87568a33..7e1d614a0 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Storage/Browser/FolderViewModel.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; using OwlCore.Storage; using SecureFolderFS.Sdk.Attributes; +using SecureFolderFS.Sdk.Extensions; using SecureFolderFS.Sdk.Services; using SecureFolderFS.Sdk.ViewModels.Views.Vault; using SecureFolderFS.Shared; @@ -22,6 +23,8 @@ namespace SecureFolderFS.Sdk.ViewModels.Controls.Storage.Browser [Bindable(true)] public partial class FolderViewModel : BrowserItemViewModel, IViewDesignation { + private CancellationTokenSource? _listingCts; + /// /// Gets the folder associated with this view model. /// @@ -67,7 +70,6 @@ public virtual void OnAppearing() /// public virtual void OnDisappearing() { - Items.DisposeAll(); } /// @@ -77,25 +79,52 @@ public virtual void OnDisappearing() /// A that represents the asynchronous operation. public async Task ListContentsAsync(CancellationToken cancellationToken = default) { - var scope = Logger.GetPerformanceScope(); - SelectedItems.Clear(); - Items.DisposeAll(); - Items.Clear(); - - var isPickingFolder = BrowserViewModel.IsPickingFolder; - var items = await Folder.GetItemsAsync(isPickingFolder ? StorableType.Folder : StorableType.All, cancellationToken).ToArrayAsyncImpl(cancellationToken: cancellationToken); - BrowserViewModel.Layouts.GetSorter().SortCollection(items.Where(x => !isPickingFolder || x is IFolder).Select(x => (BrowserItemViewModel)(x switch - { - IFile file => new FileViewModel(file, BrowserViewModel, this), - IFolder folder => new FolderViewModel(folder, BrowserViewModel, this), - _ => throw new ArgumentOutOfRangeException(nameof(x)) - })), Items); + // Cancel any listing already in flight + if (_listingCts is not null) + await _listingCts.CancelAsync(); - // Apply adaptive layout - if (SettingsService.UserSettings.IsAdaptiveLayoutEnabled && BrowserViewModel.TransferViewModel is { IsPickingFolder: false }) - ApplyAdaptiveLayout(); + _listingCts?.Dispose(); + _listingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var token = _listingCts.Token; - Logger.LogPerformance(scope, minThresholdMs: 200); + try + { + var scope = Logger.GetPerformanceScope(); + + // Enumerate before mutating the collection, so a canceled or failed + // enumeration leaves the current contents intact + var isPickingFolder = BrowserViewModel.IsPickingFolder; + var items = await Folder.GetItemsAsync(isPickingFolder ? StorableType.Folder : StorableType.All, token).ToArrayAsyncImpl(cancellationToken: token); + token.ThrowIfCancellationRequested(); + + SelectedItems.Clear(); + Items.DisposeAll(); + Items.Clear(); + + BrowserViewModel.Layouts.GetSorter().SortCollection(items.Where(x => !isPickingFolder || x is IFolder).Select(x => (BrowserItemViewModel)(x switch + { + IFile file => new FileViewModel(file, BrowserViewModel, this), + IFolder folder => new FolderViewModel(folder, BrowserViewModel, this), + _ => throw new ArgumentOutOfRangeException(nameof(x)) + })), Items); + + // Apply adaptive layout + if (SettingsService.UserSettings.IsAdaptiveLayoutEnabled && BrowserViewModel.TransferViewModel is { IsPickingFolder: false }) + ApplyAdaptiveLayout(); + + Logger.LogPerformance(scope, minThresholdMs: 200); + } + catch (OperationCanceledException) + { + // A newer listing superseded this one (or the caller canceled) + } + catch (Exception ex) + { + // Only inform the user that the refresh failed and leave existing contents intact + Logger.LogError(ex, "Failed to list the contents of a folder."); + if (BrowserViewModel.TransferViewModel is { } transferViewModel) + await transferViewModel.ReportErrorAsync("FolderLoadFailed".ToLocalized()); + } } /// @@ -116,10 +145,31 @@ protected override async Task OpenAsync(CancellationToken cancellationToken) private void ApplyAdaptiveLayout() { var itemCount = Items.Count; - var folderPercentage = 100f * Items.Count(IsFolder) / itemCount; - var imagePercentage = 100f * Items.Count(IsImage) / itemCount; - var mediaPercentage = 100f * Items.Count(IsMedia) / itemCount; - var documentPercentage = 100f * Items.Count(IsDocument) / itemCount; + if (itemCount == 0) + return; + + // Classify each item once + int folders = 0, images = 0, media = 0, documents = 0; + foreach (var item in Items) + { + if (item.Inner is IFolder) + { + folders++; + continue; + } + + switch (FileTypeHelper.GetTypeHint(item.Inner)) + { + case TypeHint.Image: images++; break; + case TypeHint.Media: media++; break; + case TypeHint.Document or TypeHint.Plaintext: documents++; break; + } + } + + var folderPercentage = 100f * folders / itemCount; + var imagePercentage = 100f * images / itemCount; + var mediaPercentage = 100f * media / itemCount; + var documentPercentage = 100f * documents / itemCount; var otherPercentage = 100f - (folderPercentage + imagePercentage + mediaPercentage + documentPercentage); var galleryView = BrowserViewModel.Layouts.ViewOptions.FirstOrDefault(x => x.Id == "GalleryView"); @@ -154,18 +204,6 @@ private void ApplyAdaptiveLayout() // ListView BrowserViewModel.Layouts.CurrentViewOption = listView; } - - static bool IsFolder(BrowserItemViewModel item) - => item.Inner is IFolder; - - static bool IsImage(BrowserItemViewModel item) - => FileTypeHelper.GetTypeHint(item.Inner) is TypeHint.Image; - - static bool IsMedia(BrowserItemViewModel item) - => FileTypeHelper.GetTypeHint(item.Inner) is TypeHint.Media; - - static bool IsDocument(BrowserItemViewModel item) - => FileTypeHelper.GetTypeHint(item.Inner) is TypeHint.Document or TypeHint.Plaintext; } } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs index daeffdd17..00b98dd98 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/Transfer/TransferViewModel.cs @@ -11,6 +11,7 @@ using SecureFolderFS.Sdk.Extensions; using SecureFolderFS.Sdk.ViewModels.Views.Vault; using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Storage.Pickers; namespace SecureFolderFS.Sdk.ViewModels.Controls.Transfer @@ -18,25 +19,117 @@ namespace SecureFolderFS.Sdk.ViewModels.Controls.Transfer [Bindable(true)] public sealed partial class TransferViewModel : ObservableObject, IViewable, IProgress, IFolderPicker { + private const int ERROR_DISPLAY_DURATION_MS = 5000; + private readonly BrowserViewModel _browserViewModel; + private readonly SynchronizationContext? _synchronizationContext; private TaskCompletionSource? _tcs; private CancellationTokenSource? _cts; + private CancellationTokenSource? _errorCts; [ObservableProperty] private string? _Title; [ObservableProperty] private bool _CanCancel; [ObservableProperty] private bool _IsVisible; [ObservableProperty] private bool _IsProgressing; [ObservableProperty] private bool _IsPickingFolder; + [ObservableProperty] private bool _IsErrorVisible; [ObservableProperty] private TransferType _TransferType; public TransferViewModel(BrowserViewModel browserViewModel) { _browserViewModel = browserViewModel; + _synchronizationContext = SynchronizationContext.Current; } /// public void Report(TotalProgress value) { + // Progress is reported from worker threads, but Title is UI-bound. + // Marshal the update back to the context this view model was created on + if (_synchronizationContext != SynchronizationContext.Current) + { + _synchronizationContext.PostOrExecute(static state => + { + var (self, progress) = ((TransferViewModel, TotalProgress))state!; + self.ReportCore(progress); + }, (this, value)); + + return; + } + + ReportCore(value); + } + + /// + /// Shows an indeterminate progress state, e.g. while item sizes are being calculated before a transfer. + /// + /// The title to display while the operation is running. + public void ShowIndeterminate(string title) + { + ClearError(); + Title = title; + IsProgressing = true; + IsVisible = true; + } + + /// + /// Dismisses any operation UI and shows as a transient error banner. + /// The banner disappears on its own or when dismissed by the user. + /// + /// The error message to display. + /// A that completes once the banner is shown. + public async Task ReportErrorAsync(string message) + { + ClearError(); + _errorCts = new(); + var token = _errorCts.Token; + + // Finish and dismiss the current operation UI before revealing the error + if (IsVisible) + await this.HideAsync(); + + if (token.IsCancellationRequested) + return; + + Title = message; + IsErrorVisible = true; + IsProgressing = false; + CanCancel = true; + IsVisible = true; + + _ = DismissErrorLaterAsync(token); + } + + private async Task DismissErrorLaterAsync(CancellationToken token) + { + try + { + await Task.Delay(ERROR_DISPLAY_DURATION_MS, token); + IsErrorVisible = false; + await this.HideAsync(); + } + catch (OperationCanceledException) + { + // A newer operation or an explicit dismissal took over the control + } + } + + /// + /// Clears the error state, if any, allowing the control to display a new operation. + /// + public void ClearError() + { + _errorCts?.TryCancel(); + _errorCts?.Dispose(); + _errorCts = null; + IsErrorVisible = false; + } + + private void ReportCore(TotalProgress value) + { + if (IsErrorVisible) + return; + if (value.Achieved >= value.Total && value.Total > 0) { Title = "TransferDone".ToLocalized(); @@ -62,6 +155,7 @@ object GetInterpolation() public CancellationTokenSource GetCancellation(CancellationToken? linkToken = null) { + ClearError(); _cts?.Dispose(); _cts = linkToken is not null ? CancellationTokenSource.CreateLinkedTokenSource(linkToken.Value) @@ -94,6 +188,10 @@ public CancellationTokenSource GetCancellation(CancellationToken? linkToken = nu IsPickingFolder = false; if (_tcs?.TrySetCanceled(CancellationToken.None) ?? false) await this.HideAsync(); + + // Clear the completed source, otherwise a later Cancel would be routed + // to the finished pick instead of cancelling the running transfer + _tcs = null; } } @@ -107,6 +205,14 @@ private void Confirm() [RelayCommand] private async Task CancelAsync() { + if (IsErrorVisible) + { + // Dismiss the error banner since there is no operation left to cancel + ClearError(); + await this.HideAsync(); + return; + } + if (_tcs is not null) { _tcs.TrySetCanceled(CancellationToken.None); diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListItemViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListItemViewModel.cs index 397ebeb41..65e46c5b2 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListItemViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/VaultList/VaultListItemViewModel.cs @@ -1,6 +1,12 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; +using Microsoft.Extensions.Logging; using OwlCore.Storage; using SecureFolderFS.Sdk.Attributes; using SecureFolderFS.Sdk.DataModels; @@ -17,11 +23,6 @@ using SecureFolderFS.Shared.Models; using SecureFolderFS.Storage; using SecureFolderFS.Storage.Extensions; -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Threading; -using System.Threading.Tasks; namespace SecureFolderFS.Sdk.ViewModels.Controls.VaultList { @@ -93,15 +94,21 @@ private async Task CustomizeAsync(string? option, CancellationToken cancellation if (sourceIconFile is null) return; - // TODO: Configured icon causes a crash when debugger is not attached - // Update vault icon - //var destinationIconFile = await modifiableFolder.CreateFileAsync(Constants.Vault.VAULT_ICON_FILENAME, true, cancellationToken); - //await sourceIconFile.CopyContentsToAsync(destinationIconFile, cancellationToken); // TODO: Resize icon (don't load large icons) - //await UpdateIconAsync(cancellationToken); - - // Update folder icon - await using var iconStream = await sourceIconFile.OpenReadAsync(cancellationToken); - await MediaService.TrySetFolderIconAsync(modifiableFolder, iconStream, cancellationToken); + // Guard the icon update so a decode/IO failure (e.g., an unsupported or oversized image) + // degrades gracefully instead of crashing the command + try + { + await using var iconStream = await sourceIconFile.OpenReadAsync(cancellationToken); + await MediaService.TrySetFolderIconAsync(modifiableFolder, iconStream, cancellationToken); + } + catch (OperationCanceledException) + { + // Cancellation. Nothing to report + } + catch (Exception ex) + { + DI.OptionalService()?.LogError(ex, "Failed to set the vault folder icon."); + } break; } @@ -155,7 +162,7 @@ private async Task RevealFolderAsync(CancellationToken cancellationToken) [RelayCommand] private async Task CreateShortcutAsync(CancellationToken cancellationToken) { - if (VaultViewModel.VaultModel.VaultFolder is not { } vaultFolder) + if (VaultViewModel.VaultModel.VaultFolder is null) return; // Check Iap Plus requirement @@ -172,7 +179,7 @@ private async Task CreateShortcutAsync(CancellationToken cancellationToken) var filter = new Dictionary { - { "SecureFolderFS Vault", VaultService.ShortcutFileExtension } + { $"{"Vault".ToLocalized()} ({nameof(SecureFolderFS)})", VaultService.ShortcutFileExtension } }; await FileExplorerService.SaveFileAsync(suggestedName, dataStream, filter, cancellationToken); diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsConfirmationViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsConfirmationViewModel.cs index 97a846501..adce0729a 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsConfirmationViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsConfirmationViewModel.cs @@ -4,7 +4,6 @@ using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; using OwlCore.Storage; using SecureFolderFS.Sdk.AppModels; using SecureFolderFS.Sdk.Attributes; @@ -47,13 +46,26 @@ public CredentialsConfirmationViewModel(IFolder vaultFolder, RegisterViewModel r RegisterViewModel.CredentialsProvided += RegisterViewModel_CredentialsProvided; } - [RelayCommand] - public async Task ConfirmAsync(CancellationToken cancellationToken) + /// + /// Confirms the credentials by performing either a removal or modification operation, depending on the current context. + /// + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is a result of type indicating the success or failure of the operation. + public async Task ConfirmAsync(CancellationToken cancellationToken = default) { - if (IsRemoving) - await RemoveAsync(cancellationToken); - else - await ModifyAsync(cancellationToken); + try + { + if (IsRemoving) + await RemoveAsync(cancellationToken); + else + await ModifyAsync(cancellationToken); + + return Result.Success; + } + catch (Exception ex) + { + return Result.Failure(ex); + } } private async Task ModifyAsync(CancellationToken cancellationToken) diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsResetViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsResetViewModel.cs index b01cff97f..50ebfd34b 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsResetViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsResetViewModel.cs @@ -5,7 +5,6 @@ using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; using OwlCore.Storage; using SecureFolderFS.Sdk.AppModels; using SecureFolderFS.Sdk.Attributes; @@ -55,22 +54,35 @@ public async Task InitAsync(CancellationToken cancellationToken = default) RegisterViewModel.CurrentViewModel = AuthenticationOptions.FirstOrDefault(); } - [RelayCommand] - public async Task ConfirmAsync(CancellationToken cancellationToken) + /// + /// Confirms the credentials reset process and applies the updated authentication configuration for the vault. + /// + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is an indicating the success or failure of the confirmation process. + public async Task ConfirmAsync(CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(RegisterViewModel.CurrentViewModel); - RegisterViewModel.ConfirmCredentialsCommand.Execute(null); - - var key = await _credentialsTcs.Task; - var configuredOptions = await VaultService.GetVaultOptionsAsync(_vaultFolder, cancellationToken); - var newOptions = configuredOptions with + try { - UnlockProcedure = new AuthenticationMethod([ RegisterViewModel.CurrentViewModel.Id ], null) - }; + ArgumentNullException.ThrowIfNull(RegisterViewModel.CurrentViewModel); + RegisterViewModel.ConfirmCredentialsCommand.Execute(null); + + var key = await _credentialsTcs.Task; + var configuredOptions = await VaultService.GetVaultOptionsAsync(_vaultFolder, cancellationToken); + var newOptions = configuredOptions with + { + UnlockProcedure = new AuthenticationMethod([ RegisterViewModel.CurrentViewModel.Id ], null) + }; - await VaultManagerService.ModifyAuthenticationAsync(_vaultFolder, _unlockContract, key, newOptions, cancellationToken); - if (!string.IsNullOrEmpty(configuredOptions.VaultId)) - PersistedCredentialsModel.Instance.Remove(configuredOptions.VaultId); + await VaultManagerService.ModifyAuthenticationAsync(_vaultFolder, _unlockContract, key, newOptions, cancellationToken); + if (!string.IsNullOrEmpty(configuredOptions.VaultId)) + PersistedCredentialsModel.Instance.Remove(configuredOptions.VaultId); + + return Result.Success; + } + catch (Exception ex) + { + return Result.Failure(ex); + } } private void RegisterViewModel_CredentialsProvided(object? sender, CredentialsProvidedEventArgs e) diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/CredentialsOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/CredentialsOverlayViewModel.cs index 5e5bed8d4..a13c17a6d 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/CredentialsOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/CredentialsOverlayViewModel.cs @@ -21,7 +21,7 @@ namespace SecureFolderFS.Sdk.ViewModels.Views.Overlays { [Bindable(true)] [Inject, Inject] - public sealed partial class CredentialsOverlayViewModel : OverlayViewModel, IAsyncInitialize, IDisposable + public sealed partial class CredentialsOverlayViewModel : OverlayViewModel, IAsyncInitialize, IProgress, IDisposable { private readonly IFolder _vaultFolder; private readonly KeySequence _keySequence; @@ -31,6 +31,7 @@ public sealed partial class CredentialsOverlayViewModel : OverlayViewModel, IAsy [ObservableProperty] private RegisterViewModel _RegisterViewModel; [ObservableProperty] private CredentialsSelectionViewModel _SelectionViewModel; [ObservableProperty] private INotifyPropertyChanged? _SelectedViewModel; + [ObservableProperty] private InfoBarViewModel _StatusInfoBar = new(); public CredentialsOverlayViewModel(IFolder vaultFolder, string? vaultName, AuthenticationStage authenticationStage) { @@ -47,10 +48,33 @@ public CredentialsOverlayViewModel(IFolder vaultFolder, string? vaultName, Authe PrimaryText = "Continue".ToLocalized(); LoginViewModel.VaultUnlocked += LoginViewModel_VaultUnlocked; + LoginViewModel.StateChanged += LoginViewModel_StateChanged; RegisterViewModel.PropertyChanged += RegisterViewModel_PropertyChanged; SelectionViewModel.ConfirmationRequested += SelectionViewModel_ConfirmationRequested; } + /// + public void Report(IResult result) + { + if (result.Successful) + { + StatusInfoBar.IsOpen = false; + return; + } + + StatusInfoBar.Title = "CredentialsChangeFailed".ToLocalized(); + StatusInfoBar.Message = result.GetMessage("UnknownError".ToLocalized()); + StatusInfoBar.Severity = Severity.Critical; + StatusInfoBar.IsCloseable = true; + StatusInfoBar.IsOpen = true; + } + + private void LoginViewModel_StateChanged(object? sender, EventArgs e) + { + if (e is ErrorReportedEventArgs args) + Report(args.Result); + } + /// public async Task InitAsync(CancellationToken cancellationToken = default) { @@ -118,6 +142,7 @@ public void Dispose() RegisterViewModel.PropertyChanged -= RegisterViewModel_PropertyChanged; SelectionViewModel.ConfirmationRequested -= SelectionViewModel_ConfirmationRequested; LoginViewModel.VaultUnlocked -= LoginViewModel_VaultUnlocked; + LoginViewModel.StateChanged -= LoginViewModel_StateChanged; (SelectedViewModel as IDisposable)?.Dispose(); SelectionViewModel.Dispose(); LoginViewModel.Dispose(); diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecoveryOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecoveryOverlayViewModel.cs index 1ad629323..176b7efb9 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecoveryOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecoveryOverlayViewModel.cs @@ -36,6 +36,7 @@ public RecoveryOverlayViewModel(IFolder vaultFolder) public async Task RecoverAsync(CancellationToken cancellationToken) { + ErrorMessage = null; try { var unlockContract = await VaultManagerService.RecoverAsync(_vaultFolder, RecoveryKey ?? string.Empty, cancellationToken); diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecycleBinOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecycleBinOverlayViewModel.cs index 13ec8be04..ee6ed12ee 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecycleBinOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/RecycleBinOverlayViewModel.cs @@ -11,6 +11,7 @@ using CommunityToolkit.Mvvm.Input; using OwlCore.Storage; using SecureFolderFS.Sdk.Attributes; +using SecureFolderFS.Sdk.Enums; using SecureFolderFS.Sdk.Extensions; using SecureFolderFS.Sdk.Helpers; using SecureFolderFS.Sdk.Services; @@ -20,6 +21,7 @@ using SecureFolderFS.Shared; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.Shared.Models; using SecureFolderFS.Shared.Helpers; using SecureFolderFS.Storage.Extensions; using SecureFolderFS.Storage.VirtualFileSystem; @@ -28,7 +30,7 @@ namespace SecureFolderFS.Sdk.ViewModels.Views.Overlays { [Bindable(true)] [Inject, Inject] - public sealed partial class RecycleBinOverlayViewModel : BaseDesignationViewModel, IAsyncInitialize, IDisposable + public sealed partial class RecycleBinOverlayViewModel : BaseDesignationViewModel, IAsyncInitialize, IProgress, IDisposable { private readonly SynchronizationContext? _synchronizationContext; private long _occupiedSize; @@ -43,6 +45,7 @@ public sealed partial class RecycleBinOverlayViewModel : BaseDesignationViewMode [ObservableProperty] private UnlockedVaultViewModel _UnlockedVaultViewModel; [ObservableProperty] private ObservableCollection _Items; [ObservableProperty] private ObservableCollection _SizeOptions; + [ObservableProperty] private InfoBarViewModel _StatusInfoBar = new(); public INavigator OuterNavigator { get; } @@ -205,9 +208,12 @@ private async Task RestoreSelectedAsync(IList? selectedItems, Cancellati var folderPicker = DI.Service(); if (await _recycleBin.TryRestoreItemsAsync(itemsToRestore, folderPicker, cancellationToken)) { + StatusInfoBar.IsOpen = false; foreach (var item in items) Items.Remove(item); } + else + Report(new MessageResult(false, "ItemsFailedToRestorePlural".ToLocalized(items.Length))); ToggleSelectionCommand.Execute(false); await UpdateSizesAsync(false, cancellationToken); @@ -223,6 +229,8 @@ private async Task DeleteSelectedAsync(IList? selectedItems, Cancellatio if (items.Length == 0) return; + var failedCount = 0; + Exception? lastException = null; foreach (var item in items) { if (item.AsWrapper().GetWrapperAt(1).Inner is not IStorableChild innerChild) @@ -235,14 +243,31 @@ private async Task DeleteSelectedAsync(IList? selectedItems, Cancellatio } catch (Exception ex) { - _ = ex; + // A failed item must not abandon the remaining ones - aggregate and report once + failedCount++; + lastException = ex; } } + if (failedCount > 0) + Report(Result.Failure(lastException)); + else + StatusInfoBar.IsOpen = false; + ToggleSelectionCommand.Execute(false); await UpdateSizesAsync(false, cancellationToken); } + /// + public void Report(IResult result) + { + StatusInfoBar.Title = "ErrorOccurred".ToLocalized(); + StatusInfoBar.Message = result.GetMessage(result.Exception?.Message ?? "UnknownError".ToLocalized()); + StatusInfoBar.Severity = Severity.Critical; + StatusInfoBar.IsCloseable = true; + StatusInfoBar.IsOpen = true; + } + private void UpdateSizeBar(PickerOptionViewModel? value) { if (value is not null && value.Id != "-1") diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Root/VaultPreviewViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Root/VaultPreviewViewModel.cs index 426d67665..73d5f7240 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Root/VaultPreviewViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Root/VaultPreviewViewModel.cs @@ -1,4 +1,8 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using System; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; using SecureFolderFS.Sdk.Attributes; @@ -14,16 +18,13 @@ using SecureFolderFS.Shared; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Extensions; -using System; -using System.ComponentModel; -using System.Threading; -using System.Threading.Tasks; +using SecureFolderFS.Shared.Models; namespace SecureFolderFS.Sdk.ViewModels.Views.Root { [Inject, Inject, Inject] [Bindable(true)] - public sealed partial class VaultPreviewViewModel : ObservableObject, IAsyncInitialize, IRecipient, IRecipient, IDisposable + public sealed partial class VaultPreviewViewModel : ObservableObject, IAsyncInitialize, IRecipient, IRecipient, IProgress, IDisposable { private readonly INavigationService _vaultNavigation; @@ -31,6 +32,7 @@ public sealed partial class VaultPreviewViewModel : ObservableObject, IAsyncInit [ObservableProperty] private LoginViewModel? _LoginViewModel; [ObservableProperty] private VaultViewModel _VaultViewModel; [ObservableProperty] private bool _IsReadOnly; + [ObservableProperty] private InfoBarViewModel _StatusInfoBar = new(); public VaultPreviewViewModel(VaultViewModel vaultViewModel, INavigationService vaultNavigation) { @@ -113,8 +115,20 @@ private async Task LockVaultAsync() if (UnlockedVaultViewModel is null) return; - // Lock vault - await UnlockedVaultViewModel.DisposeAsync(); + try + { + // Lock vault + await UnlockedVaultViewModel.DisposeAsync(); + } + catch (Exception ex) + { + // Unmounting failed (e.g. files are still open) - the vault remains unlocked, + // so don't navigate away or announce a lock that didn't happen + Report(new MessageResult(ex, "LockFailed".ToLocalized())); + return; + } + + StatusInfoBar.IsOpen = false; // Prepare login page var loginPageViewModel = new VaultLoginViewModel(UnlockedVaultViewModel.VaultViewModel, _vaultNavigation); @@ -127,11 +141,28 @@ private async Task LockVaultAsync() private async Task UnlockAsync(IDisposable unlockContract) { + StatusInfoBar.IsOpen = false; + try { // Unlock the vault UnlockedVaultViewModel = await VaultViewModel.UnlockAsync(unlockContract, IsReadOnly); + } + catch (Exception ex) + { + // Mounting failed - the credentials were correct, so keep this view alive for a retry + unlockContract.Dispose(); + Report(new MessageResult(ex, "UnlockFailed".ToLocalized())); + // Reset the login state so the user can attempt to unlock again + if (LoginViewModel is not null) + await LoginViewModel.InitAsync(); + + return; + } + + try + { // Setup dashboard var dashboardNavigation = DI.Service(); var dashboardViewModel = new VaultDashboardViewModel(UnlockedVaultViewModel, _vaultNavigation, dashboardNavigation); @@ -161,11 +192,32 @@ await _vaultNavigation.ForgetNavigateSpecificViewAsync( } } + /// + public void Report(IResult result) + { + StatusInfoBar.Title = result.GetMessage("UnknownError".ToLocalized()); + StatusInfoBar.Severity = Severity.Critical; + StatusInfoBar.IsCloseable = true; + StatusInfoBar.IsOpen = true; + } + + private void LoginViewModel_StateChanged(object? sender, EventArgs e) + { + if (e is ErrorReportedEventArgs args) + Report(MessageResult.WithMessage(args.Result, "RecoveryFailed".ToLocalized())); + } + private async void LoginViewModel_VaultUnlocked(object? sender, VaultUnlockedEventArgs e) { await UnlockAsync(e.UnlockContract); } + partial void OnLoginViewModelChanged(LoginViewModel? oldValue, LoginViewModel? newValue) + { + oldValue?.StateChanged -= LoginViewModel_StateChanged; + newValue?.StateChanged += LoginViewModel_StateChanged; + } + /// public void Dispose() { @@ -173,6 +225,7 @@ public void Dispose() if (LoginViewModel is not null) { LoginViewModel.VaultUnlocked -= LoginViewModel_VaultUnlocked; + LoginViewModel.StateChanged -= LoginViewModel_StateChanged; LoginViewModel.Dispose(); } } diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs index ef5b7a51e..ed0921ce4 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/BrowserViewModel.cs @@ -93,7 +93,8 @@ public override void OnDisappearing() TransferViewModel?.CancelCommand.Execute(null); } - CurrentFolder?.Dispose(); + // Do not dispose CurrentFolder here because the page may reappear (e.g., after an + // overlay closes) and its items are still bound. Disposal happens in Dispose() base.OnDisappearing(); } @@ -280,22 +281,34 @@ protected virtual async Task NewItemAsync(string? itemType, CancellationToken ca var formattedName = CollisionHelpers.GetAvailableName( FormattingHelpers.SanitizeItemName(newItemViewModel.ItemName, "New item"), CurrentFolder.Items.Select(x => x.Inner.Name)); - switch (itemType) + try { - case "File": + switch (itemType) { - var file = await modifiableFolder.CreateFileAsync(formattedName, false, cancellationToken); - CurrentFolder.Items.Insert(new FileViewModel(file, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); - break; - } + case "File": + { + var file = await modifiableFolder.CreateFileAsync(formattedName, false, cancellationToken); + CurrentFolder.Items.Insert(new FileViewModel(file, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); + break; + } - case "Folder": - { - var folder = await modifiableFolder.CreateFolderAsync(formattedName, false, cancellationToken); - CurrentFolder.Items.Insert(new FolderViewModel(folder, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); - break; + case "Folder": + { + var folder = await modifiableFolder.CreateFolderAsync(formattedName, false, cancellationToken); + CurrentFolder.Items.Insert(new FolderViewModel(folder, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); + break; + } } } + catch (OperationCanceledException) + { + // Cancellation, nothing to report + } + catch (Exception) + { + if (TransferViewModel is not null) + await TransferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); + } } [RelayCommand] @@ -323,83 +336,113 @@ protected virtual async Task ImportItemAsync(string? itemType, CancellationToken itemType = storableTypeViewModel.SelectedOption ?? storableTypeViewModel.StorableType.ToString(); } - switch (itemType) + try { - case "File": + switch (itemType) { - var file = await FileExplorerService.PickFileAsync(null, false, cancellationToken); - if (file is null) - return; - - TransferViewModel.TransferType = TransferType.Copy; - using var cts = TransferViewModel.GetCancellation(cancellationToken); - await TransferViewModel.TransferAsync([ file ], async (item, token) => + case "File": { - // Get available name to avoid collision - var availableName = CollisionHelpers.GetAvailableName(item.Name, CurrentFolder.Items.Select(x => x.Inner.Name)); + var file = await FileExplorerService.PickFileAsync(null, false, cancellationToken); + if (file is null) + return; - // Copy - var copiedFile = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, token); + TransferViewModel.TransferType = TransferType.Copy; + using var cts = TransferViewModel.GetCancellation(cancellationToken); + await TransferViewModel.TransferAsync([ file ], async (item, token) => + { + // Get available name to avoid collision + var availableName = CollisionHelpers.GetAvailableName(item.Name, CurrentFolder.Items.Select(x => x.Inner.Name)); - // Add to destination - CurrentFolder.Items.Insert(new FileViewModel(copiedFile, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); - }, cts.Token); + // Copy + var copiedFile = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, token); - break; - } + // Add to destination + CurrentFolder.Items.Insert(new FileViewModel(copiedFile, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); + }, cts.Token); - case "Folder": - { - var folder = await FileExplorerService.PickFolderAsync(null, false, cancellationToken); - if (folder is null) - return; + break; + } - TransferViewModel.TransferType = TransferType.Copy; - using var cts = TransferViewModel.GetCancellation(cancellationToken); - await TransferViewModel.TransferAsync([ folder ], async (item, reporter, token) => + case "Folder": { - // Get available name to avoid collision - var availableName = CollisionHelpers.GetAvailableName(item.Name, CurrentFolder.Items.Select(x => x.Inner.Name)); + var folder = await FileExplorerService.PickFolderAsync(null, false, cancellationToken); + if (folder is null) + return; - // Copy - var copiedFolder = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, reporter, token); + TransferViewModel.TransferType = TransferType.Copy; + using var cts = TransferViewModel.GetCancellation(cancellationToken); + await TransferViewModel.TransferAsync([ folder ], async (item, reporter, token) => + { + // Get available name to avoid collision + var availableName = CollisionHelpers.GetAvailableName(item.Name, CurrentFolder.Items.Select(x => x.Inner.Name)); - // Add to destination - CurrentFolder.Items.Insert(new FolderViewModel(copiedFolder, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); - }, cts.Token); + // Copy + var copiedFolder = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, reporter, token); - break; - } + // Add to destination + CurrentFolder.Items.Insert(new FolderViewModel(copiedFolder, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); + }, cts.Token); - case var t when t == "Gallery".ToLocalized(): - { - var galleryItems = (await FileExplorerService.PickGalleryItemsAsync(cancellationToken)).OfType().ToArray(); - if (galleryItems.Length == 0) - return; + break; + } - TransferViewModel.TransferType = TransferType.Copy; - using var cts = TransferViewModel.GetCancellation(cancellationToken); - await TransferViewModel.TransferAsync(galleryItems, async (item, token) => + case var t when t == "Gallery".ToLocalized(): { - // Get available name to avoid collision - var availableName = CollisionHelpers.GetAvailableName(item.Name, CurrentFolder.Items.Select(x => x.Inner.Name)); - - // Copy - var copiedFile = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, token); - - // Add to destination - CurrentFolder.Items.Insert(new FileViewModel(copiedFile, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); - }, cts.Token); - - break; + var galleryItems = (await FileExplorerService.PickGalleryItemsAsync(cancellationToken)).OfType().ToArray(); + if (galleryItems.Length == 0) + return; + + TransferViewModel.TransferType = TransferType.Copy; + using var cts = TransferViewModel.GetCancellation(cancellationToken); + await TransferViewModel.TransferAsync(galleryItems, async (item, token) => + { + // Get available name to avoid collision + var availableName = CollisionHelpers.GetAvailableName(item.Name, CurrentFolder.Items.Select(x => x.Inner.Name)); + + // Copy + var copiedFile = await modifiableFolder.CreateCopyOfAsync(item, false, availableName, token); + + // Add to destination + CurrentFolder.Items.Insert(new FileViewModel(copiedFile, this, CurrentFolder).WithInitAsync(), Layouts.GetSorter()); + }, cts.Token); + + break; + } } } + catch (OperationCanceledException) + { + // Cancellation, nothing to report + } + catch (Exception) + { + await TransferViewModel.ReportErrorAsync("OperationFailed".ToLocalized()); + } + finally + { + await TransferViewModel.HideAsync(); + } } /// public void Dispose() { ThumbnailCache.Dispose(); + + // Dispose every folder accumulated in the navigation stack + if (InnerNavigator is INavigationService navigationService) + { + foreach (var view in navigationService.Views) + { + if (view is not FolderViewModel folderViewModel || folderViewModel == CurrentFolder) + continue; + + folderViewModel.Items.DisposeAll(); + folderViewModel.Items.Clear(); + folderViewModel.Dispose(); + } + } + CurrentFolder?.Dispose(); CurrentFolder?.Items.DisposeAll(); CurrentFolder?.Items.Clear(); diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultLoginViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultLoginViewModel.cs index 81a529ad5..78b35790a 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultLoginViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultLoginViewModel.cs @@ -16,12 +16,13 @@ using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.EventArguments; using SecureFolderFS.Shared.Extensions; +using SecureFolderFS.Shared.Models; namespace SecureFolderFS.Sdk.ViewModels.Views.Vault { [Inject, Inject, Inject, Inject] [Bindable(true)] - public sealed partial class VaultLoginViewModel : BaseDesignationViewModel, IVaultViewContext, INavigatable, IAsyncInitialize, IDisposable + public sealed partial class VaultLoginViewModel : BaseDesignationViewModel, IVaultViewContext, INavigatable, IAsyncInitialize, IProgress, IDisposable { private CancellationTokenSource? _connectionCts; @@ -29,6 +30,7 @@ public sealed partial class VaultLoginViewModel : BaseDesignationViewModel, IVau [ObservableProperty] private bool _IsConnected; [ObservableProperty] private bool _IsProgressing; [ObservableProperty] private LoginViewModel? _LoginViewModel; + [ObservableProperty] private InfoBarViewModel _StatusInfoBar = new(); public INavigationService VaultNavigation { get; } @@ -105,11 +107,12 @@ private async Task ConnectToVaultAsync(CancellationToken cancellationToken) try { + StatusInfoBar.IsOpen = false; LoginViewModel?.Dispose(); var result = await VaultViewModel.VaultModel.TryConnectAsync(linkedCts.Token); if (!result.TryGetValue(out var vaultFolder)) { - // TODO: Report error + Report(MessageResult.WithMessage(result, "ConnectionFailed".ToLocalized())); return; } @@ -124,6 +127,10 @@ private async Task ConnectToVaultAsync(CancellationToken cancellationToken) LoginViewModel?.Dispose(); LoginViewModel = null; } + catch (Exception ex) + { + Report(new MessageResult(ex, ex.Message)); + } finally { IsProgressing = false; @@ -169,10 +176,29 @@ private async Task BeginRecoveryAsync(CancellationToken cancellationToken) private async Task UnlockAsync(IDisposable unlockContract) { + StatusInfoBar.IsOpen = false; + + UnlockedVaultViewModel unlockedVaultViewModel; + try + { + unlockedVaultViewModel = await VaultViewModel.UnlockAsync(unlockContract, IsReadOnly); + } + catch (Exception ex) + { + // Mounting failed - the credentials were correct, so keep this view alive for a retry + unlockContract.Dispose(); + Report(new MessageResult(ex, "UnlockFailed".ToLocalized())); + + // Reset the login state so the user can attempt to unlock again + if (LoginViewModel is not null) + await LoginViewModel.InitAsync(); + + return; + } + try { // Navigate away - var unlockedVaultViewModel = await VaultViewModel.UnlockAsync(unlockContract, IsReadOnly); NavigationRequested?.Invoke(this, new UnlockNavigationRequestedEventArgs(unlockedVaultViewModel, this)); // Show vault tutorial @@ -193,6 +219,28 @@ private async Task UnlockAsync(IDisposable unlockContract) } } + /// + public void Report(IResult result) + { + StatusInfoBar.Title = "ErrorOccurred".ToLocalized(); + StatusInfoBar.Message = result.GetMessage(result.Exception?.Message ?? "UnknownError".ToLocalized()); + StatusInfoBar.Severity = Severity.Critical; + StatusInfoBar.IsCloseable = true; + StatusInfoBar.IsOpen = true; + } + + private void LoginViewModel_StateChanged(object? sender, EventArgs e) + { + if (e is ErrorReportedEventArgs args) + Report(MessageResult.WithMessage(args.Result, "RecoveryFailed".ToLocalized())); + } + + partial void OnLoginViewModelChanged(LoginViewModel? oldValue, LoginViewModel? newValue) + { + oldValue?.StateChanged -= LoginViewModel_StateChanged; + newValue?.StateChanged += LoginViewModel_StateChanged; + } + private async void LoginViewModel_VaultUnlocked(object? sender, VaultUnlockedEventArgs e) { await UnlockAsync(e.UnlockContract); diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/SummaryWizardViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/SummaryWizardViewModel.cs index 4dabbdfda..b79e39dcd 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/SummaryWizardViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/SummaryWizardViewModel.cs @@ -19,6 +19,7 @@ public sealed partial class SummaryWizardViewModel : OverlayViewModel, IStagingV [ObservableProperty] private string? _Message; [ObservableProperty] private string? _VaultName; + [ObservableProperty] private bool _IsError; public SummaryWizardViewModel(IVaultModel vaultModel, IVaultCollectionModel vaultCollectionModel) { @@ -50,12 +51,13 @@ public override async void OnAppearing() // Add the newly created vault _vaultCollectionModel.Add(_vaultModel); - // Try to save the new vault - var result = await _vaultCollectionModel.TrySaveAsync(); - _ = result; // TODO: Maybe use the result to indicate whether the save was successful or not - - // Display result as message - Message = "VaultAdded".ToLocalized(); + // Try to save the new vault and reflect the outcome to the user. A silent failure here would + // leave the vault absent after the next app restart, so it must be surfaced + var saved = await _vaultCollectionModel.TrySaveAsync(); + IsError = !saved; + Message = saved + ? "VaultAdded".ToLocalized() + : "VaultAddedSaveFailed".ToLocalized(); } } } diff --git a/src/Shared/SecureFolderFS.Shared/Models/Result.Message.cs b/src/Shared/SecureFolderFS.Shared/Models/Result.Message.cs index 8c0c802c6..cb0f37589 100644 --- a/src/Shared/SecureFolderFS.Shared/Models/Result.Message.cs +++ b/src/Shared/SecureFolderFS.Shared/Models/Result.Message.cs @@ -20,6 +20,19 @@ public MessageResult(bool isSuccess, string? message = null) { Message = message; } + + /// + /// Creates an instance of by combining the given base result and an additional message. + /// + /// The base result, which contains the success state or an exception. + /// The additional message to include in the result. + /// A new instance of containing the provided base result and message. + public static IResultWithMessage WithMessage(IResult baseResult, string message) + { + return baseResult.Exception is not null + ? new MessageResult(baseResult.Exception, message) + : new MessageResult(baseResult.Successful, message); + } } /// diff --git a/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/VirtualFileSystemOptions.cs b/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/VirtualFileSystemOptions.cs index 8dd52a9fe..a5d9e0d94 100644 --- a/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/VirtualFileSystemOptions.cs +++ b/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/VirtualFileSystemOptions.cs @@ -35,7 +35,11 @@ public class VirtualFileSystemOptions : FileSystemOptions /// /// Gets or sets whether to enable caching for ciphertext and plaintext names. /// - public bool IsCachingFileNames { get; protected set => SetField(ref field, value); } = true; + /// + /// File name caching is disabled by default. Enabling it requires cache invalidation + /// for renames, moves, and deletions across all file system layers. + /// + public bool IsCachingFileNames { get; protected set => SetField(ref field, value); } = false; /// /// Sets the read-only status of the file system. @@ -83,7 +87,7 @@ public static VirtualFileSystemOptions ToOptions( FileSystemStatistics = GetOption(options, nameof(FileSystemStatistics)) ?? fileSystemStatistics.Invoke(), IsReadOnly = GetOption(options, nameof(IsReadOnly)) ?? false, IsCachingChunks = GetOption(options, nameof(IsCachingChunks)) ?? true, - IsCachingFileNames = GetOption(options, nameof(IsCachingFileNames)) ?? true, + IsCachingFileNames = GetOption(options, nameof(IsCachingFileNames)) ?? false, IsCachingDirectoryIds = GetOption(options, nameof(IsCachingDirectoryIds)) ?? true, RecycleBinSize = GetOption(options, nameof(RecycleBinSize)) ?? 0L }; diff --git a/tests/SecureFolderFS.Tests/CliTests/CliExecutionResult.cs b/tests/SecureFolderFS.Tests/CliTests/CliExecutionResult.cs index a4d4e42b6..47359b899 100644 --- a/tests/SecureFolderFS.Tests/CliTests/CliExecutionResult.cs +++ b/tests/SecureFolderFS.Tests/CliTests/CliExecutionResult.cs @@ -5,5 +5,3 @@ public sealed record CliExecutionResult( int ProcessExitCode, string StandardOutput, string StandardError); - - diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/CredsAddCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsAddCommandTests.cs index da3c28180..f37145b02 100644 --- a/tests/SecureFolderFS.Tests/CliTests/Commands/CredsAddCommandTests.cs +++ b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsAddCommandTests.cs @@ -19,5 +19,3 @@ public async Task CredsAdd_WithRecoveryKeyFlag_ShouldReturnBadArguments() result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments); } } - - diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/CredsChangeCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsChangeCommandTests.cs index 0e8e99b09..9d793160d 100644 --- a/tests/SecureFolderFS.Tests/CliTests/Commands/CredsChangeCommandTests.cs +++ b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsChangeCommandTests.cs @@ -19,5 +19,3 @@ public async Task CredsChange_WithRecoveryKeyFlag_ShouldReturnBadArguments() result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments); } } - - diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/CredsRemoveCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsRemoveCommandTests.cs index 4191f582f..98836b726 100644 --- a/tests/SecureFolderFS.Tests/CliTests/Commands/CredsRemoveCommandTests.cs +++ b/tests/SecureFolderFS.Tests/CliTests/Commands/CredsRemoveCommandTests.cs @@ -19,5 +19,3 @@ public async Task CredsRemove_WithRecoveryKeyFlag_ShouldReturnBadArguments() result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments); } } - - diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultCreateCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultCreateCommandTests.cs index 968b30135..247450acc 100644 --- a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultCreateCommandTests.cs +++ b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultCreateCommandTests.cs @@ -40,5 +40,3 @@ public async Task VaultCreate_WithPasswordEnvironmentVariable_ShouldCreateVault( File.Exists(configPath).Should().BeTrue(); } } - - diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultInfoCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultInfoCommandTests.cs index 82e1a496d..43427c54c 100644 --- a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultInfoCommandTests.cs +++ b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultInfoCommandTests.cs @@ -35,5 +35,3 @@ public async Task VaultInfo_OnExistingVault_ShouldReturnInformation() Assert.Pass(); } } - - diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultRunCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultRunCommandTests.cs index 6ed58c267..794590881 100644 --- a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultRunCommandTests.cs +++ b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultRunCommandTests.cs @@ -19,5 +19,3 @@ public async Task VaultRun_WithoutReadOrWrite_ShouldReturnBadArguments() result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments); } } - - diff --git a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultShellCommandTests.cs b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultShellCommandTests.cs index edd046846..7520afd15 100644 --- a/tests/SecureFolderFS.Tests/CliTests/Commands/VaultShellCommandTests.cs +++ b/tests/SecureFolderFS.Tests/CliTests/Commands/VaultShellCommandTests.cs @@ -19,5 +19,3 @@ public async Task VaultShell_WithoutAnyCredential_ShouldReturnBadArguments() result.ProcessExitCode.Should().Be(CliExpectedExitCodes.BadArguments); } } - -