From 9c791a50dbf142aa2d7316d8f9e0d8ff7a76d76e Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Thu, 21 May 2026 17:16:38 +0200 Subject: [PATCH 01/14] Begin work on shortening --- .../Constants.cs | 2 + .../Abstract/AbstractPathHelpers.Names.cs | 92 +++++++++++++++ .../AbstractPathHelpers.Shortening.cs | 108 ++++++++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Shortening.cs diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Constants.cs b/src/Core/SecureFolderFS.Core.FileSystem/Constants.cs index 6f90d82f0..a46276621 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Constants.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Constants.cs @@ -18,6 +18,8 @@ public static class FileSystem public static class Names { public const string ENCRYPTED_FILE_EXTENSION = ".sffs"; + public const string SHORTENED_FILE_EXTENSION = ".sffsn"; + public const string SIDECAR_FILE_EXTENSION = ".sffsi"; public const string DIRECTORY_ID_FILENAME = "dirid.iv"; public const string RECYCLE_BIN_NAME = "recycle_bin"; public const string RECYCLE_BIN_CONFIGURATION_FILENAME = "recycle_bin.cfg"; diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Names.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Names.cs index 2a662d97e..a3e4c084b 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Names.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Names.cs @@ -9,6 +9,8 @@ namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract { public static partial class AbstractPathHelpers { + #region Encrypt Name Non-Materialized + /// public static async Task EncryptNameAsync(string plaintextName, IFolder ciphertextParentFolder, FileSystemSpecifics specifics, CancellationToken cancellationToken = default) @@ -62,6 +64,81 @@ public static async Task EncryptNameAsync(string plaintextName, IFolder return security.NameCrypt.EncryptName(plaintextName, result ? directoryId : ReadOnlySpan.Empty) + Constants.Names.ENCRYPTED_FILE_EXTENSION; } + #endregion + + #region Encrypt Name Materialized + + /// + public static async Task EncryptNameForUseAsync(string plaintextName, IFolder ciphertextParentFolder, + FileSystemSpecifics specifics, CancellationToken cancellationToken = default) + { + if (specifics.Security.NameCrypt is null) + return plaintextName; + + var directoryId = AllocateDirectoryId(specifics.Security, plaintextName); + return await EncryptNameForUseAsync(plaintextName, ciphertextParentFolder, specifics, directoryId, cancellationToken); + } + + /// + /// Encrypts the provided and materializes it. + /// + /// The name to encrypt. + /// The ciphertext parent folder. + /// The instance associated with the item. + /// A buffer of size which will be used to hold the Directory ID data. + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is an encrypted name with the appropriate file extension appended. + public static async Task EncryptNameForUseAsync(string plaintextName, IFolder ciphertextParentFolder, + FileSystemSpecifics specifics, byte[]? expendableDirectoryId = null, CancellationToken cancellationToken = default) + { + if (specifics.Security.NameCrypt is null) + return plaintextName; + + expendableDirectoryId ??= AllocateDirectoryId(specifics.Security, plaintextName); + var result = await GetDirectoryIdAsync(ciphertextParentFolder, specifics, expendableDirectoryId, cancellationToken); + + var encryptedName = specifics.Security.NameCrypt.EncryptName(plaintextName, result ? expendableDirectoryId : ReadOnlySpan.Empty) + Constants.Names.ENCRYPTED_FILE_EXTENSION; + if (SHORTENING_THRESHOLD > 0 && encryptedName.Length >= SHORTENING_THRESHOLD) + { + var shortenedBase = ComputeShortenedNameBase(encryptedName); + await WriteSidecarAsync(ciphertextParentFolder, shortenedBase, encryptedName, cancellationToken); + return shortenedBase + Constants.Names.SHORTENED_FILE_EXTENSION; + } + + return encryptedName; + } + + /// + /// Encrypts the provided and materializes it. + /// + /// The name to encrypt. + /// The ciphertext parent folder. + /// The content folder. + /// The instance associated with the item. + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is an encrypted name with the appropriate file extension appended. + public static async Task EncryptNameForUseAsync(string plaintextName, IFolder ciphertextParentFolder, IFolder contentFolder, + Security security, CancellationToken cancellationToken = default) + { + if (security.NameCrypt is null) + return plaintextName; + + var directoryId = AllocateDirectoryId(security, plaintextName); + var result = await GetDirectoryIdAsync(ciphertextParentFolder, contentFolder, directoryId, cancellationToken); + + var encryptedName = security.NameCrypt.EncryptName(plaintextName, result ? directoryId : ReadOnlySpan.Empty) + Constants.Names.ENCRYPTED_FILE_EXTENSION; + if (SHORTENING_THRESHOLD > 0 && encryptedName.Length >= SHORTENING_THRESHOLD) + { + var shortenedBase = ComputeShortenedNameBase(encryptedName); + await WriteSidecarAsync(ciphertextParentFolder, shortenedBase, encryptedName, cancellationToken); + return shortenedBase + Constants.Names.SHORTENED_FILE_EXTENSION; + } + + return encryptedName; + } + + #endregion + /// /// Encrypts a plaintext name using the specified Directory ID and security parameters. /// @@ -103,8 +180,23 @@ public static string EncryptNewName(string plaintextName, byte[] newDirectoryId, if (specifics.Security.NameCrypt is null) return ciphertextName; + // Sidecar files are internal bookkeeping - they have no plaintext name + if (IsSidecarName(ciphertextName)) + return null; + try { + // Resolve shortened names to their full ciphertext name via the paired sidecar + if (ciphertextName.EndsWith(Constants.Names.SHORTENED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) + { + var shortenedBase = RemoveShortenedExtension(ciphertextName).ToString(); + var resolvedName = await ReadSidecarAsync(ciphertextParentFolder, shortenedBase, cancellationToken); + if (resolvedName is null) + return null; + + ciphertextName = resolvedName; + } + expendableDirectoryId ??= AllocateDirectoryId(specifics.Security, ciphertextName); var result = await GetDirectoryIdAsync(ciphertextParentFolder, specifics, expendableDirectoryId, cancellationToken); diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Shortening.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Shortening.cs new file mode 100644 index 000000000..78fffe271 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Shortening.cs @@ -0,0 +1,108 @@ +using System; +using System.Buffers.Text; +using System.IO; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using OwlCore.Storage; +using SecureFolderFS.Storage.Extensions; + +namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract +{ + public static partial class AbstractPathHelpers + { + private const int SHORTENING_THRESHOLD = 220; + private const int MAX_SIDECAR_BYTES = 4096; // No legitimate ciphertext name approaches this upper bound + + /// + /// Returns whether is a name-shortening sidecar file (). + /// Sidecar files are an internal implementation detail and should be excluded from vault enumeration. + /// + /// True, if the is a sidecar file; otherwise, false + public static bool IsSidecarName(string name) + { + return name.EndsWith(Constants.Names.SIDECAR_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Removes the shortened file extension from the specified filename if present. + /// + /// A or without ; otherwise, . + /// + /// The returned may contain an extension other than . + /// + public static ReadOnlySpan RemoveShortenedExtension(string shortenedName) + { + return shortenedName.EndsWith(Constants.Names.SHORTENED_FILE_EXTENSION, StringComparison.Ordinal) + ? shortenedName.AsSpan(0, shortenedName.Length - Constants.Names.SHORTENED_FILE_EXTENSION.Length) + : shortenedName.AsSpan(); + } + + /// + /// Computes a deterministic, filesystem-safe name base (no extension) for a full ciphertext name. + /// The result is a URL-safe Base64 encoding of the first 20 bytes of SHA-256(UTF-8()), + /// yielding a fixed 27-character string. + /// + [SkipLocalsInit] + private static string ComputeShortenedNameBase(string ciphertextName) + { + var nameBytes = Encoding.UTF8.GetBytes(ciphertextName); + Span hash = stackalloc byte[32]; + SHA256.HashData(nameBytes, hash); + + return Base64Url.EncodeToString(hash.Slice(0, 20)); + } + + /// + /// Writes a sidecar file containing the full ciphertext name for a shortened file. + /// The sidecar is named + . + /// Must be written before the shortened file/directory is created. + /// + private static async Task WriteSidecarAsync( + IFolder parentFolder, + string shortenedBase, + string fullCiphertextName, + CancellationToken cancellationToken) + { + if (parentFolder is not IModifiableFolder modifiableFolder) + throw new InvalidOperationException("Cannot write name-shortening sidecar: parent folder does not support modification."); + + var sidecarName = shortenedBase + Constants.Names.SIDECAR_FILE_EXTENSION; + var sidecarFile = await modifiableFolder.CreateFileAsync(sidecarName, overwrite: true, cancellationToken); + await using var stream = await sidecarFile.OpenStreamAsync(FileAccess.Write, cancellationToken); + await stream.WriteAsync(Encoding.UTF8.GetBytes(fullCiphertextName), cancellationToken); + } + + /// + /// Reads the full ciphertext name from a sidecar file. + /// Returns if the sidecar does not exist or cannot be read. + /// + private static async Task ReadSidecarAsync( + IFolder parentFolder, + string shortenedBase, + CancellationToken cancellationToken) + { + try + { + var sidecarName = shortenedBase + Constants.Names.SIDECAR_FILE_EXTENSION; + var sidecarFile = await parentFolder.TryGetFileByNameAsync(sidecarName, cancellationToken); + if (sidecarFile is null) + return null; + + await using var stream = await sidecarFile.OpenStreamAsync(FileAccess.Read, cancellationToken); + var buffer = new byte[MAX_SIDECAR_BYTES + 1]; + var bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); + if (bytesRead > MAX_SIDECAR_BYTES) + return null; // Reject malformed/malicious sidecar + + return Encoding.UTF8.GetString(buffer.AsSpan(0, bytesRead)); + } + catch (Exception) + { + return null; + } + } + } +} From a25e59d367671201d8aa9eb4dff5d2bfa10ab879 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Fri, 22 May 2026 14:58:30 +0200 Subject: [PATCH 02/14] Added name shortening vault option --- .../AppModels/DokanyOptions.cs | 7 +++-- .../AppModels/FuseOptions.cs | 1 + .../Abstract/AbstractPathHelpers.Names.cs | 31 +------------------ .../AbstractPathHelpers.Shortening.cs | 1 - .../Helpers/Paths/PathHelpers.cs | 1 + .../Storage/CryptoFolder.cs | 27 +++++++++++++--- .../AppModels/WebDavOptions.cs | 7 +++-- .../AppModels/WinFspOptions.cs | 7 +++-- src/Core/SecureFolderFS.Core/Constants.cs | 1 + .../V4VaultConfigurationDataModel.cs | 10 +++++- .../ModifyComplementationRoutine.cs | 4 +-- .../Operational/ModifyCredentialsRoutine.cs | 2 +- .../Routines/Operational/RecoverRoutine.cs | 2 +- .../Routines/Operational/RestoreRoutine.cs | 5 +++ .../Routines/Operational/UnlockRoutine.cs | 4 +-- .../VaultAccess/VaultParser.cs | 11 ++++--- .../VaultAccess/VaultReader.cs | 24 +++++++++----- .../VaultAccess/VaultWriter.cs | 3 +- .../RecycleBinService.cs | 4 +-- .../ServiceImplementation/VaultService.cs | 19 ++---------- .../Views/Vault/VaultPropertiesPage.xaml | 10 ++++++ .../VaultWizard/CredentialsWizardPage.xaml | 20 ++++++++++++ .../Views/Vault/VaultPropertiesViewModel.cs | 9 ++++-- .../Wizard/CredentialsWizardViewModel.cs | 2 ++ .../Models/VaultOptions.cs | 5 +++ .../VirtualFileSystemOptions.cs | 6 ++++ 26 files changed, 138 insertions(+), 85 deletions(-) diff --git a/src/Core/SecureFolderFS.Core.Dokany/AppModels/DokanyOptions.cs b/src/Core/SecureFolderFS.Core.Dokany/AppModels/DokanyOptions.cs index c660e54b9..d9a81346c 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/AppModels/DokanyOptions.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/AppModels/DokanyOptions.cs @@ -1,8 +1,8 @@ -using SecureFolderFS.Core.FileSystem.AppModels; +using System; +using System.Collections.Generic; +using SecureFolderFS.Core.FileSystem.AppModels; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Storage.VirtualFileSystem; -using System; -using System.Collections.Generic; namespace SecureFolderFS.Core.Dokany.AppModels { @@ -36,6 +36,7 @@ public static DokanyOptions ToOptions(IDictionary options) IsCachingFileNames = (bool?)options.Get(nameof(IsCachingFileNames)) ?? true, IsCachingDirectoryIds = (bool?)options.Get(nameof(IsCachingDirectoryIds)) ?? true, RecycleBinSize = (long?)options.Get(nameof(RecycleBinSize)) ?? 0L, + ShorteningThreshold = (int?)options.Get(nameof(ShorteningThreshold)) ?? 0, // Dokany specific MountPoint = (string?)options.Get(nameof(MountPoint)) diff --git a/src/Core/SecureFolderFS.Core.FUSE/AppModels/FuseOptions.cs b/src/Core/SecureFolderFS.Core.FUSE/AppModels/FuseOptions.cs index 0b1a6e204..f5802f803 100644 --- a/src/Core/SecureFolderFS.Core.FUSE/AppModels/FuseOptions.cs +++ b/src/Core/SecureFolderFS.Core.FUSE/AppModels/FuseOptions.cs @@ -61,6 +61,7 @@ public static FuseOptions ToOptions(IDictionary options) IsCachingFileNames = (bool?)options.Get(nameof(IsCachingFileNames)) ?? true, IsCachingDirectoryIds = (bool?)options.Get(nameof(IsCachingDirectoryIds)) ?? true, RecycleBinSize = (long?)options.Get(nameof(RecycleBinSize)) ?? 0L, + ShorteningThreshold = (int?)options.Get(nameof(ShorteningThreshold)) ?? 0, // FUSE specific MountPoint = (string?)options.Get(nameof(MountPoint)), diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Names.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Names.cs index a3e4c084b..1244a8cf7 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Names.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Names.cs @@ -98,36 +98,7 @@ public static async Task EncryptNameForUseAsync(string plaintextName, IF var result = await GetDirectoryIdAsync(ciphertextParentFolder, specifics, expendableDirectoryId, cancellationToken); var encryptedName = specifics.Security.NameCrypt.EncryptName(plaintextName, result ? expendableDirectoryId : ReadOnlySpan.Empty) + Constants.Names.ENCRYPTED_FILE_EXTENSION; - if (SHORTENING_THRESHOLD > 0 && encryptedName.Length >= SHORTENING_THRESHOLD) - { - var shortenedBase = ComputeShortenedNameBase(encryptedName); - await WriteSidecarAsync(ciphertextParentFolder, shortenedBase, encryptedName, cancellationToken); - return shortenedBase + Constants.Names.SHORTENED_FILE_EXTENSION; - } - - return encryptedName; - } - - /// - /// Encrypts the provided and materializes it. - /// - /// The name to encrypt. - /// The ciphertext parent folder. - /// The content folder. - /// The instance associated with the item. - /// A that cancels this action. - /// A that represents the asynchronous operation. Value is an encrypted name with the appropriate file extension appended. - public static async Task EncryptNameForUseAsync(string plaintextName, IFolder ciphertextParentFolder, IFolder contentFolder, - Security security, CancellationToken cancellationToken = default) - { - if (security.NameCrypt is null) - return plaintextName; - - var directoryId = AllocateDirectoryId(security, plaintextName); - var result = await GetDirectoryIdAsync(ciphertextParentFolder, contentFolder, directoryId, cancellationToken); - - var encryptedName = security.NameCrypt.EncryptName(plaintextName, result ? directoryId : ReadOnlySpan.Empty) + Constants.Names.ENCRYPTED_FILE_EXTENSION; - if (SHORTENING_THRESHOLD > 0 && encryptedName.Length >= SHORTENING_THRESHOLD) + if (specifics.Options.ShorteningThreshold > 0 && encryptedName.Length >= specifics.Options.ShorteningThreshold) { var shortenedBase = ComputeShortenedNameBase(encryptedName); await WriteSidecarAsync(ciphertextParentFolder, shortenedBase, encryptedName, cancellationToken); diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Shortening.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Shortening.cs index 78fffe271..f5b70d089 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Shortening.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Shortening.cs @@ -13,7 +13,6 @@ namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract { public static partial class AbstractPathHelpers { - private const int SHORTENING_THRESHOLD = 220; private const int MAX_SIDECAR_BYTES = 4096; // No legitimate ciphertext name approaches this upper bound /// diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/PathHelpers.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/PathHelpers.cs index 3e939fd5e..7db77e77a 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/PathHelpers.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/PathHelpers.cs @@ -11,6 +11,7 @@ public static class PathHelpers public static bool IsCoreName(string itemName) { return + itemName.EndsWith(Constants.Names.SIDECAR_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase) || itemName.Contains(Constants.Names.DIRECTORY_ID_FILENAME, StringComparison.OrdinalIgnoreCase) || itemName.Contains(Constants.Names.RECYCLE_BIN_NAME, StringComparison.OrdinalIgnoreCase) || itemName.Contains(Constants.Names.RECYCLE_BIN_CONFIGURATION_FILENAME, StringComparison.OrdinalIgnoreCase); diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs index a284f7255..08555231d 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs @@ -15,6 +15,7 @@ using SecureFolderFS.Storage.Extensions; using SecureFolderFS.Storage.Recyclable; using SecureFolderFS.Storage.Renamable; +using SecureFolderFS.Storage.VirtualFileSystem; namespace SecureFolderFS.Core.FileSystem.Storage { @@ -43,6 +44,9 @@ public CryptoFolder(string plaintextId, IFolder inner, FileSystemSpecifics speci /// public virtual async Task RenameAsync(IStorableChild storable, string newName, CancellationToken cancellationToken = default) { + if (specifics.Options.IsReadOnly) + throw FileSystemExceptions.FileSystemReadOnly; + if (Inner is not IRenamableFolder renamableFolder) throw new NotSupportedException("Renaming folder contents is not supported."); @@ -112,6 +116,9 @@ public virtual async Task DeleteAsync(IStorableChild item, CancellationToken can /// public virtual async Task DeleteAsync(IStorableChild item, long sizeHint = -1L, bool deleteImmediately = false, CancellationToken cancellationToken = default) { + if (specifics.Options.IsReadOnly) + throw FileSystemExceptions.FileSystemReadOnly; + if (Inner is not IModifiableFolder modifiableFolder) throw new NotSupportedException("Modifying folder contents is not supported."); @@ -138,10 +145,13 @@ public virtual async Task DeleteAsync(IStorableChild item, long sizeHint = -1L, /// public virtual async Task CreateFolderAsync(string name, bool overwrite = false, CancellationToken cancellationToken = default) { + if (specifics.Options.IsReadOnly) + throw FileSystemExceptions.FileSystemReadOnly; + if (Inner is not IModifiableFolder modifiableFolder) throw new NotSupportedException("Modifying folder contents is not supported."); - var encryptedName = await AbstractPathHelpers.EncryptNameAsync(name, Inner, specifics, cancellationToken); + var encryptedName = await AbstractPathHelpers.EncryptNameForUseAsync(name, Inner, specifics, cancellationToken); var folder = await modifiableFolder.CreateFolderAsync(encryptedName, overwrite, cancellationToken); if (folder is not IModifiableFolder createdModifiableFolder) throw new ArgumentException("The created folder is not modifiable."); @@ -163,10 +173,13 @@ public virtual async Task CreateFolderAsync(string name, bool over /// public virtual async Task CreateFileAsync(string name, bool overwrite = false, CancellationToken cancellationToken = default) { + if (specifics.Options.IsReadOnly) + throw FileSystemExceptions.FileSystemReadOnly; + if (Inner is not IModifiableFolder modifiableFolder) throw new NotSupportedException("Modifying folder contents is not supported."); - var encryptedName = await AbstractPathHelpers.EncryptNameAsync(name, Inner, specifics, cancellationToken); + var encryptedName = await AbstractPathHelpers.EncryptNameForUseAsync(name, Inner, specifics, cancellationToken); var file = await modifiableFolder.CreateFileAsync(encryptedName, overwrite, cancellationToken); return (IChildFile)Wrap(file, name); @@ -183,6 +196,9 @@ public virtual Task CreateCopyOfAsync(IFile fileToCopy, bool overwri public virtual async Task CreateCopyOfAsync(IFile fileToCopy, bool overwrite, string newName, CancellationToken cancellationToken, CreateRenamedCopyOfDelegate fallback) { + if (specifics.Options.IsReadOnly) + throw FileSystemExceptions.FileSystemReadOnly; + if (Inner is not IModifiableFolder) throw new NotSupportedException("Modifying folder contents is not supported."); @@ -195,7 +211,7 @@ public virtual async Task CreateCopyOfAsync(IFile fileToCopy, bool o return await fallback(this, fileToCopy, overwrite, newName, cancellationToken); // Encrypt the new name - var ciphertextNewName = await AbstractPathHelpers.EncryptNameAsync(newName, Inner, specifics, cancellationToken); + var ciphertextNewName = await AbstractPathHelpers.EncryptNameForUseAsync(newName, Inner, specifics, cancellationToken); // Copy the ciphertext file var copiedCiphertextFile = await createRenamedCopyOf.CreateCopyOfAsync(ciphertextFileToCopy, overwrite, ciphertextNewName, cancellationToken); @@ -213,6 +229,9 @@ public virtual Task MoveFromAsync(IChildFile fileToMove, IModifiable public virtual async Task MoveFromAsync(IChildFile fileToMove, IModifiableFolder source, bool overwrite, string newName, CancellationToken cancellationToken, MoveRenamedFromDelegate fallback) { + if (specifics.Options.IsReadOnly) + throw FileSystemExceptions.FileSystemReadOnly; + if (Inner is not IModifiableFolder) throw new NotSupportedException("Modifying folder contents is not supported."); @@ -231,7 +250,7 @@ public virtual async Task MoveFromAsync(IChildFile fileToMove, IModi return await fallback(this, fileToMove, source, overwrite, newName, cancellationToken); // Encrypt the new name - var newCiphertextName = await AbstractPathHelpers.EncryptNameAsync(newName, Inner, specifics, cancellationToken); + var newCiphertextName = await AbstractPathHelpers.EncryptNameForUseAsync(newName, Inner, specifics, cancellationToken); // Move the ciphertext file var movedCiphertextFile = await moveRenamedFrom.MoveFromAsync(ciphertextFileToMove, ciphertextSourceModifiableFolder, overwrite, newCiphertextName, cancellationToken, fallback); diff --git a/src/Core/SecureFolderFS.Core.WebDav/AppModels/WebDavOptions.cs b/src/Core/SecureFolderFS.Core.WebDav/AppModels/WebDavOptions.cs index 2dbef30de..a3c31c970 100644 --- a/src/Core/SecureFolderFS.Core.WebDav/AppModels/WebDavOptions.cs +++ b/src/Core/SecureFolderFS.Core.WebDav/AppModels/WebDavOptions.cs @@ -1,8 +1,8 @@ -using SecureFolderFS.Core.FileSystem.AppModels; +using System; +using System.Collections.Generic; +using SecureFolderFS.Core.FileSystem.AppModels; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Storage.VirtualFileSystem; -using System; -using System.Collections.Generic; namespace SecureFolderFS.Core.WebDav.AppModels { @@ -49,6 +49,7 @@ public static WebDavOptions ToOptions(IDictionary options) IsCachingFileNames = (bool?)options.Get(nameof(IsCachingFileNames)) ?? true, IsCachingDirectoryIds = (bool?)options.Get(nameof(IsCachingDirectoryIds)) ?? true, RecycleBinSize = (long?)options.Get(nameof(RecycleBinSize)) ?? 0L, + ShorteningThreshold = (int?)options.Get(nameof(ShorteningThreshold)) ?? 0, // WebDav specific Protocol = (string?)options.Get(nameof(Protocol)) ?? "http", diff --git a/src/Core/SecureFolderFS.Core.WinFsp/AppModels/WinFspOptions.cs b/src/Core/SecureFolderFS.Core.WinFsp/AppModels/WinFspOptions.cs index fa8a1a22e..a284a1d01 100644 --- a/src/Core/SecureFolderFS.Core.WinFsp/AppModels/WinFspOptions.cs +++ b/src/Core/SecureFolderFS.Core.WinFsp/AppModels/WinFspOptions.cs @@ -1,8 +1,8 @@ -using SecureFolderFS.Core.FileSystem.AppModels; +using System; +using System.Collections.Generic; +using SecureFolderFS.Core.FileSystem.AppModels; using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Storage.VirtualFileSystem; -using System; -using System.Collections.Generic; namespace SecureFolderFS.Core.WinFsp.AppModels { @@ -36,6 +36,7 @@ public static WinFspOptions ToOptions(IDictionary options) IsCachingFileNames = (bool?)options.Get(nameof(IsCachingFileNames)) ?? true, IsCachingDirectoryIds = (bool?)options.Get(nameof(IsCachingDirectoryIds)) ?? true, RecycleBinSize = (long?)options.Get(nameof(RecycleBinSize)) ?? 0L, + ShorteningThreshold = (int?)options.Get(nameof(ShorteningThreshold)) ?? 0, // WinFsp specific MountPoint = (string?)options.Get(nameof(MountPoint)) diff --git a/src/Core/SecureFolderFS.Core/Constants.cs b/src/Core/SecureFolderFS.Core/Constants.cs index eeedc5336..c307b33fc 100644 --- a/src/Core/SecureFolderFS.Core/Constants.cs +++ b/src/Core/SecureFolderFS.Core/Constants.cs @@ -45,6 +45,7 @@ public static class Associations public const string ASSOC_CONTENT_CIPHER_ID = "contentCipherScheme"; public const string ASSOC_FILENAME_CIPHER_ID = "filenameCipherScheme"; public const string ASSOC_FILENAME_ENCODING_ID = "filenameEncoding"; + public const string ASSOC_FILENAME_SHORTENING = "filenameShortening"; public const string ASSOC_RECYCLE_SIZE = "recycleBinSize"; public const string ASSOC_AUTHENTICATION = "authMode"; public const string ASSOC_VAULT_ID = "vaultId"; diff --git a/src/Core/SecureFolderFS.Core/DataModels/V4VaultConfigurationDataModel.cs b/src/Core/SecureFolderFS.Core/DataModels/V4VaultConfigurationDataModel.cs index 38ea79dbd..904be33ab 100644 --- a/src/Core/SecureFolderFS.Core/DataModels/V4VaultConfigurationDataModel.cs +++ b/src/Core/SecureFolderFS.Core/DataModels/V4VaultConfigurationDataModel.cs @@ -29,7 +29,14 @@ public sealed record class V4VaultConfigurationDataModel : VersionDataModel /// [JsonPropertyName(Associations.ASSOC_FILENAME_ENCODING_ID)] [DefaultValue("")] - public string FileNameEncodingId { get; set; } = Cryptography.Constants.CipherId.ENCODING_BASE64URL; + public required string FileNameEncodingId { get; set; } = Cryptography.Constants.CipherId.ENCODING_BASE64URL; + + /// + /// Gets the threshold for shortening file names. + /// + [JsonPropertyName(Associations.ASSOC_FILENAME_SHORTENING)] + [DefaultValue(0)] + public required int ShorteningThreshold { get; init; } /// /// Gets the size of the recycle bin. @@ -77,6 +84,7 @@ public static V4VaultConfigurationDataModel V4FromVaultOptions(VaultOptions vaul ContentCipherId = vaultOptions.ContentCipherId ?? Cryptography.Constants.CipherId.XCHACHA20_POLY1305, FileNameCipherId = vaultOptions.FileNameCipherId ?? Cryptography.Constants.CipherId.AES_SIV, FileNameEncodingId = vaultOptions.NameEncodingId ?? Cryptography.Constants.CipherId.ENCODING_BASE64URL, + ShorteningThreshold = vaultOptions.ShorteningThreshold, AuthenticationMethod = vaultOptions.UnlockProcedure.ToString(), RecycleBinSize = vaultOptions.RecycleBinSize, Uid = vaultOptions.VaultId ?? Guid.NewGuid().ToString(), diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyComplementationRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyComplementationRoutine.cs index 7c31a5692..bd389f017 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyComplementationRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyComplementationRoutine.cs @@ -37,8 +37,8 @@ public ModifyComplementationRoutine(VaultReader vaultReader, VaultWriter vaultWr /// public async Task InitAsync(CancellationToken cancellationToken = default) { - _existingConfigDataModel = await _vaultReader.ReadV4ConfigurationAsync(cancellationToken); - _existingKeystoreDataModel = await _vaultReader.ReadKeystoreAsync(cancellationToken); + _existingConfigDataModel = await _vaultReader.ReadConfigurationAsync(cancellationToken); + _existingKeystoreDataModel = await _vaultReader.ReadKeystoreAsync(cancellationToken); _existingSharesDataModel = await _vaultReader.ReadComplementationAsync(cancellationToken); } diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs index ffd687a3a..427d574c7 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/ModifyCredentialsRoutine.cs @@ -32,7 +32,7 @@ public ModifyCredentialsRoutine(VaultReader vaultReader, VaultWriter vaultWriter /// public async Task InitAsync(CancellationToken cancellationToken = default) { - _existingV4KeystoreDataModel = await _vaultReader.ReadKeystoreAsync(cancellationToken); + _existingV4KeystoreDataModel = await _vaultReader.ReadKeystoreAsync(cancellationToken); } /// diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/RecoverRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/RecoverRoutine.cs index 0d976b5b8..9f85743e5 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/RecoverRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/RecoverRoutine.cs @@ -25,7 +25,7 @@ public RecoverRoutine(VaultReader vaultReader) /// public async Task InitAsync(CancellationToken cancellationToken) { - _configDataModel = await _vaultReader.ReadV4ConfigurationAsync(cancellationToken); + _configDataModel = await _vaultReader.ReadConfigurationAsync(cancellationToken); } /// diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/RestoreRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/RestoreRoutine.cs index 12d95dfeb..2d7d4c847 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/RestoreRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/RestoreRoutine.cs @@ -69,6 +69,7 @@ public async Task FinalizeAsync(CancellationToken cancellationToken string? foundNameCrypt = null; string? foundEncoding = null; var noExtensions = 0; + var shorteningThreshold = 0; var folderScanner = new DeepFolderScanner(contentFolder, StorableType.File); await foreach (var item in folderScanner.ScanFolderAsync(cancellationToken)) @@ -76,6 +77,9 @@ public async Task FinalizeAsync(CancellationToken cancellationToken if (item is not IFile file) continue; + if (shorteningThreshold == 0 && item.Name.EndsWith(FileSystem.Constants.Names.SHORTENED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) + shorteningThreshold = 220; // Arbitrary threshold typical for shortened names + if (!item.Name.EndsWith(FileSystem.Constants.Names.ENCRYPTED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) noExtensions++; @@ -106,6 +110,7 @@ public async Task FinalizeAsync(CancellationToken cancellationToken ContentCipherId = foundContentCrypt, FileNameCipherId = foundNameCrypt, FileNameEncodingId = foundEncoding, + ShorteningThreshold = shorteningThreshold, RecycleBinSize = 0L, Uid = Guid.NewGuid().ToString(), Version = Constants.Vault.Versions.LATEST_VERSION, diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs index b3449b2c7..a77f92ef5 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs @@ -32,8 +32,8 @@ public UnlockRoutine(VaultReader vaultReader) /// public async Task InitAsync(CancellationToken cancellationToken) { - _configDataModel = await _vaultReader.ReadV4ConfigurationAsync(cancellationToken); - _keystoreDataModel = await _vaultReader.ReadKeystoreAsync(cancellationToken); + _configDataModel = await _vaultReader.ReadConfigurationAsync(cancellationToken); + _keystoreDataModel = await _vaultReader.ReadKeystoreAsync(cancellationToken); _sharesDataModel = await _vaultReader.ReadComplementationAsync(cancellationToken); } diff --git a/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs b/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs index 47fea346b..de6764375 100644 --- a/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs +++ b/src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs @@ -43,13 +43,14 @@ public static void V4CalculateConfigMac(V4VaultConfigurationDataModel configData hmacSha256.AppendData(BitConverter.GetBytes(CryptHelpers.ContentCipherId(configDataModel.ContentCipherId))); hmacSha256.AppendData(BitConverter.GetBytes(CryptHelpers.FileNameCipherId(configDataModel.FileNameCipherId))); hmacSha256.AppendData(BitConverter.GetBytes(configDataModel.RecycleBinSize)); + hmacSha256.AppendData(BitConverter.GetBytes(configDataModel.ShorteningThreshold)); hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.FileNameEncodingId)); hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.Uid)); - hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.AppPlatform?.ServerUrl ?? string.Empty)); - hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.AppPlatform?.VaultResource ?? string.Empty)); - hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.AppPlatform?.Organization ?? string.Empty)); - hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.AppPlatform?.AccessTokenEndpoint ?? string.Empty)); - hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.AppPlatform?.DeviceRegistrationEndpoint ?? string.Empty)); + // hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.AppPlatform?.ServerUrl ?? string.Empty)); + // hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.AppPlatform?.VaultResource ?? string.Empty)); + // hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.AppPlatform?.Organization ?? string.Empty)); + // hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.AppPlatform?.AccessTokenEndpoint ?? string.Empty)); + // hmacSha256.AppendData(Encoding.UTF8.GetBytes(configDataModel.AppPlatform?.DeviceRegistrationEndpoint ?? string.Empty)); hmacSha256.AppendFinalData(Encoding.UTF8.GetBytes(configDataModel.AuthenticationMethod)); hmacSha256.GetCurrentHash(mac); diff --git a/src/Core/SecureFolderFS.Core/VaultAccess/VaultReader.cs b/src/Core/SecureFolderFS.Core/VaultAccess/VaultReader.cs index c87017c8d..4b7cf1c0e 100644 --- a/src/Core/SecureFolderFS.Core/VaultAccess/VaultReader.cs +++ b/src/Core/SecureFolderFS.Core/VaultAccess/VaultReader.cs @@ -23,6 +23,16 @@ public VaultReader(IFolder vaultFolder, IAsyncSerializer serializer) _serializer = serializer; } + public async Task ReadConfigurationAsync(CancellationToken cancellationToken) + { + return await ReadConfigurationAsync(cancellationToken); + } + + public async Task ReadKeystoreAsync(CancellationToken cancellationToken) + { + return await ReadKeystoreAsync(cancellationToken); + } + /// /// Reads the keystore as the specified type. /// @@ -34,17 +44,15 @@ public async Task ReadKeystoreAsync(CancellationToken canc return await ReadDataAsync(keystoreFile, _serializer, cancellationToken); } - public async Task ReadConfigurationAsync(CancellationToken cancellationToken) + /// + /// Reads the configuration file as the specified type. + /// + public async Task ReadConfigurationAsync(CancellationToken cancellationToken) + where TConfiguration : class { // Get configuration file var configFile = await _vaultFolder.GetFileByNameAsync(Constants.Vault.Names.VAULT_CONFIGURATION_FILENAME, cancellationToken); - return await ReadDataAsync(configFile, _serializer, cancellationToken); - } - - public async Task ReadV4ConfigurationAsync(CancellationToken cancellationToken) - { - var configFile = await _vaultFolder.GetFileByNameAsync(Constants.Vault.Names.VAULT_CONFIGURATION_FILENAME, cancellationToken); - return await ReadDataAsync(configFile, _serializer, cancellationToken); + return await ReadDataAsync(configFile, _serializer, cancellationToken); } public async Task ReadComplementationAsync(CancellationToken cancellationToken) diff --git a/src/Core/SecureFolderFS.Core/VaultAccess/VaultWriter.cs b/src/Core/SecureFolderFS.Core/VaultAccess/VaultWriter.cs index 6c232d541..daa9c24f5 100644 --- a/src/Core/SecureFolderFS.Core/VaultAccess/VaultWriter.cs +++ b/src/Core/SecureFolderFS.Core/VaultAccess/VaultWriter.cs @@ -36,7 +36,8 @@ public async Task WriteKeystoreAsync(TKeystore? keystoreDataModel, Ca await WriteDataAsync(keystoreFile, keystoreDataModel, cancellationToken); } - public async Task WriteConfigurationAsync(VaultConfigurationDataModel? configDataModel, CancellationToken cancellationToken) + public async Task WriteConfigurationAsync(TConfiguration? configDataModel, CancellationToken cancellationToken) + where TConfiguration : class { var configFile = configDataModel is null ? null : _vaultFolder switch { diff --git a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/RecycleBinService.cs b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/RecycleBinService.cs index 3b33ef479..c5a0d2d9e 100644 --- a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/RecycleBinService.cs +++ b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/RecycleBinService.cs @@ -36,7 +36,7 @@ public async Task ConfigureRecycleBinAsync(UnlockedVaultViewModel unlockedViewMo var configDataModel = await vaultReader.ReadConfigurationAsync(cancellationToken); if (configDataModel.AuthenticationMethod.Contains(Core.Constants.Vault.Authentication.AUTH_APP_PLATFORM, StringComparison.Ordinal)) { - var v4ConfigDataModel = await vaultReader.ReadV4ConfigurationAsync(cancellationToken); + var v4ConfigDataModel = await vaultReader.ReadConfigurationAsync(cancellationToken); var newV4ConfigDataModel = v4ConfigDataModel with { RecycleBinSize = maxSize, @@ -61,7 +61,7 @@ public async Task ConfigureRecycleBinAsync(UnlockedVaultViewModel unlockedViewMo // First, we need to fill in the PayloadMac of the content specifics.Security.KeyPair.MacKey.UseKey(macKey => { - VaultParser.CalculateConfigMac(newConfigDataModel, macKey, newConfigDataModel.PayloadMac); + VaultParser.V4CalculateConfigMac(newConfigDataModel, macKey, newConfigDataModel.PayloadMac); }); await vaultWriter.WriteConfigurationAsync(newConfigDataModel, cancellationToken); diff --git a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultService.cs b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultService.cs index eb5f106bd..f01282b19 100644 --- a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultService.cs +++ b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultService.cs @@ -25,7 +25,7 @@ public class VaultService : IVaultService public IAsyncValidator VaultValidator { get; } = new VaultValidator(StreamSerializer.Instance); /// - public string ShortcutFileExtension { get; } = UI.Constants.FileNames.VAULT_SHORTCUT_FILE_EXTENSION; + public string ShortcutFileExtension { get; } = Constants.FileNames.VAULT_SHORTCUT_FILE_EXTENSION; /// public virtual bool IsNameReserved(string? name) @@ -42,20 +42,6 @@ public virtual async Task GetVaultOptionsAsync(IFolder vaultFolder { var vaultReader = new VaultReader(vaultFolder, StreamSerializer.Instance); var config = await vaultReader.ReadConfigurationAsync(cancellationToken); - AppPlatformVaultOptions? appPlatform = null; - - if (config.AuthenticationMethod.Contains(Core.Constants.Vault.Authentication.AUTH_APP_PLATFORM, StringComparison.Ordinal)) - { - try - { - var v4Config = await vaultReader.ReadV4ConfigurationAsync(cancellationToken); - appPlatform = v4Config.AppPlatform; - } - catch (Exception) - { - appPlatform = null; - } - } return new() { @@ -63,10 +49,11 @@ public virtual async Task GetVaultOptionsAsync(IFolder vaultFolder ContentCipherId = config.ContentCipherId, FileNameCipherId = config.FileNameCipherId, NameEncodingId = config.FileNameEncodingId, + ShorteningThreshold = config.ShorteningThreshold, RecycleBinSize = config.RecycleBinSize, VaultId = config.Uid, Version = config.Version, - AppPlatform = appPlatform + AppPlatform = config.AppPlatform }; } diff --git a/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultPropertiesPage.xaml b/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultPropertiesPage.xaml index 7f3844509..75fbb555d 100644 --- a/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultPropertiesPage.xaml +++ b/src/Platforms/SecureFolderFS.Uno/Views/Vault/VaultPropertiesPage.xaml @@ -38,6 +38,10 @@ + @@ -53,6 +57,12 @@ IsTextSelectionEnabled="True" Opacity="0.6" Text="{x:Bind ViewModel.FileNameCipherText, Mode=OneWay}" /> + + @@ -122,6 +123,25 @@ + + + + diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultPropertiesViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultPropertiesViewModel.cs index c7368712c..02ba7b2e2 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultPropertiesViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Vault/VaultPropertiesViewModel.cs @@ -23,6 +23,7 @@ public sealed partial class VaultPropertiesViewModel : BaseDesignationViewModel, [ObservableProperty] private string? _SecurityText; [ObservableProperty] private string? _ContentCipherText; [ObservableProperty] private string? _FileNameCipherText; + [ObservableProperty] private string? _FileNameShorteningText; [ObservableProperty] private string? _ActiveFileSystemText; [ObservableProperty] private string? _FileSystemDescriptionText; @@ -49,8 +50,12 @@ public VaultPropertiesViewModel(UnlockedVaultViewModel unlockedVaultViewModel, I public async Task InitAsync(CancellationToken cancellationToken = default) { var vaultOptions = await VaultService.GetVaultOptionsAsync(UnlockedVaultViewModel.VaultFolder, cancellationToken); - ContentCipherText = string.IsNullOrEmpty(vaultOptions.ContentCipherId) ? "NoEncryption".ToLocalized() : (vaultOptions.ContentCipherId ?? "Unknown"); - FileNameCipherText = string.IsNullOrEmpty(vaultOptions.FileNameCipherId) ? "NoEncryption".ToLocalized() : (vaultOptions.FileNameCipherId ?? "Unknown") + $" + {vaultOptions.NameEncodingId}"; + var areNamesEncrypted = !string.IsNullOrEmpty(vaultOptions.FileNameCipherId); + var areContentsEncrypted = !string.IsNullOrEmpty(vaultOptions.ContentCipherId); + + ContentCipherText = !areContentsEncrypted ? "NoEncryption".ToLocalized() : (vaultOptions.ContentCipherId ?? "Unknown"); + FileNameCipherText = !areNamesEncrypted ? "NoEncryption".ToLocalized() : (vaultOptions.FileNameCipherId ?? "Unknown") + $" + {vaultOptions.NameEncodingId}"; + FileNameShorteningText = !areNamesEncrypted ? null : vaultOptions.ShorteningThreshold.ToString(); ActiveFileSystemText = UnlockedVaultViewModel.StorageRoot.FileSystemName; FileSystemDescriptionText = UnlockedVaultViewModel.StorageRoot.Options.GetDescription(); SecurityText = await VaultCredentialsService.FromUnlockProcedureAsync(UnlockedVaultViewModel.VaultFolder, vaultOptions.UnlockProcedure, cancellationToken); diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/CredentialsWizardViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/CredentialsWizardViewModel.cs index 739a802be..dfe906ae9 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/CredentialsWizardViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Wizard/CredentialsWizardViewModel.cs @@ -32,6 +32,7 @@ public sealed partial class CredentialsWizardViewModel : OverlayViewModel, IStag private readonly string _vaultId; private readonly TaskCompletionSource _credentialsTcs; + [ObservableProperty] private int _ShorteningThreshold; [ObservableProperty] private bool _IsNameCipherEnabled; [ObservableProperty] private PickerOptionViewModel? _ContentCipher; [ObservableProperty] private PickerOptionViewModel? _FileNameCipher; @@ -87,6 +88,7 @@ public async Task TryContinueAsync(CancellationToken cancellationToken) ContentCipherId = ContentCipher.Id, FileNameCipherId = FileNameCipher.Id, NameEncodingId = EncodingOption.Id, + ShorteningThreshold = Math.Max(0, Math.Min(ShorteningThreshold, 250)), RecycleBinSize = 0L, VaultId = _vaultId }; diff --git a/src/Shared/SecureFolderFS.Shared/Models/VaultOptions.cs b/src/Shared/SecureFolderFS.Shared/Models/VaultOptions.cs index 9750831ef..6cd174653 100644 --- a/src/Shared/SecureFolderFS.Shared/Models/VaultOptions.cs +++ b/src/Shared/SecureFolderFS.Shared/Models/VaultOptions.cs @@ -25,6 +25,11 @@ public sealed record class VaultOptions /// public string? NameEncodingId { get; init; } + /// + /// Gets the threshold at which the file names are shortened. + /// + public int ShorteningThreshold { get; init; } + /// /// Gets the size of the recycle bin. /// diff --git a/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/VirtualFileSystemOptions.cs b/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/VirtualFileSystemOptions.cs index 8dd52a9fe..6aa1d14af 100644 --- a/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/VirtualFileSystemOptions.cs +++ b/src/Shared/SecureFolderFS.Storage/VirtualFileSystem/VirtualFileSystemOptions.cs @@ -37,6 +37,11 @@ public class VirtualFileSystemOptions : FileSystemOptions /// public bool IsCachingFileNames { get; protected set => SetField(ref field, value); } = true; + /// + /// Gets or sets the threshold for shortening file names. + /// + public required int ShorteningThreshold { get; init; } + /// /// Sets the read-only status of the file system. /// @@ -85,6 +90,7 @@ public static VirtualFileSystemOptions ToOptions( IsCachingChunks = GetOption(options, nameof(IsCachingChunks)) ?? true, IsCachingFileNames = GetOption(options, nameof(IsCachingFileNames)) ?? true, IsCachingDirectoryIds = GetOption(options, nameof(IsCachingDirectoryIds)) ?? true, + ShorteningThreshold = GetOption(options, nameof(ShorteningThreshold)) ?? 0, RecycleBinSize = GetOption(options, nameof(RecycleBinSize)) ?? 0L }; } From dc85c4c432cded90d78cbb179d62c064d6c70ac0 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Fri, 22 May 2026 19:39:47 +0200 Subject: [PATCH 03/14] Implement shortening for CryptoFolder --- .../Abstract/AbstractPathHelpers.Names.cs | 43 ++++++++++++++++ .../Abstract/AbstractPathHelpers.Paths.cs | 6 +-- .../AbstractPathHelpers.Shortening.cs | 51 ++++++++++++++++++- .../AbstractRecycleBinHelpers.Operational.cs | 6 +-- .../Storage/CryptoFolder.cs | 26 +++++++--- .../Models/SecurityWrapper.cs | 1 + 6 files changed, 117 insertions(+), 16 deletions(-) diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Names.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Names.cs index 1244a8cf7..1bb98b54d 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Names.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Names.cs @@ -110,6 +110,49 @@ public static async Task EncryptNameForUseAsync(string plaintextName, IF #endregion + #region Encrypt Name Discoverability + + /// + public static async Task EncryptNameForDiscoveryAsync(string plaintextName, IFolder ciphertextParentFolder, + FileSystemSpecifics specifics, CancellationToken cancellationToken = default) + { + if (specifics.Security.NameCrypt is null) + return plaintextName; + + var directoryId = AllocateDirectoryId(specifics.Security, plaintextName); + return await EncryptNameForDiscoveryAsync(plaintextName, ciphertextParentFolder, specifics, directoryId, cancellationToken); + } + + /// + /// Encrypts the provided and discovers potential shortening branch. + /// + /// The name to encrypt. + /// The ciphertext parent folder. + /// The instance associated with the item. + /// A buffer of size which will be used to hold the Directory ID data. + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is an encrypted name with the appropriate file extension appended. + public static async Task EncryptNameForDiscoveryAsync(string plaintextName, IFolder ciphertextParentFolder, + FileSystemSpecifics specifics, byte[]? expendableDirectoryId = null, CancellationToken cancellationToken = default) + { + if (specifics.Security.NameCrypt is null) + return plaintextName; + + expendableDirectoryId ??= AllocateDirectoryId(specifics.Security, plaintextName); + var result = await GetDirectoryIdAsync(ciphertextParentFolder, specifics, expendableDirectoryId, cancellationToken); + + var encryptedName = specifics.Security.NameCrypt.EncryptName(plaintextName, result ? expendableDirectoryId : ReadOnlySpan.Empty) + Constants.Names.ENCRYPTED_FILE_EXTENSION; + if (specifics.Options.ShorteningThreshold > 0 && encryptedName.Length >= specifics.Options.ShorteningThreshold) + { + var shortenedBase = ComputeShortenedNameBase(encryptedName); + return shortenedBase + Constants.Names.SHORTENED_FILE_EXTENSION; + } + + return encryptedName; + } + + #endregion + /// /// Encrypts a plaintext name using the specified Directory ID and security parameters. /// diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Paths.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Paths.cs index 6b5656ca0..52aa11184 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Paths.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Paths.cs @@ -20,7 +20,7 @@ public static partial class AbstractPathHelpers foreach (var plaintextName in plaintextPath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) { - var ciphertextName = await EncryptNameAsync(plaintextName, currentParent, specifics, cancellationToken); + var ciphertextName = await EncryptNameForDiscoveryAsync(plaintextName, currentParent, specifics, cancellationToken); finalItem = await currentParent.GetFirstByNameAsync(ciphertextName, cancellationToken); if (finalItem is IFolder nextParent) @@ -56,12 +56,12 @@ public static partial class AbstractPathHelpers foreach (var item in folderChain) { // Walk through plaintext folder chain and retrieve ciphertext folders - var subCiphertextName = await EncryptNameAsync(item.Name, finalFolder, specifics, expendableDirectoryId, cancellationToken).ConfigureAwait(false); + var subCiphertextName = await EncryptNameForDiscoveryAsync(item.Name, finalFolder, specifics, expendableDirectoryId, cancellationToken).ConfigureAwait(false); finalFolder = await finalFolder.GetFolderByNameAsync(subCiphertextName, cancellationToken); } // Encrypt and retrieve the final item - var ciphertextName = await EncryptNameAsync(plaintextStorable.Name, finalFolder, specifics, expendableDirectoryId, cancellationToken).ConfigureAwait(false); + var ciphertextName = await EncryptNameForDiscoveryAsync(plaintextStorable.Name, finalFolder, specifics, expendableDirectoryId, cancellationToken).ConfigureAwait(false); return await finalFolder.GetFirstByNameAsync(ciphertextName, cancellationToken); } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Shortening.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Shortening.cs index f5b70d089..e8dce558b 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Shortening.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Shortening.cs @@ -15,10 +15,45 @@ public static partial class AbstractPathHelpers { private const int MAX_SIDECAR_BYTES = 4096; // No legitimate ciphertext name approaches this upper bound + /// + /// Tries to generate the name of the sidecar file associated with the given disk name. + /// + /// The disk name to evaluate for a potential sidecar file name. + /// A string representing the sidecar file name if the disk name matches the expected format, or null if it does not. + public static string? TryGetSidecarName(string diskName) + { + return diskName.EndsWith(Constants.Names.SHORTENED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase) + ? RemoveShortenedExtension(diskName).ToString() + Constants.Names.SIDECAR_FILE_EXTENSION + : null; + } + + /// + /// Deletes the sidecar file for a shortened item, if it exists. + /// + /// The shortened ciphertext name of the item. + /// The parent folder where the item is found. + /// The instance associated with the item. + /// A that cancels this action. + /// A that represents the asynchronous operation. + public static async Task DeleteSidecarFileAsync(string ciphertextItemName, IModifiableFolder ciphertextParent, FileSystemSpecifics specifics, CancellationToken cancellationToken = default) + { + if (specifics.Options.ShorteningThreshold > 0 && ciphertextItemName.Length >= specifics.Options.ShorteningThreshold) + { + var oldSidecarName = TryGetSidecarName(ciphertextItemName); + if (oldSidecarName is null) + return; + + var oldSidecar = await ciphertextParent.TryGetFileByNameAsync(oldSidecarName, cancellationToken); + if (oldSidecar is not null) + await ciphertextParent.DeleteAsync(oldSidecar, cancellationToken); + } + } + /// /// Returns whether is a name-shortening sidecar file (). /// Sidecar files are an internal implementation detail and should be excluded from vault enumeration. /// + /// The name to evaluate. /// True, if the is a sidecar file; otherwise, false public static bool IsSidecarName(string name) { @@ -28,6 +63,7 @@ public static bool IsSidecarName(string name) /// /// Removes the shortened file extension from the specified filename if present. /// + /// The filename with an optional shortened file extension. /// A or without ; otherwise, . /// /// The returned may contain an extension other than . @@ -44,6 +80,8 @@ public static ReadOnlySpan RemoveShortenedExtension(string shortenedName) /// The result is a URL-safe Base64 encoding of the first 20 bytes of SHA-256(UTF-8()), /// yielding a fixed 27-character string. /// + /// The full ciphertext name to compute the base for. + /// A deterministic, filesystem-safe name base (no extension) for . [SkipLocalsInit] private static string ComputeShortenedNameBase(string ciphertextName) { @@ -59,10 +97,15 @@ private static string ComputeShortenedNameBase(string ciphertextName) /// The sidecar is named + . /// Must be written before the shortened file/directory is created. /// + /// The parent folder where the sidecar file will be written. + /// The deterministic, filesystem-safe name base (no extension) for the shortened file. + /// The full ciphertext name to write to the sidecar file. + /// A that cancels this action. + /// A that represents the asynchronous operation. private static async Task WriteSidecarAsync( IFolder parentFolder, string shortenedBase, - string fullCiphertextName, + string ciphertextName, CancellationToken cancellationToken) { if (parentFolder is not IModifiableFolder modifiableFolder) @@ -71,13 +114,17 @@ private static async Task WriteSidecarAsync( var sidecarName = shortenedBase + Constants.Names.SIDECAR_FILE_EXTENSION; var sidecarFile = await modifiableFolder.CreateFileAsync(sidecarName, overwrite: true, cancellationToken); await using var stream = await sidecarFile.OpenStreamAsync(FileAccess.Write, cancellationToken); - await stream.WriteAsync(Encoding.UTF8.GetBytes(fullCiphertextName), cancellationToken); + await stream.WriteAsync(Encoding.UTF8.GetBytes(ciphertextName), cancellationToken); } /// /// Reads the full ciphertext name from a sidecar file. /// Returns if the sidecar does not exist or cannot be read. /// + /// The parent folder where the sidecar file is located. + /// The deterministic, filesystem-safe name base (no extension) for the shortened file. + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is the full ciphertext name contained in the sidecar file, or if the sidecar does not exist or cannot be read. private static async Task ReadSidecarAsync( IFolder parentFolder, string shortenedBase, diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs index 23dc77ee5..16d491990 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs @@ -78,7 +78,7 @@ public static async Task RestoreAsync(IStorableChild recycleBinItem, IModifiable { // Destination folder is different from the original destination // A new item name should be chosen fit for the new folder (so that Directory ID match) - var ciphertextName = await AbstractPathHelpers.EncryptNameAsync(plaintextOriginalName, ciphertextDestinationFolder, specifics, cancellationToken); + var ciphertextName = await AbstractPathHelpers.EncryptNameForDiscoveryAsync(plaintextOriginalName, ciphertextDestinationFolder, specifics, cancellationToken); // Get an available name if the destination already exists ciphertextName = await GetAvailableDestinationNameAsync(ciphertextDestinationFolder, ciphertextName, plaintextOriginalName, specifics, cancellationToken); @@ -90,7 +90,7 @@ public static async Task RestoreAsync(IStorableChild recycleBinItem, IModifiable { // Destination folder is the same as the original destination // The same name could be used since the Directory IDs match - var ciphertextName = await AbstractPathHelpers.EncryptNameAsync(plaintextOriginalName, ciphertextDestinationFolder, specifics, cancellationToken); + var ciphertextName = await AbstractPathHelpers.EncryptNameForDiscoveryAsync(plaintextOriginalName, ciphertextDestinationFolder, specifics, cancellationToken); // Get an available name if the destination already exists ciphertextName = await GetAvailableDestinationNameAsync(ciphertextDestinationFolder, ciphertextName, plaintextOriginalName, specifics, cancellationToken); @@ -244,7 +244,7 @@ private static async Task GetAvailableDestinationNameAsync(IFolder ciphe do { var newPlaintextName = $"{nameWithoutExtension} ({suffix}){extension}"; - ciphertextName = await AbstractPathHelpers.EncryptNameAsync(newPlaintextName, ciphertextDestinationFolder, specifics, cancellationToken); + ciphertextName = await AbstractPathHelpers.EncryptNameForDiscoveryAsync(newPlaintextName, ciphertextDestinationFolder, specifics, cancellationToken); existing = await ciphertextDestinationFolder.TryGetFirstByNameAsync(ciphertextName, cancellationToken); suffix++; } while (existing is not null); diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs index 08555231d..231af8ff1 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs @@ -51,13 +51,16 @@ public virtual async Task RenameAsync(IStorableChild storable, s throw new NotSupportedException("Renaming folder contents is not supported."); // We need to get the equivalent on the disk - var ciphertextName = await AbstractPathHelpers.EncryptNameAsync(storable.Name, Inner, specifics, cancellationToken); + var ciphertextName = await AbstractPathHelpers.EncryptNameForDiscoveryAsync(storable.Name, Inner, specifics, cancellationToken); var ciphertextItem = await Inner.GetFirstByNameAsync(ciphertextName, cancellationToken); // Encrypt name - var newCiphertextName = await AbstractPathHelpers.EncryptNameAsync(newName, Inner, specifics, cancellationToken); + var newCiphertextName = await AbstractPathHelpers.EncryptNameForUseAsync(newName, Inner, specifics, cancellationToken); var renamedCiphertextItem = await renamableFolder.RenameAsync(ciphertextItem, newCiphertextName, cancellationToken); + // Clean up old sidecar if the old name was shortened + await AbstractPathHelpers.DeleteSidecarFileAsync(ciphertextName, renamableFolder, specifics, cancellationToken); + var plaintextId = Path.Combine(Inner.Id, newName); return renamedCiphertextItem switch { @@ -91,7 +94,7 @@ public virtual async IAsyncEnumerable GetItemsAsync(StorableType /// public virtual async Task GetFirstByNameAsync(string name, CancellationToken cancellationToken = default) { - var ciphertextName = await AbstractPathHelpers.EncryptNameAsync(name, Inner, specifics, cancellationToken); + var ciphertextName = await AbstractPathHelpers.EncryptNameForDiscoveryAsync(name, Inner, specifics, cancellationToken); return await Inner.GetFirstByNameAsync(ciphertextName, cancellationToken) switch { IChildFile file => (IStorableChild)Wrap(file, name), @@ -123,7 +126,7 @@ public virtual async Task DeleteAsync(IStorableChild item, long sizeHint = -1L, throw new NotSupportedException("Modifying folder contents is not supported."); // We need to get the equivalent on the disk - var ciphertextName = await AbstractPathHelpers.EncryptNameAsync(item.Name, Inner, specifics, cancellationToken); + var ciphertextName = await AbstractPathHelpers.EncryptNameForDiscoveryAsync(item.Name, Inner, specifics, cancellationToken); var ciphertextItem = await Inner.GetFirstByNameAsync(ciphertextName, cancellationToken); if (deleteImmediately) @@ -137,6 +140,9 @@ public virtual async Task DeleteAsync(IStorableChild item, long sizeHint = -1L, await AbstractRecycleBinHelpers.DeleteOrRecycleAsync(modifiableFolder, ciphertextItem, specifics, StreamSerializer.Instance, sizeHint, cancellationToken: cancellationToken); } + // Clean up old sidecar if the old name was shortened + await AbstractPathHelpers.DeleteSidecarFileAsync(ciphertextName, modifiableFolder, specifics, cancellationToken); + // Remove deleted directory from cache if (ciphertextItem is IFolder) specifics.DirectoryIdCache.CacheRemove(Path.Combine(ciphertextItem.Id, Constants.Names.DIRECTORY_ID_FILENAME)); @@ -244,7 +250,7 @@ public virtual async Task MoveFromAsync(IChildFile fileToMove, IModi return await fallback(this, fileToMove, source, overwrite, newName, cancellationToken); // Get the ciphertext representation of the file to move - var existingCiphertextName = await AbstractPathHelpers.EncryptNameAsync(fileToMove.Name, ciphertextSource, specifics, cancellationToken); + var existingCiphertextName = await AbstractPathHelpers.EncryptNameForDiscoveryAsync(fileToMove.Name, ciphertextSource, specifics, cancellationToken); var ciphertextFileToMove = await ciphertextSource.TryGetFileByNameAsync(existingCiphertextName, cancellationToken); if (ciphertextFileToMove is null) return await fallback(this, fileToMove, source, overwrite, newName, cancellationToken); @@ -254,6 +260,10 @@ public virtual async Task MoveFromAsync(IChildFile fileToMove, IModi // Move the ciphertext file var movedCiphertextFile = await moveRenamedFrom.MoveFromAsync(ciphertextFileToMove, ciphertextSourceModifiableFolder, overwrite, newCiphertextName, cancellationToken, fallback); + + // Clean up old sidecar if the old name was shortened + await AbstractPathHelpers.DeleteSidecarFileAsync(existingCiphertextName, ciphertextSourceModifiableFolder, specifics, cancellationToken); + return (IChildFile)Wrap(movedCiphertextFile, newName); } @@ -279,10 +289,10 @@ public virtual async Task MoveFromAsync(IChildFile fileToMove, IModi if (folderWrapper.GetWrapperAt() is not { Inner: var ciphertextRoot }) return null; - if (parentFolder.Id == Path.DirectorySeparatorChar.ToString()) + if (parentFolder.Id == Path.DirectorySeparatorChar.ToString() || parentFolder.Id == specifics.ContentFolder.Id) return ciphertextRoot as TStorable; - var ciphertextName = await AbstractPathHelpers.EncryptNameAsync(item.Name, ciphertextRoot, specifics, cancellationToken); + var ciphertextName = await AbstractPathHelpers.EncryptNameForDiscoveryAsync(item.Name, ciphertextRoot, specifics, cancellationToken); return await ciphertextRoot.TryGetFirstByNameAsync(ciphertextName, cancellationToken) as TStorable; } @@ -292,7 +302,7 @@ public virtual async Task MoveFromAsync(IChildFile fileToMove, IModi if (parentFolderWrapper.GetWrapperAt() is not { Inner: var ciphertextParent }) return null; - var ciphertextName2 = await AbstractPathHelpers.EncryptNameAsync(item.Name, ciphertextParent, specifics, cancellationToken); + var ciphertextName2 = await AbstractPathHelpers.EncryptNameForDiscoveryAsync(item.Name, ciphertextParent, specifics, cancellationToken); return await ciphertextParent.TryGetFirstByNameAsync(ciphertextName2, cancellationToken) as TStorable; } } diff --git a/src/Core/SecureFolderFS.Core/Models/SecurityWrapper.cs b/src/Core/SecureFolderFS.Core/Models/SecurityWrapper.cs index f5de7c008..51d6fe4c7 100644 --- a/src/Core/SecureFolderFS.Core/Models/SecurityWrapper.cs +++ b/src/Core/SecureFolderFS.Core/Models/SecurityWrapper.cs @@ -31,6 +31,7 @@ public SecurityWrapper(KeyPair keyPair, V4VaultConfigurationDataModel configData public IEnumerator> GetEnumerator() { yield return new(nameof(VirtualFileSystemOptions.RecycleBinSize), _configDataModel.RecycleBinSize); + yield return new (nameof(VirtualFileSystemOptions.ShorteningThreshold), _configDataModel.ShorteningThreshold); } /// From 97c339a97d5faf27a3558c112f1f5e2d300d7056 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Fri, 22 May 2026 19:53:27 +0200 Subject: [PATCH 04/14] Apply partial code review --- .../AbstractPathHelpers.Shortening.cs | 20 ++++------ .../Storage/CryptoFolder.cs | 6 +-- .../RecycleBinService.cs | 38 +++++-------------- 3 files changed, 21 insertions(+), 43 deletions(-) diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Shortening.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Shortening.cs index e8dce558b..9c83d74eb 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Shortening.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Shortening.cs @@ -32,21 +32,17 @@ public static partial class AbstractPathHelpers /// /// The shortened ciphertext name of the item. /// The parent folder where the item is found. - /// The instance associated with the item. /// A that cancels this action. /// A that represents the asynchronous operation. - public static async Task DeleteSidecarFileAsync(string ciphertextItemName, IModifiableFolder ciphertextParent, FileSystemSpecifics specifics, CancellationToken cancellationToken = default) + public static async Task DeleteSidecarFileAsync(string ciphertextItemName, IModifiableFolder ciphertextParent, CancellationToken cancellationToken = default) { - if (specifics.Options.ShorteningThreshold > 0 && ciphertextItemName.Length >= specifics.Options.ShorteningThreshold) - { - var oldSidecarName = TryGetSidecarName(ciphertextItemName); - if (oldSidecarName is null) - return; + var oldSidecarName = TryGetSidecarName(ciphertextItemName); + if (oldSidecarName is null) + return; - var oldSidecar = await ciphertextParent.TryGetFileByNameAsync(oldSidecarName, cancellationToken); - if (oldSidecar is not null) - await ciphertextParent.DeleteAsync(oldSidecar, cancellationToken); - } + var oldSidecar = await ciphertextParent.TryGetFileByNameAsync(oldSidecarName, cancellationToken); + if (oldSidecar is not null) + await ciphertextParent.DeleteAsync(oldSidecar, cancellationToken); } /// @@ -70,7 +66,7 @@ public static bool IsSidecarName(string name) /// public static ReadOnlySpan RemoveShortenedExtension(string shortenedName) { - return shortenedName.EndsWith(Constants.Names.SHORTENED_FILE_EXTENSION, StringComparison.Ordinal) + return shortenedName.EndsWith(Constants.Names.SHORTENED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase) ? shortenedName.AsSpan(0, shortenedName.Length - Constants.Names.SHORTENED_FILE_EXTENSION.Length) : shortenedName.AsSpan(); } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs index 231af8ff1..d712f0878 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Storage/CryptoFolder.cs @@ -59,7 +59,7 @@ public virtual async Task RenameAsync(IStorableChild storable, s var renamedCiphertextItem = await renamableFolder.RenameAsync(ciphertextItem, newCiphertextName, cancellationToken); // Clean up old sidecar if the old name was shortened - await AbstractPathHelpers.DeleteSidecarFileAsync(ciphertextName, renamableFolder, specifics, cancellationToken); + await AbstractPathHelpers.DeleteSidecarFileAsync(ciphertextName, renamableFolder, cancellationToken); var plaintextId = Path.Combine(Inner.Id, newName); return renamedCiphertextItem switch @@ -141,7 +141,7 @@ public virtual async Task DeleteAsync(IStorableChild item, long sizeHint = -1L, } // Clean up old sidecar if the old name was shortened - await AbstractPathHelpers.DeleteSidecarFileAsync(ciphertextName, modifiableFolder, specifics, cancellationToken); + await AbstractPathHelpers.DeleteSidecarFileAsync(ciphertextName, modifiableFolder, cancellationToken); // Remove deleted directory from cache if (ciphertextItem is IFolder) @@ -262,7 +262,7 @@ public virtual async Task MoveFromAsync(IChildFile fileToMove, IModi var movedCiphertextFile = await moveRenamedFrom.MoveFromAsync(ciphertextFileToMove, ciphertextSourceModifiableFolder, overwrite, newCiphertextName, cancellationToken, fallback); // Clean up old sidecar if the old name was shortened - await AbstractPathHelpers.DeleteSidecarFileAsync(existingCiphertextName, ciphertextSourceModifiableFolder, specifics, cancellationToken); + await AbstractPathHelpers.DeleteSidecarFileAsync(existingCiphertextName, ciphertextSourceModifiableFolder, cancellationToken); return (IChildFile)Wrap(movedCiphertextFile, newName); } diff --git a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/RecycleBinService.cs b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/RecycleBinService.cs index c5a0d2d9e..895fd10d2 100644 --- a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/RecycleBinService.cs +++ b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/RecycleBinService.cs @@ -34,38 +34,20 @@ public async Task ConfigureRecycleBinAsync(UnlockedVaultViewModel unlockedViewMo // Read configuration var configDataModel = await vaultReader.ReadConfigurationAsync(cancellationToken); - if (configDataModel.AuthenticationMethod.Contains(Core.Constants.Vault.Authentication.AUTH_APP_PLATFORM, StringComparison.Ordinal)) + var newConfigDataModel = configDataModel with { - var v4ConfigDataModel = await vaultReader.ReadConfigurationAsync(cancellationToken); - var newV4ConfigDataModel = v4ConfigDataModel with - { - RecycleBinSize = maxSize, - PayloadMac = new byte[HMACSHA256.HashSizeInBytes] - }; + RecycleBinSize = maxSize, + PayloadMac = new byte[HMACSHA256.HashSizeInBytes] + }; - specifics.Security.KeyPair.MacKey.UseKey(macKey => - { - VaultParser.V4CalculateConfigMac(newV4ConfigDataModel, macKey, newV4ConfigDataModel.PayloadMac); - }); - - await vaultWriter.WriteV4ConfigurationAsync(newV4ConfigDataModel, cancellationToken); - } - else + // First, we need to fill in the PayloadMac of the content + specifics.Security.KeyPair.MacKey.UseKey(macKey => { - var newConfigDataModel = configDataModel with - { - RecycleBinSize = maxSize, - PayloadMac = new byte[HMACSHA256.HashSizeInBytes] - }; + VaultParser.V4CalculateConfigMac(newConfigDataModel, macKey, newConfigDataModel.PayloadMac); + }); - // First, we need to fill in the PayloadMac of the content - specifics.Security.KeyPair.MacKey.UseKey(macKey => - { - VaultParser.V4CalculateConfigMac(newConfigDataModel, macKey, newConfigDataModel.PayloadMac); - }); - - await vaultWriter.WriteConfigurationAsync(newConfigDataModel, cancellationToken); - } + // Then, write the config + await vaultWriter.WriteConfigurationAsync(newConfigDataModel, cancellationToken); // Make sure to also update the file system options specifics.Options.DangerousSetRecycleBin(maxSize); From 240e023e69f19df1fa114e432df29b63e102c6cc Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Mon, 25 May 2026 14:46:35 +0200 Subject: [PATCH 05/14] Fix Recycle Bin restore with sidecar names --- .../AbstractRecycleBinHelpers.Operational.cs | 41 +++++++------------ 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs index 16d491990..a67d70310 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Abstract/AbstractRecycleBinHelpers.Operational.cs @@ -7,7 +7,6 @@ using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Extensions; -using SecureFolderFS.Shared.Helpers; using SecureFolderFS.Storage.Extensions; using SecureFolderFS.Storage.VirtualFileSystem; @@ -73,31 +72,17 @@ public static async Task RestoreAsync(IStorableChild recycleBinItem, IModifiable if (plaintextOriginalName is null || plaintextParentPath is null) throw new FormatException("Could not decrypt recycle bin configuration file."); - var ciphertextParentFolder = await SafetyHelpers.NoFailureAsync(async () => await AbstractPathHelpers.GetCiphertextItemAsync(plaintextParentPath, specifics, cancellationToken) as IFolder); - if (string.IsNullOrEmpty(ciphertextParentFolder?.Id) || !ciphertextDestinationFolder.Id.EndsWith(ciphertextParentFolder.Id)) - { - // Destination folder is different from the original destination - // A new item name should be chosen fit for the new folder (so that Directory ID match) - var ciphertextName = await AbstractPathHelpers.EncryptNameForDiscoveryAsync(plaintextOriginalName, ciphertextDestinationFolder, specifics, cancellationToken); - - // Get an available name if the destination already exists - ciphertextName = await GetAvailableDestinationNameAsync(ciphertextDestinationFolder, ciphertextName, plaintextOriginalName, specifics, cancellationToken); + // Encrypt the name for discovery (no sidecar side-effects) + var ciphertextName = await AbstractPathHelpers.EncryptNameForDiscoveryAsync(plaintextOriginalName, ciphertextDestinationFolder, specifics, cancellationToken); - // Rename and move item to destination - _ = await ciphertextDestinationFolder.MoveStorableFromAsync(recycleBinItem, modifiableRecycleBin, false, ciphertextName, null, cancellationToken); - } - else - { - // Destination folder is the same as the original destination - // The same name could be used since the Directory IDs match - var ciphertextName = await AbstractPathHelpers.EncryptNameForDiscoveryAsync(plaintextOriginalName, ciphertextDestinationFolder, specifics, cancellationToken); + // Get an available name if the destination already has an item with that name + var (_, finalPlaintextName) = await GetAvailableDestinationNameAsync(ciphertextDestinationFolder, ciphertextName, plaintextOriginalName, specifics, cancellationToken); - // Get an available name if the destination already exists - ciphertextName = await GetAvailableDestinationNameAsync(ciphertextDestinationFolder, ciphertextName, plaintextOriginalName, specifics, cancellationToken); + // Materialize the name: writes the sidecar file if the name is shortened + ciphertextName = await AbstractPathHelpers.EncryptNameForUseAsync(finalPlaintextName, ciphertextDestinationFolder, specifics, cancellationToken); - // Rename and move item to destination - _ = await ciphertextDestinationFolder.MoveStorableFromAsync(recycleBinItem, modifiableRecycleBin, false, ciphertextName, null, cancellationToken); - } + // Rename and move item to destination + _ = await ciphertextDestinationFolder.MoveStorableFromAsync(recycleBinItem, modifiableRecycleBin, false, ciphertextName, null, cancellationToken); // Delete the old configuration file var configurationFile = await recycleBin.GetFileByNameAsync($"{recycleBinItem.Name}.json", cancellationToken); @@ -231,8 +216,10 @@ static async Task DeleteImmediatelyAsync(IModifiableFolder ciphertextSourceFolde } } - private static async Task GetAvailableDestinationNameAsync(IFolder ciphertextDestinationFolder, string ciphertextName, string plaintextOriginalName, FileSystemSpecifics specifics, CancellationToken cancellationToken) + private static async Task<(string CiphertextName, string PlaintextName)> GetAvailableDestinationNameAsync(IFolder ciphertextDestinationFolder, string ciphertextName, string plaintextOriginalName, FileSystemSpecifics specifics, CancellationToken cancellationToken) { + var finalPlaintextName = plaintextOriginalName; + // Check if the item already exists var existing = await ciphertextDestinationFolder.TryGetFirstByNameAsync(ciphertextName, cancellationToken); if (existing is not null) @@ -243,14 +230,14 @@ private static async Task GetAvailableDestinationNameAsync(IFolder ciphe var suffix = 1; do { - var newPlaintextName = $"{nameWithoutExtension} ({suffix}){extension}"; - ciphertextName = await AbstractPathHelpers.EncryptNameForDiscoveryAsync(newPlaintextName, ciphertextDestinationFolder, specifics, cancellationToken); + finalPlaintextName = $"{nameWithoutExtension} ({suffix}){extension}"; + ciphertextName = await AbstractPathHelpers.EncryptNameForDiscoveryAsync(finalPlaintextName, ciphertextDestinationFolder, specifics, cancellationToken); existing = await ciphertextDestinationFolder.TryGetFirstByNameAsync(ciphertextName, cancellationToken); suffix++; } while (existing is not null); } - return ciphertextName; + return (ciphertextName, finalPlaintextName); } private static async Task IsRecentlyCreatedAsync(IStorable storable, CancellationToken cancellationToken) From 071f0c6838fd8024e7e22e803f68c872069a061e Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Mon, 25 May 2026 15:03:46 +0200 Subject: [PATCH 06/14] Fixed threshold detection in RestoreRoutine --- .../Routines/Operational/RestoreRoutine.cs | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/RestoreRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/RestoreRoutine.cs index 2d7d4c847..2384a44f0 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/RestoreRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/RestoreRoutine.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Security.Cryptography; +using System.Text; using System.Threading; using System.Threading.Tasks; using OwlCore.Storage; @@ -69,7 +70,8 @@ public async Task FinalizeAsync(CancellationToken cancellationToken string? foundNameCrypt = null; string? foundEncoding = null; var noExtensions = 0; - var shorteningThreshold = 0; + var minSidecarContentLength = int.MaxValue; + var hasShortenedNames = false; var folderScanner = new DeepFolderScanner(contentFolder, StorableType.File); await foreach (var item in folderScanner.ScanFolderAsync(cancellationToken)) @@ -77,8 +79,28 @@ public async Task FinalizeAsync(CancellationToken cancellationToken if (item is not IFile file) continue; - if (shorteningThreshold == 0 && item.Name.EndsWith(FileSystem.Constants.Names.SHORTENED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) - shorteningThreshold = 220; // Arbitrary threshold typical for shortened names + // Read sidecar files to determine the shortening threshold from the actual ciphertext name length + if (item.Name.EndsWith(FileSystem.Constants.Names.SIDECAR_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) + { + try + { + await using var sidecarStream = await file.OpenReadAsync(cancellationToken); + var buffer = new byte[4097]; + var bytesRead = await sidecarStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); + if (bytesRead is > 0 and <= 4096) + minSidecarContentLength = Math.Min(minSidecarContentLength, Encoding.UTF8.GetString(buffer, 0, bytesRead).Length); + } + catch { } + + continue; + } + + // Shortened files are hashes that can't be decrypted directly + if (item.Name.EndsWith(FileSystem.Constants.Names.SHORTENED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) + { + hasShortenedNames = true; + continue; + } if (!item.Name.EndsWith(FileSystem.Constants.Names.ENCRYPTED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) noExtensions++; @@ -102,6 +124,11 @@ public async Task FinalizeAsync(CancellationToken cancellationToken if (foundNameCrypt is null || foundEncoding is null || foundContentCrypt is null) throw new InvalidOperationException("Could not find all required cryptographic components."); + // Determine shortening threshold from sidecar content, with fallback for missing sidecars + var shorteningThreshold = minSidecarContentLength < int.MaxValue + ? minSidecarContentLength + : hasShortenedNames ? 220 : 0; + // Regenerate config var configDataModel = new V4VaultConfigurationDataModel() { From 94e810f071186a44872a74aa06146997996b08bf Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Mon, 25 May 2026 17:59:53 +0200 Subject: [PATCH 07/14] Detect orphan sidecar files in vault health --- .../Exceptions/OrphanSidecarException.cs | 15 ++++ .../Helpers/Health/HealthHelpers.Directory.cs | 15 ++-- .../Helpers/Health/HealthHelpers.Name.cs | 51 ++++++------- .../Helpers/Health/HealthHelpers.Sidecar.cs | 76 +++++++++++++++++++ .../Abstract/AbstractPathHelpers.Names.cs | 25 ++++++ .../Validators/StructureContentsValidator.cs | 5 ++ .../Validators/StructureValidator.cs | 5 ++ .../VaultHealthService.cs | 9 ++- .../Strings/en-US/Resources.resx | 6 ++ .../HealthOrphanSidecarIssueViewModel.cs | 19 +++++ 10 files changed, 194 insertions(+), 32 deletions(-) create mode 100644 src/Core/SecureFolderFS.Core.FileSystem/Exceptions/OrphanSidecarException.cs create mode 100644 src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Sidecar.cs create mode 100644 src/Platforms/SecureFolderFS.UI/ViewModels/Health/HealthOrphanSidecarIssueViewModel.cs diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Exceptions/OrphanSidecarException.cs b/src/Core/SecureFolderFS.Core.FileSystem/Exceptions/OrphanSidecarException.cs new file mode 100644 index 000000000..3f14c4f27 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.FileSystem/Exceptions/OrphanSidecarException.cs @@ -0,0 +1,15 @@ +using System; + +namespace SecureFolderFS.Core.FileSystem.Exceptions +{ + /// + /// Exception thrown when a sidecar file (.sffsi) exists without a matching shortened file (.sffsn). + /// + public sealed class OrphanSidecarException : Exception + { + public OrphanSidecarException(string sidecarName) + : base($"Orphan sidecar file has no matching shortened file: {sidecarName}") + { + } + } +} diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Directory.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Directory.cs index 492023908..64e9695b6 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Directory.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Directory.cs @@ -3,7 +3,6 @@ using System.Threading; using System.Threading.Tasks; using OwlCore.Storage; -using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Core.FileSystem.Helpers.Paths; using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract; using SecureFolderFS.Shared.ComponentModel; @@ -15,10 +14,10 @@ namespace SecureFolderFS.Core.FileSystem.Helpers.Health { public static partial class HealthHelpers { - public static async Task RepairDirectoryAsync(IFolder affected, Security security, CancellationToken cancellationToken) + public static async Task RepairDirectoryAsync(IFolder affected, FileSystemSpecifics specifics, CancellationToken cancellationToken) { // Return success if no encryption is used - if (security.NameCrypt is null) + if (specifics.Security.NameCrypt is null) return Result.Success; if (affected is not IRenamableFolder renamableFolder) @@ -39,9 +38,15 @@ public static async Task RepairDirectoryAsync(IFolder affected, Securit if (PathHelpers.IsCoreName(item.Name)) continue; - // Encrypt a new name and rename - var encryptedName = AbstractPathHelpers.EncryptNewName(item.Name, directoryId, security); + // Remember old name for sidecar cleanup + var oldName = item.Name; + + // Encrypt a new name (writes sidecar if shortening applies) and rename + var encryptedName = await AbstractPathHelpers.EncryptNewNameForUseAsync(item.Name, directoryId, affected, specifics, cancellationToken); _ = await renamableFolder.RenameAsync(item, encryptedName, cancellationToken); + + // Clean up old sidecar if the previous name was shortened + await AbstractPathHelpers.DeleteSidecarFileAsync(oldName, renamableFolder, cancellationToken); } return Result.Success; diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Name.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Name.cs index a8fbcf787..114ab7e7a 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Name.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Name.cs @@ -2,7 +2,6 @@ using System.Threading; using System.Threading.Tasks; using OwlCore.Storage; -using SecureFolderFS.Core.Cryptography; using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Helpers; @@ -14,48 +13,48 @@ namespace SecureFolderFS.Core.FileSystem.Helpers.Health public static partial class HealthHelpers { public static async Task RepairNameAsync(IStorableChild affected, FileSystemSpecifics specifics, string newName, CancellationToken cancellationToken) - { - var repairResult = await RepairNameAsync(affected, specifics.Security, specifics.ContentFolder, newName, cancellationToken); - if (!repairResult.Successful || !specifics.CiphertextFileNameCache.IsAvailable) - return repairResult; - - // Update cache - await SafetyHelpers.NoFailureAsync(async () => - { - var parent = await affected.GetParentAsync(cancellationToken); - if (parent is null) - return; - - var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security); - var isAllocated = await AbstractPathHelpers.GetDirectoryIdAsync(parent, specifics, directoryId, cancellationToken); - specifics.CiphertextFileNameCache.CacheRemove(new(isAllocated ? directoryId : [], affected.Name)); - }); - - return repairResult; - } - - private static async Task RepairNameAsync(IStorableChild affected, Security security, IFolder contentFolder, string newName, CancellationToken cancellationToken) { // Return success if no encryption is used - if (security.NameCrypt is null) + if (specifics.Security.NameCrypt is null) return Result.Success; try { - // Get Directory ID + // Get parent folder var parentFolder = await affected.GetParentAsync(cancellationToken); if (parentFolder is not IRenamableFolder renamableFolder) return Result.Failure(FolderNotRenamable); - // Encrypt new name and rename - var encryptedName = await AbstractPathHelpers.EncryptNameAsync(newName, parentFolder, contentFolder, security, cancellationToken); + // Remember old name for sidecar cleanup + var oldName = affected.Name; + + // Encrypt new name (writes sidecar if shortening applies) and rename + var encryptedName = await AbstractPathHelpers.EncryptNameForUseAsync(newName, parentFolder, specifics, cancellationToken); _ = await renamableFolder.RenameAsync(affected, encryptedName, cancellationToken); + + // Clean up old sidecar if the previous name was shortened + await AbstractPathHelpers.DeleteSidecarFileAsync(oldName, renamableFolder, cancellationToken); } catch (Exception ex) { return Result.Failure(ex); } + // Update cache + if (specifics.CiphertextFileNameCache.IsAvailable) + { + await SafetyHelpers.NoFailureAsync(async () => + { + var parent = await affected.GetParentAsync(cancellationToken); + if (parent is null) + return; + + var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security); + var isAllocated = await AbstractPathHelpers.GetDirectoryIdAsync(parent, specifics, directoryId, cancellationToken); + specifics.CiphertextFileNameCache.CacheRemove(new(isAllocated ? directoryId : [], affected.Name)); + }); + } + return Result.Success; } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Sidecar.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Sidecar.cs new file mode 100644 index 000000000..45975f023 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Sidecar.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using OwlCore.Storage; +using SecureFolderFS.Core.FileSystem.Exceptions; +using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract; +using SecureFolderFS.Shared.ComponentModel; +using SecureFolderFS.Shared.Models; + +namespace SecureFolderFS.Core.FileSystem.Helpers.Health +{ + public static partial class HealthHelpers + { + /// + /// Detects orphan sidecar files in the specified folder and reports them as issues. + /// An orphan sidecar is a .sffsi file with no matching .sffsn companion. + /// + /// The folder to scan for orphan sidecars. + /// The progress reporter for reporting detected issues. + /// A that cancels this action. + /// A that represents the asynchronous operation. + public static async Task DetectOrphanSidecarsAsync(IFolder folder, IProgress? reporter, CancellationToken cancellationToken = default) + { + if (reporter is null) + return; + + var shortenedNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var sidecars = new List(); + + await foreach (var item in folder.GetItemsAsync(StorableType.File, cancellationToken)) + { + if (AbstractPathHelpers.IsSidecarName(item.Name)) + sidecars.Add(item); + else if (item.Name.EndsWith(Constants.Names.SHORTENED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) + shortenedNames.Add(item.Name); + } + + foreach (var sidecar in sidecars) + { + // Derive the expected companion: replace .sffsi → .sffsn + var baseName = sidecar.Name[..^Constants.Names.SIDECAR_FILE_EXTENSION.Length]; + var expectedCompanion = baseName + Constants.Names.SHORTENED_FILE_EXTENSION; + + if (!shortenedNames.Contains(expectedCompanion)) + reporter.Report(Result.Failure(sidecar, new OrphanSidecarException(sidecar.Name))); + } + } + + /// + /// Deletes an orphan sidecar file. + /// + /// The orphan sidecar file to delete. + /// A that cancels this action. + /// A that represents the asynchronous operation. + public static async Task DeleteOrphanSidecarAsync(IStorableChild sidecar, CancellationToken cancellationToken = default) + { + try + { + if (sidecar is not IChildFile childFile) + return Result.Failure(new InvalidOperationException("Sidecar is not a child file.")); + + var parent = await childFile.GetParentAsync(cancellationToken); + if (parent is not IModifiableFolder modifiableFolder) + return Result.Failure(new InvalidOperationException("Parent folder does not support deletion.")); + + await modifiableFolder.DeleteAsync(childFile, cancellationToken); + return Result.Success; + } + catch (Exception ex) + { + return Result.Failure(ex); + } + } + } +} diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Names.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Names.cs index 1bb98b54d..c501ffe95 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Names.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Names.cs @@ -168,6 +168,31 @@ public static string EncryptNewName(string plaintextName, byte[] newDirectoryId, return security.NameCrypt.EncryptName(plaintextName, newDirectoryId) + Constants.Names.ENCRYPTED_FILE_EXTENSION; } + /// + /// Encrypts a plaintext name using the specified Directory ID and materializes it, writing a sidecar file if shortening applies. + /// + /// The original plaintext name to encrypt. + /// A new Directory ID used for encryption. + /// The ciphertext parent folder where the sidecar will be written if needed. + /// The instance associated with the item. + /// A that cancels this action. + /// A that represents the asynchronous operation. Value is an encrypted name with the appropriate file extension appended. + public static async Task EncryptNewNameForUseAsync(string plaintextName, byte[] newDirectoryId, IFolder ciphertextParentFolder, FileSystemSpecifics specifics, CancellationToken cancellationToken = default) + { + if (specifics.Security.NameCrypt is null) + return plaintextName; + + var encryptedName = specifics.Security.NameCrypt.EncryptName(plaintextName, newDirectoryId) + Constants.Names.ENCRYPTED_FILE_EXTENSION; + if (specifics.Options.ShorteningThreshold > 0 && encryptedName.Length >= specifics.Options.ShorteningThreshold) + { + var shortenedBase = ComputeShortenedNameBase(encryptedName); + await WriteSidecarAsync(ciphertextParentFolder, shortenedBase, encryptedName, cancellationToken); + return shortenedBase + Constants.Names.SHORTENED_FILE_EXTENSION; + } + + return encryptedName; + } + /// public static async Task DecryptNameAsync(string ciphertextName, IFolder ciphertextParentFolder, FileSystemSpecifics specifics, CancellationToken cancellationToken = default) diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureContentsValidator.cs b/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureContentsValidator.cs index d619238eb..259986263 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureContentsValidator.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureContentsValidator.cs @@ -2,6 +2,7 @@ using System.Threading; using System.Threading.Tasks; using OwlCore.Storage; +using SecureFolderFS.Core.FileSystem.Helpers.Health; using SecureFolderFS.Core.FileSystem.Helpers.Paths; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Models; @@ -36,6 +37,10 @@ public override async Task ValidateResultAsync((IFolder, IProgress 0) + await HealthHelpers.DetectOrphanSidecarsAsync(scannedFolder, reporter, cancellationToken).ConfigureAwait(false); + await foreach (var item in scannedFolder.GetItemsAsync(StorableType.All, cancellationToken).ConfigureAwait(false)) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureValidator.cs b/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureValidator.cs index 4bce08c7b..f8ac87861 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureValidator.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureValidator.cs @@ -2,6 +2,7 @@ using System.Threading; using System.Threading.Tasks; using OwlCore.Storage; +using SecureFolderFS.Core.FileSystem.Helpers.Health; using SecureFolderFS.Core.FileSystem.Helpers.Paths; using SecureFolderFS.Shared.ComponentModel; using SecureFolderFS.Shared.Models; @@ -37,6 +38,10 @@ public override async Task ValidateResultAsync((IFolder, IProgress 0) + await HealthHelpers.DetectOrphanSidecarsAsync(scannedFolder, reporter, cancellationToken).ConfigureAwait(false); + await foreach (var item in scannedFolder.GetItemsAsync(StorableType.All, cancellationToken).ConfigureAwait(false)) { if (PathHelpers.IsCoreName(item.Name)) diff --git a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultHealthService.cs b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultHealthService.cs index 944685fe4..2bf804812 100644 --- a/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultHealthService.cs +++ b/src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultHealthService.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using OwlCore.Storage; using SecureFolderFS.Core.FileSystem; +using SecureFolderFS.Core.FileSystem.Exceptions; using SecureFolderFS.Core.FileSystem.Helpers.Health; using SecureFolderFS.Core.FileSystem.Validators; using SecureFolderFS.Sdk.Extensions; @@ -40,6 +41,7 @@ public class VaultHealthService : IVaultHealthService _ => GetDefault(aggregate) }, FormatException => new HealthNameIssueViewModel(storable, result, "InvalidItemName".ToLocalized()) { ErrorMessage = "GenerateNewName".ToLocalized() }, + OrphanSidecarException => new HealthOrphanSidecarIssueViewModel(storable, result, "OrphanSidecar".ToLocalized()) { ErrorMessage = "DeleteOrphanSidecar".ToLocalized() }, FileHeaderCorruptedException => new HealthFileDataIssueViewModel(storable, result, "IrrecoverableFile".ToLocalized(), isRecoverable: false) { ErrorMessage = "FileHeaderCorrupted".ToLocalized() }, FileChunksCorruptedException chunksEx => new HealthFileDataIssueViewModel(storable, result, "CorruptedFileChunks".ToLocalized(), chunksEx.CorruptedChunks, isRecoverable: true) { ErrorMessage = "FileHasCorruptedChunks".ToLocalized(chunksEx.CorruptedChunks.Count) }, CryptographicException => new HealthFileDataIssueViewModel(storable, result, "InvalidFileContents".ToLocalized()) { ErrorMessage = "RegenerateFileContents".ToLocalized() }, @@ -90,7 +92,7 @@ 1. Corrupted file contents (HealthFileDataIssueViewModel) // Directory ID issue HealthDirectoryIssueViewModel directoryIssue => await HealthHelpers.RepairDirectoryAsync( directoryIssue.Folder ?? throw new ArgumentNullException(nameof(HealthDirectoryIssueViewModel.Folder)), - specificsWrapper.Inner.Security, + specificsWrapper.Inner, cancellationToken), // File data issue - repair chunks or delete irrecoverable file @@ -105,6 +107,11 @@ 1. Corrupted file contents (HealthFileDataIssueViewModel) dataIssue.File, cancellationToken), + // Orphan sidecar - delete it + HealthOrphanSidecarIssueViewModel => await HealthHelpers.DeleteOrphanSidecarAsync( + item.Inner, + cancellationToken), + // Default _ => null }; diff --git a/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx index 77a37a08a..119aba2e4 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx @@ -1457,4 +1457,10 @@ Login method + + Unlinked shortening file + + + The name shortening file has no data file and will be deleted. + diff --git a/src/Platforms/SecureFolderFS.UI/ViewModels/Health/HealthOrphanSidecarIssueViewModel.cs b/src/Platforms/SecureFolderFS.UI/ViewModels/Health/HealthOrphanSidecarIssueViewModel.cs new file mode 100644 index 000000000..c93bf283c --- /dev/null +++ b/src/Platforms/SecureFolderFS.UI/ViewModels/Health/HealthOrphanSidecarIssueViewModel.cs @@ -0,0 +1,19 @@ +using System.ComponentModel; +using OwlCore.Storage; +using SecureFolderFS.Sdk.Enums; +using SecureFolderFS.Sdk.ViewModels.Controls.Widgets.Health; +using SecureFolderFS.Shared.ComponentModel; + +namespace SecureFolderFS.UI.ViewModels.Health +{ + /// + [Bindable(true)] + public sealed class HealthOrphanSidecarIssueViewModel : HealthIssueViewModel + { + public HealthOrphanSidecarIssueViewModel(IStorableChild storable, IResult result, string? title = null) + : base(storable, result, title) + { + Severity = Severity.Warning; + } + } +} From f8a398e3a12f45a5d3117ef0577f5bd46d55ef40 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Tue, 26 May 2026 00:42:10 +0200 Subject: [PATCH 08/14] Apply code review --- .../Helpers/Health/HealthHelpers.Sidecar.cs | 2 +- .../Validators/BaseFileSystemValidator.cs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Sidecar.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Sidecar.cs index 45975f023..699f76b1d 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Sidecar.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Sidecar.cs @@ -28,7 +28,7 @@ public static async Task DetectOrphanSidecarsAsync(IFolder folder, IProgress(StringComparer.OrdinalIgnoreCase); var sidecars = new List(); - await foreach (var item in folder.GetItemsAsync(StorableType.File, cancellationToken)) + await foreach (var item in folder.GetItemsAsync(StorableType.All, cancellationToken)) { if (AbstractPathHelpers.IsSidecarName(item.Name)) sidecars.Add(item); diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Validators/BaseFileSystemValidator.cs b/src/Core/SecureFolderFS.Core.FileSystem/Validators/BaseFileSystemValidator.cs index c035cd07b..3e3e9b2a1 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Validators/BaseFileSystemValidator.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Validators/BaseFileSystemValidator.cs @@ -60,6 +60,11 @@ protected async Task ValidateNameResultAsync(IStorableChild storable, C if (!string.IsNullOrEmpty(decryptedName)) return decryptedName; + // A shortened file (.sffsn) that couldn't be decrypted means its sidecar is missing. + // Report this as an invalid name so the health system can offer to generate a new one. + if (storable.Name.EndsWith(Constants.Names.SHORTENED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) + return null; + // We want to suppress failures that might be raised when the Directory ID file is not found. // This case should be already handled in the folder validator From c28b0453da4fe657ba3ee0a3a8ad0d26a77344ba Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Tue, 26 May 2026 02:20:38 +0200 Subject: [PATCH 09/14] Implemented sidecar logic for Dokany and WinFsp --- .../Callbacks/OnDeviceDokany.cs | 29 +++- ...Sidecar.cs => HealthHelpers.Shortening.cs} | 2 +- .../AbstractPathHelpers.Shortening.cs | 4 +- .../Native/NativePathHelpers.Directory.cs | 7 + .../Paths/Native/NativePathHelpers.Names.cs | 152 +++++++++++++++++- .../Paths/Native/NativePathHelpers.Paths.cs | 52 +++++- .../Native/NativePathHelpers.Shortening.cs | 65 ++++++++ .../Helpers/Paths/Native/NativePathHelpers.cs | 6 + .../NativeRecycleBinHelpers.Operational.cs | 9 +- .../Callbacks/OnDeviceWinFsp.Helpers.cs | 6 + .../Callbacks/OnDeviceWinFsp.cs | 20 ++- 11 files changed, 334 insertions(+), 18 deletions(-) rename src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/{HealthHelpers.Sidecar.cs => HealthHelpers.Shortening.cs} (97%) create mode 100644 src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Shortening.cs diff --git a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs index 4d1308fc1..63a37dcbe 100644 --- a/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs +++ b/src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs @@ -90,6 +90,9 @@ public override NtStatus CreateFile(string fileName, FileAccess access, FileShar { } + // Materialize sidecar for the new directory name if shortened + ciphertextPath = GetCiphertextPathForUse(fileName) ?? ciphertextPath; + // Create directory _ = Directory.CreateDirectory(ciphertextPath); @@ -172,6 +175,10 @@ public override NtStatus CreateFile(string fileName, FileAccess access, FileShar if (specifics.Options.IsReadOnly && mode.IsWriteFlag()) throw FileSystemExceptions.FileSystemReadOnly; + // Materialize sidecar for the new file name if shortened + if (mode is FileMode.CreateNew or FileMode.Create or FileMode.OpenOrCreate) + ciphertextPath = GetCiphertextPathForUse(fileName) ?? ciphertextPath; + var openAccess = readAccess ? System.IO.FileAccess.Read : System.IO.FileAccess.ReadWrite; if (mode == FileMode.CreateNew && readAccess) openAccess = System.IO.FileAccess.ReadWrite; @@ -252,6 +259,11 @@ public override void Cleanup(string fileName, IDokanFileInfo info) { NativeRecycleBinHelpers.DeleteOrRecycle(ciphertextPath, specifics, StorableType.File); } + + // Clean up sidecar after successful delete/recycle + NativePathHelpers.DeleteSidecarFile( + Path.GetFileName(ciphertextPath), + Path.GetDirectoryName(ciphertextPath) ?? string.Empty); } catch (UnauthorizedAccessException) { @@ -537,7 +549,7 @@ public override NtStatus DeleteDirectory(string fileName, IDokanFileInfo info) public override NtStatus MoveFile(string oldName, string newName, bool replace, IDokanFileInfo info) { var oldCiphertextPath = GetCiphertextPath(oldName); - var newCiphertextPath = GetCiphertextPath(newName); + var newCiphertextPath = GetCiphertextPathForUse(newName); var fileNameCombined = $"{oldName} -> {newName}"; if (oldCiphertextPath is null || newCiphertextPath is null) @@ -564,6 +576,11 @@ public override NtStatus MoveFile(string oldName, string newName, bool replace, File.Move(oldCiphertextPath, newCiphertextPath); } + // Clean up old sidecar after successful move + NativePathHelpers.DeleteSidecarFile( + Path.GetFileName(oldCiphertextPath), + Path.GetDirectoryName(oldCiphertextPath) ?? string.Empty); + return Trace(DokanResult.Success, fileNameCombined, info); } else if (replace) @@ -579,6 +596,11 @@ public override NtStatus MoveFile(string oldName, string newName, bool replace, File.Delete(newCiphertextPath); File.Move(oldCiphertextPath, newCiphertextPath); + // Clean up old sidecar after successful move + NativePathHelpers.DeleteSidecarFile( + Path.GetFileName(oldCiphertextPath), + Path.GetDirectoryName(oldCiphertextPath) ?? string.Empty); + return Trace(DokanResult.Success, fileNameCombined, info); } else @@ -824,5 +846,10 @@ public override NtStatus FindStreams(string fileName, out IList { return NativePathHelpers.GetCiphertextPath(plaintextName, specifics); } + + private string? GetCiphertextPathForUse(string plaintextName) + { + return NativePathHelpers.GetCiphertextPathForUse(plaintextName, specifics); + } } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Sidecar.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Shortening.cs similarity index 97% rename from src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Sidecar.cs rename to src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Shortening.cs index 699f76b1d..6768bde94 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Sidecar.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Shortening.cs @@ -38,7 +38,7 @@ public static async Task DetectOrphanSidecarsAsync(IFolder folder, IProgress /// Tries to generate the name of the sidecar file associated with the given disk name. @@ -79,7 +79,7 @@ public static ReadOnlySpan RemoveShortenedExtension(string shortenedName) /// The full ciphertext name to compute the base for. /// A deterministic, filesystem-safe name base (no extension) for . [SkipLocalsInit] - private static string ComputeShortenedNameBase(string ciphertextName) + internal static string ComputeShortenedNameBase(string ciphertextName) { var nameBytes = Encoding.UTF8.GetBytes(ciphertextName); Span hash = stackalloc byte[32]; 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..2b7f3e420 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 @@ -5,6 +5,13 @@ namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Native { public static partial class NativePathHelpers { + /// + /// Gets the Directory ID for the specified ciphertext folder. + /// + /// The ciphertext folder path on disk. + /// The instance associated with the item. + /// A of size which will be filled with the Directory ID data. + /// True if a Directory ID was read; false if the folder is the root. public static bool GetDirectoryId(string ciphertextFolderPath, FileSystemSpecifics specifics, Span directoryId) { // Check if we're at the root diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Names.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Names.cs index 0deb34ff0..7d0f68427 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Names.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Names.cs @@ -5,46 +5,184 @@ namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Native { public static partial class NativePathHelpers { - public static string EncryptName(string plaintextName, string plaintextParentFolder, + #region Encrypt Name Non-Materialized + + /// + public static string EncryptName(string plaintextName, string ciphertextParentFolder, FileSystemSpecifics specifics) { if (specifics.Security.NameCrypt is null) return plaintextName; var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security, plaintextName); - return EncryptName(plaintextName, plaintextParentFolder, specifics, directoryId); + return EncryptName(plaintextName, ciphertextParentFolder, specifics, directoryId); } - public static string EncryptName(string plaintextName, string plaintextParentFolder, + /// + /// Encrypts the provided . + /// + /// The name to encrypt. + /// The ciphertext parent folder path. + /// The instance associated with the item. + /// A of size which will be used to hold the Directory ID data. + /// An encrypted name with the appropriate file extension appended. + public static string EncryptName(string plaintextName, string ciphertextParentFolder, FileSystemSpecifics specifics, Span expendableDirectoryId) { if (specifics.Security.NameCrypt is null) return plaintextName; - var result = GetDirectoryId(plaintextParentFolder, specifics, expendableDirectoryId); + var result = GetDirectoryId(ciphertextParentFolder, specifics, expendableDirectoryId); return specifics.Security.NameCrypt.EncryptName(plaintextName, result ? expendableDirectoryId : ReadOnlySpan.Empty) + Constants.Names.ENCRYPTED_FILE_EXTENSION; } + #endregion + + #region Encrypt Name Materialized + + /// + public static string EncryptNameForUse(string plaintextName, string ciphertextParentFolder, + FileSystemSpecifics specifics) + { + if (specifics.Security.NameCrypt is null) + return plaintextName; + + var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security, plaintextName); + return EncryptNameForUse(plaintextName, ciphertextParentFolder, specifics, directoryId); + } + + /// + /// Encrypts the provided and materializes it. + /// If the encrypted name exceeds the shortening threshold, a sidecar file is written and the shortened name is returned. + /// + /// The name to encrypt. + /// The ciphertext parent folder path. + /// The instance associated with the item. + /// A of size which will be used to hold the Directory ID data. + /// An encrypted name with the appropriate file extension appended. + public static string EncryptNameForUse(string plaintextName, string ciphertextParentFolder, + FileSystemSpecifics specifics, Span expendableDirectoryId) + { + if (specifics.Security.NameCrypt is null) + return plaintextName; + + var result = GetDirectoryId(ciphertextParentFolder, specifics, expendableDirectoryId); + var encryptedName = specifics.Security.NameCrypt.EncryptName(plaintextName, result ? expendableDirectoryId : ReadOnlySpan.Empty) + Constants.Names.ENCRYPTED_FILE_EXTENSION; + + if (specifics.Options.ShorteningThreshold > 0 && encryptedName.Length >= specifics.Options.ShorteningThreshold) + { + var shortenedBase = AbstractPathHelpers.ComputeShortenedNameBase(encryptedName); + WriteSidecar(ciphertextParentFolder, shortenedBase, encryptedName); + return shortenedBase + Constants.Names.SHORTENED_FILE_EXTENSION; + } + + return encryptedName; + } + + #endregion + + #region Encrypt Name Discoverability + + /// + public static string EncryptNameForDiscovery(string plaintextName, string ciphertextParentFolder, + FileSystemSpecifics specifics) + { + if (specifics.Security.NameCrypt is null) + return plaintextName; + + var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security, plaintextName); + return EncryptNameForDiscovery(plaintextName, ciphertextParentFolder, specifics, directoryId); + } + + /// + /// Encrypts the provided and discovers potential shortening branch. + /// Unlike , this method does not write a sidecar file. + /// + /// The name to encrypt. + /// The ciphertext parent folder path. + /// The instance associated with the item. + /// A of size which will be used to hold the Directory ID data. + /// An encrypted name with the appropriate file extension appended. + public static string EncryptNameForDiscovery(string plaintextName, string ciphertextParentFolder, + FileSystemSpecifics specifics, Span expendableDirectoryId) + { + if (specifics.Security.NameCrypt is null) + return plaintextName; + + var result = GetDirectoryId(ciphertextParentFolder, specifics, expendableDirectoryId); + var encryptedName = specifics.Security.NameCrypt.EncryptName(plaintextName, result ? expendableDirectoryId : ReadOnlySpan.Empty) + Constants.Names.ENCRYPTED_FILE_EXTENSION; + + if (specifics.Options.ShorteningThreshold > 0 && encryptedName.Length >= specifics.Options.ShorteningThreshold) + { + var shortenedBase = AbstractPathHelpers.ComputeShortenedNameBase(encryptedName); + return shortenedBase + Constants.Names.SHORTENED_FILE_EXTENSION; + } + + return encryptedName; + } + + #endregion + + #region Decrypt Name + + /// public static string? DecryptName(string ciphertextName, string ciphertextParentFolder, FileSystemSpecifics specifics) { if (specifics.Security.NameCrypt is null) return ciphertextName; + // Sidecar files are internal bookkeeping - they have no plaintext name + if (AbstractPathHelpers.IsSidecarName(ciphertextName)) + return null; + var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security, ciphertextName); return DecryptName(ciphertextName, ciphertextParentFolder, specifics, directoryId); } + /// + /// Decrypts the provided . + /// Resolves shortened names (.sffsn) to their full ciphertext name via the paired sidecar before decrypting. + /// + /// The name to decrypt. + /// The ciphertext parent folder path. + /// The instance associated with the item. + /// A of size which will be used to hold the Directory ID data. + /// A decrypted name, or if decryption fails. public static string? DecryptName(string ciphertextName, string ciphertextParentFolder, FileSystemSpecifics specifics, Span expendableDirectoryId) { if (specifics.Security.NameCrypt is null) return ciphertextName; - var result = GetDirectoryId(ciphertextParentFolder, specifics, expendableDirectoryId); - var normalizedName = AbstractPathHelpers.RemoveCiphertextExtension(ciphertextName); + // Sidecar files are internal bookkeeping - they have no plaintext name + if (AbstractPathHelpers.IsSidecarName(ciphertextName)) + return null; + + try + { + // Resolve shortened names to their full ciphertext name via the paired sidecar + if (ciphertextName.EndsWith(Constants.Names.SHORTENED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) + { + var shortenedBase = AbstractPathHelpers.RemoveShortenedExtension(ciphertextName).ToString(); + var resolvedName = ReadSidecar(ciphertextParentFolder, shortenedBase); + if (resolvedName is null) + return null; - return specifics.Security.NameCrypt.DecryptName(normalizedName, result ? expendableDirectoryId : ReadOnlySpan.Empty); + ciphertextName = resolvedName; + } + + var result = GetDirectoryId(ciphertextParentFolder, specifics, expendableDirectoryId); + var normalizedName = AbstractPathHelpers.RemoveCiphertextExtension(ciphertextName); + + return specifics.Security.NameCrypt.DecryptName(normalizedName, result ? expendableDirectoryId : ReadOnlySpan.Empty); + } + catch (Exception) + { + return null; + } } + + #endregion } } diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Paths.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Paths.cs index 415aac9ea..172f3ff18 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Paths.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Paths.cs @@ -17,6 +17,54 @@ public static string GetCiphertextPath(string plaintextRelativePath, FileSystemS return GetCiphertextPath(plaintextRelativePath, specifics, directoryId); } + /// + /// Encrypts and gets the ciphertext path from provided , + /// writing sidecar files for any name that exceeds the shortening threshold. + /// + /// The relative plaintext path to an item. + /// The specifics. + /// A full ciphertext path. + public static string GetCiphertextPathForUse(string plaintextRelativePath, FileSystemSpecifics specifics) + { + var directoryId = new byte[Constants.DIRECTORY_ID_SIZE]; + return GetCiphertextPathForUse(plaintextRelativePath, specifics, directoryId); + } + + /// + /// Encrypts and gets the ciphertext path from provided , + /// writing sidecar files for any name that exceeds the shortening threshold. + /// + /// The relative plaintext path to an item. + /// The specifics. + /// A of size which will be used to hold the Directory ID data. + /// A full ciphertext path. + public static string GetCiphertextPathForUse(string plaintextRelativePath, FileSystemSpecifics specifics, Span expendableDirectoryId) + { + // Make path relative as a precaution (if the path is passed as ContentPath + PlaintextPath) + plaintextRelativePath = MakeRelative(plaintextRelativePath, specifics.ContentFolder.Id); + + // Return the (full) path, if not using name encryption + if (specifics.Security.NameCrypt is null) + { + if (plaintextRelativePath.StartsWith(Path.DirectorySeparatorChar)) + return specifics.ContentFolder.Id + plaintextRelativePath; + + return Path.Combine(specifics.ContentFolder.Id, plaintextRelativePath); + } + + var finalPath = specifics.ContentFolder.Id; + foreach (var namePart in plaintextRelativePath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) + { + // Encrypt the name part and materialize sidecar if shortened + var ciphertextName = EncryptNameForUse(namePart, finalPath, specifics, expendableDirectoryId); + + // Combine the final path + finalPath = Path.Combine(finalPath, ciphertextName); + } + + return finalPath; + } + /// /// Decrypts and gets the plaintext path from provided . /// @@ -58,8 +106,8 @@ public static string GetCiphertextPath(string plaintextRelativePath, FileSystemS var finalPath = specifics.ContentFolder.Id; foreach (var namePart in plaintextRelativePath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) { - // Encrypt the name part - var ciphertextName = EncryptName(namePart, finalPath, specifics, expendableDirectoryId); + // Encrypt the name part (discovery only — no sidecar write) + var ciphertextName = EncryptNameForDiscovery(namePart, finalPath, specifics, expendableDirectoryId); // Combine the final path finalPath = Path.Combine(finalPath, ciphertextName); diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Shortening.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Shortening.cs new file mode 100644 index 000000000..b6306dd05 --- /dev/null +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Shortening.cs @@ -0,0 +1,65 @@ +using System; +using System.IO; +using System.Text; +using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract; + +namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Native +{ + public static partial class NativePathHelpers + { + /// + /// Writes a sidecar file containing the full ciphertext name for a shortened file. + /// + /// The ciphertext parent folder path on disk. + /// The deterministic shortened name base (no extension). + /// The full ciphertext name to store in the sidecar. + private static void WriteSidecar(string ciphertextParentPath, string shortenedBase, string ciphertextName) + { + var sidecarPath = Path.Combine(ciphertextParentPath, shortenedBase + Constants.Names.SIDECAR_FILE_EXTENSION); + File.WriteAllText(sidecarPath, ciphertextName, Encoding.UTF8); + } + + /// + /// Reads the full ciphertext name from a sidecar file. + /// Returns if the sidecar does not exist or cannot be read. + /// + /// The ciphertext parent folder path on disk. + /// The deterministic shortened name base (no extension). + /// The full ciphertext name, or if the sidecar cannot be read. + private static string? ReadSidecar(string ciphertextParentPath, string shortenedBase) + { + try + { + var sidecarPath = Path.Combine(ciphertextParentPath, shortenedBase + Constants.Names.SIDECAR_FILE_EXTENSION); + if (!File.Exists(sidecarPath)) + return null; + + var content = File.ReadAllText(sidecarPath, Encoding.UTF8); + if (content.Length > AbstractPathHelpers.MAX_SIDECAR_BYTES) + return null; // Reject malformed/malicious sidecar + + return content; + } + catch (Exception) + { + return null; + } + } + + /// + /// Deletes the sidecar file for a shortened item, if it exists. + /// + /// The shortened ciphertext name of the item. + /// The ciphertext parent folder path on disk. + public static void DeleteSidecarFile(string ciphertextItemName, string ciphertextParentPath) + { + var sidecarName = AbstractPathHelpers.TryGetSidecarName(ciphertextItemName); + if (sidecarName is null) + return; + + var sidecarPath = Path.Combine(ciphertextParentPath, sidecarName); + if (File.Exists(sidecarPath)) + File.Delete(sidecarPath); + } + } +} 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..c5a98d00d 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.cs @@ -7,6 +7,12 @@ namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Native /// public static partial class NativePathHelpers { + /// + /// Makes the provided relative to the specified . + /// + /// The full path to convert. + /// The base path to make the full path relative to. + /// A relative path with a leading directory separator. public static string MakeRelative(string fullPath, string basePath) { return PathHelpers.EnsureNoLeadingPathSeparator(Path.DirectorySeparatorChar + fullPath.Replace(basePath, string.Empty)); diff --git a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Operational.cs b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Operational.cs index df8cd0de7..eed48a8ef 100644 --- a/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Operational.cs +++ b/src/Core/SecureFolderFS.Core.FileSystem/Helpers/RecycleBin/Native/NativeRecycleBinHelpers.Operational.cs @@ -19,6 +19,11 @@ public static void DeleteOrRecycle(string ciphertextPath, FileSystemSpecifics sp throw FileSystemExceptions.FileSystemReadOnly; storableType = AlignStorableType(ciphertextPath); + + // Compute parent path early so it's available in all branches + var ciphertextParentPath = Path.GetDirectoryName(ciphertextPath); + _ = ciphertextParentPath ?? throw new DirectoryNotFoundException("The parent folder could not be determined."); + if (!specifics.Options.IsRecycleBinEnabled()) { DeleteImmediately(ciphertextPath, storableType); @@ -28,9 +33,7 @@ public static void DeleteOrRecycle(string ciphertextPath, FileSystemSpecifics sp // Allocate Directory ID for later use var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security); - // Decrypt the plaintext name - var ciphertextParentPath = Path.GetDirectoryName(ciphertextPath); - _ = ciphertextParentPath ?? throw new DirectoryNotFoundException("The parent folder could not be determined."); + // Decrypt the plaintext name (reads sidecar if shortened) var plaintextName = NativePathHelpers.DecryptName(Path.GetFileName(ciphertextPath), ciphertextParentPath, specifics, directoryId); if (plaintextName is null) throw new FormatException("Could not decrypt name for recycle bin configuration file."); diff --git a/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.Helpers.cs b/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.Helpers.cs index 835597625..9dd51e411 100644 --- a/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.Helpers.cs +++ b/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.Helpers.cs @@ -14,6 +14,12 @@ private string GetCiphertextPath(string plaintextName) return NativePathHelpers.GetCiphertextPath(plaintextName, _specifics); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private string GetCiphertextPathForUse(string plaintextName) + { + return NativePathHelpers.GetCiphertextPathForUse(plaintextName, _specifics); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool IsDirectory(string ciphertextPath) { diff --git a/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs b/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs index e3f190434..5689f7c00 100644 --- a/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs +++ b/src/Core/SecureFolderFS.Core.WinFsp/Callbacks/OnDeviceWinFsp.cs @@ -683,7 +683,7 @@ public override int Create( IDisposable? handle = null; try { - var ciphertextPath = GetCiphertextPath(FileName); + var ciphertextPath = GetCiphertextPathForUse(FileName); if ((CreateOptions & FILE_DIRECTORY_FILE) == 0) { FileSecurity? fileSecurity = null; @@ -926,6 +926,12 @@ public override void Cleanup( // Then delete NativeRecycleBinHelpers.DeleteOrRecycle(pathToDelete, _specifics, storableType); + + // Clean up sidecar after successful delete/recycle + NativePathHelpers.DeleteSidecarFile( + Path.GetFileName(pathToDelete), + Path.GetDirectoryName(pathToDelete) ?? string.Empty); + Trace(STATUS_SUCCESS, FileName); } catch (Exception) @@ -998,7 +1004,7 @@ public override int Rename( return Trace(STATUS_ACCESS_DENIED, FileName); var oldCiphertextPath = GetCiphertextPath(FileName); - var newCiphertextPath = GetCiphertextPath(NewFileName); + var newCiphertextPath = GetCiphertextPathForUse(NewFileName); var isDirectory = IsDirectory(oldCiphertextPath); var newPathExists = isDirectory ? Directory.Exists(newCiphertextPath) : File.Exists(newCiphertextPath); @@ -1015,6 +1021,11 @@ public override int Rename( File.Move(oldCiphertextPath, newCiphertextPath); } + // Clean up old sidecar after successful move + NativePathHelpers.DeleteSidecarFile( + Path.GetFileName(oldCiphertextPath), + Path.GetDirectoryName(oldCiphertextPath) ?? string.Empty); + return Trace(STATUS_SUCCESS, FileName); } else if (ReplaceIfExists) @@ -1028,6 +1039,11 @@ public override int Rename( File.Delete(newCiphertextPath); File.Move(oldCiphertextPath, newCiphertextPath); + // Clean up old sidecar after successful move + NativePathHelpers.DeleteSidecarFile( + Path.GetFileName(oldCiphertextPath), + Path.GetDirectoryName(oldCiphertextPath) ?? string.Empty); + return Trace(STATUS_SUCCESS, FileName); } else From 23a13d685968e744cc9f5d9f474e5dc15bd18c0a Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Tue, 26 May 2026 14:21:40 +0200 Subject: [PATCH 10/14] Added shortening and recycle bin support to FUSE --- .../Callbacks/OnDeviceFuse.cs | 71 +++++++++++++------ 1 file changed, 50 insertions(+), 21 deletions(-) diff --git a/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs b/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs index 659bc0f7d..10efa19e3 100644 --- a/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs +++ b/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs @@ -1,11 +1,13 @@ +using System.Runtime.CompilerServices; +using System.Text; +using OwlCore.Storage; using SecureFolderFS.Core.FileSystem; using SecureFolderFS.Core.FileSystem.Helpers.Paths; +using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract; using SecureFolderFS.Core.FileSystem.Helpers.Paths.Native; +using SecureFolderFS.Core.FileSystem.Helpers.RecycleBin.Native; using SecureFolderFS.Core.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; @@ -65,7 +67,7 @@ public override unsafe int Create(ReadOnlySpan path, mode_t mode, ref Fuse if (FuseOptions!.IsReadOnly) return -EROFS; - var ciphertextPath = GetCiphertextPath(path); + var ciphertextPath = GetCiphertextPathForUse(path); if (ciphertextPath is null) return -ENOENT; @@ -259,7 +261,7 @@ public override unsafe int MkDir(ReadOnlySpan path, mode_t mode) if (FuseOptions!.IsReadOnly) return -EROFS; - var ciphertextPath = GetCiphertextPath(path); + var ciphertextPath = GetCiphertextPathForUse(path); if (ciphertextPath is null) return -ENOENT; @@ -410,7 +412,7 @@ public override unsafe int Rename(ReadOnlySpan path, ReadOnlySpan ne return -EROFS; var ciphertextPath = GetCiphertextPath(path); - var newCiphertextPath = GetCiphertextPath(newPath); + var newCiphertextPath = GetCiphertextPathForUse(newPath); if (ciphertextPath is null || newCiphertextPath is null) return -ENOENT; @@ -421,10 +423,15 @@ public override unsafe int Rename(ReadOnlySpan path, ReadOnlySpan ne return -errno; } + // Clean up old sidecar after successful rename + NativePathHelpers.DeleteSidecarFile( + Path.GetFileName(ciphertextPath), + Path.GetDirectoryName(ciphertextPath) ?? string.Empty); + return 0; } - public override unsafe int RmDir(ReadOnlySpan path) + public override int RmDir(ReadOnlySpan path) { if (FuseOptions!.IsReadOnly) return -EROFS; @@ -437,17 +444,22 @@ public override unsafe int RmDir(ReadOnlySpan path) return -ENOTEMPTY; var directoryIdPath = Path.Combine(ciphertextPath, FileSystem.Constants.Names.DIRECTORY_ID_FILENAME); - - // Remove DirectoryID - File.Delete(directoryIdPath); specifics.DirectoryIdCache.CacheRemove(directoryIdPath); - fixed (byte *ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) + try { - if (rmdir(ciphertextPathPtr) == -1) - return -errno; + NativeRecycleBinHelpers.DeleteOrRecycle(ciphertextPath, specifics, StorableType.Folder); + } + catch + { + return -EIO; } + // Clean up sidecar after successful delete/recycle + NativePathHelpers.DeleteSidecarFile( + Path.GetFileName(ciphertextPath), + Path.GetDirectoryName(ciphertextPath) ?? string.Empty); + return 0; } @@ -529,7 +541,7 @@ public override int Truncate(ReadOnlySpan path, ulong length, FuseFileInfo /// /// This method is also responsible for file deletion. /// - public override unsafe int Unlink(ReadOnlySpan path) + public override int Unlink(ReadOnlySpan path) { if (FuseOptions!.IsReadOnly) return -EROFS; @@ -541,11 +553,19 @@ public override unsafe int Unlink(ReadOnlySpan path) if (Directory.Exists(ciphertextPath)) return -EISDIR; - fixed (byte *ciphertextPathPtr = Encoding.UTF8.GetBytes(ciphertextPath)) + try { - if (unlink(ciphertextPathPtr) == -1) - return -errno; + NativeRecycleBinHelpers.DeleteOrRecycle(ciphertextPath, specifics, StorableType.File); } + catch + { + return -EIO; + } + + // Clean up sidecar after successful delete/recycle + NativePathHelpers.DeleteSidecarFile( + Path.GetFileName(ciphertextPath), + Path.GetDirectoryName(ciphertextPath) ?? string.Empty); return 0; } @@ -569,7 +589,7 @@ 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; @@ -614,12 +634,21 @@ public override int Write(ReadOnlySpan path, ulong offset, ReadOnlySpan plaintextName) + protected override unsafe string? GetCiphertextPath(ReadOnlySpan nativePlaintextName) + { + fixed (byte *plaintextNamePtr = nativePlaintextName) + { + var directoryId = new byte[FileSystem.Constants.DIRECTORY_ID_SIZE]; + return NativePathHelpers.GetCiphertextPath(Encoding.UTF8.GetString(plaintextNamePtr, nativePlaintextName.Length), specifics, directoryId); + } + } + + private unsafe string? GetCiphertextPathForUse(ReadOnlySpan nativePlaintextName) { - fixed (byte *plaintextNamePtr = plaintextName) + fixed (byte *plaintextNamePtr = nativePlaintextName) { var directoryId = new byte[FileSystem.Constants.DIRECTORY_ID_SIZE]; - return NativePathHelpers.GetCiphertextPath(Encoding.UTF8.GetString(plaintextNamePtr, plaintextName.Length), specifics, directoryId); + return NativePathHelpers.GetCiphertextPathForUse(Encoding.UTF8.GetString(plaintextNamePtr, nativePlaintextName.Length), specifics, directoryId); } } } From 320818ff04df25b02f7d8ad1e5dc7790c95575a1 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Tue, 26 May 2026 16:43:27 +0200 Subject: [PATCH 11/14] Added name shortening to MAUI --- .../Extensions/Mappers/CustomMappers.Entry.cs | 8 +++++-- .../UserControls/Common/ModernEntry.cs | 7 ++++++ .../Modals/Wizard/CredentialsWizardPage.xaml | 23 +++++++++++++++---- .../Wizard/CredentialsWizardPage.xaml.cs | 13 +++++++++++ .../Strings/en-US/Resources.resx | 3 +++ 5 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Entry.cs b/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Entry.cs index 5d45f3ce7..de8dd3f00 100644 --- a/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Entry.cs +++ b/src/Platforms/SecureFolderFS.Maui/Extensions/Mappers/CustomMappers.Entry.cs @@ -17,7 +17,7 @@ public static void AddEntryMappers() { EntryHandler.Mapper.AppendToMapping($"{nameof(CustomMappers)}.{nameof(Entry)}", (handler, view) => { - if (view is not ModernEntry) + if (view is not ModernEntry modernEntry) return; #if ANDROID const float R = 24f; @@ -33,7 +33,11 @@ public static void AddEntryMappers() shape.Paint.StrokeWidth = 4; shape.Paint.SetStyle(Paint.Style.Stroke); handler.PlatformView.Background = shape; - handler.PlatformView.SetPadding(40, 32,40, 32); + + if (modernEntry.IsPadded) + handler.PlatformView.SetPadding(40, 32,40, 32); + else + handler.PlatformView.SetPadding(16, 8, 16, 8); #elif IOS handler.PlatformView.Layer.BorderColor = (App.Current.Resources[MauiThemeHelper.Instance.ActualTheme switch { diff --git a/src/Platforms/SecureFolderFS.Maui/UserControls/Common/ModernEntry.cs b/src/Platforms/SecureFolderFS.Maui/UserControls/Common/ModernEntry.cs index e7b1233c5..53a76f3d2 100644 --- a/src/Platforms/SecureFolderFS.Maui/UserControls/Common/ModernEntry.cs +++ b/src/Platforms/SecureFolderFS.Maui/UserControls/Common/ModernEntry.cs @@ -2,5 +2,12 @@ namespace SecureFolderFS.Maui.UserControls.Common { public class ModernEntry : Entry { + public bool IsPadded + { + get => (bool)GetValue(IsPaddedProperty); + set => SetValue(IsPaddedProperty, value); + } + public static readonly BindableProperty IsPaddedProperty = + BindableProperty.Create(nameof(IsPadded), typeof(bool), typeof(ModernEntry), defaultValue: true); } } diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Wizard/CredentialsWizardPage.xaml b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Wizard/CredentialsWizardPage.xaml index c11f3bdc1..668d40839 100644 --- a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Wizard/CredentialsWizardPage.xaml +++ b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Wizard/CredentialsWizardPage.xaml @@ -81,10 +81,7 @@ SelectedItem="{Binding ViewModel.FileNameCipher, Mode=TwoWay}" /> - + + + + + + diff --git a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Wizard/CredentialsWizardPage.xaml.cs b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Wizard/CredentialsWizardPage.xaml.cs index b3a8080e1..3eada782e 100644 --- a/src/Platforms/SecureFolderFS.Maui/Views/Modals/Wizard/CredentialsWizardPage.xaml.cs +++ b/src/Platforms/SecureFolderFS.Maui/Views/Modals/Wizard/CredentialsWizardPage.xaml.cs @@ -24,5 +24,18 @@ protected override void OnAppearing() OverlayViewModel.CurrentViewModel = ViewModel; base.OnAppearing(); } + + private void Shortening_TextChanged(object? sender, TextChangedEventArgs e) + { + if (sender is not Entry entry) + return; + + if (!int.TryParse(entry.Text, out var value)) + value = 0; + + value = Math.Max(0, Math.Min(value, 250)); + entry.Text = value.ToString(); + ViewModel.ShorteningThreshold = value; + } } } diff --git a/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx index 119aba2e4..f57b954e4 100644 --- a/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx +++ b/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx @@ -1463,4 +1463,7 @@ The name shortening file has no data file and will be deleted. + + Name shortening + From 2d5b5b4eabf15ecab37c8172c03742339863646b Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Tue, 26 May 2026 16:48:39 +0200 Subject: [PATCH 12/14] Update MockVaultHelpers.V4.cs --- .../Helpers/MockVaultHelpers.V4.cs | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/tests/SecureFolderFS.Tests/Helpers/MockVaultHelpers.V4.cs b/tests/SecureFolderFS.Tests/Helpers/MockVaultHelpers.V4.cs index 622e32796..a5f96e5d1 100644 --- a/tests/SecureFolderFS.Tests/Helpers/MockVaultHelpers.V4.cs +++ b/tests/SecureFolderFS.Tests/Helpers/MockVaultHelpers.V4.cs @@ -6,28 +6,30 @@ namespace SecureFolderFS.Tests.Helpers internal static partial class MockVaultHelpers { // Mock recovery key - public const string V4_RECOVERY_KEY = "osNie57du4qOxSkuxcyYd36RsQI3xPVnUpPad/aych4=@@@7wmweOio0sGGljnvBiggxo/65ZlvTnTfVNFmwFZ7W8g="; + public const string V4_RECOVERY_KEY = "Ww0/Rx6XHEB87y4WWfw12xo7Xfyk67EB3FNUlWdaG/k=@@@ndVd2mEgV/9Sq9xhLKa4ZaACwQH+7JVzfb7rTLBZK2s="; private const string V4_KEYSTORE_STRING = """ { - "c_encryptionKey": "d0qVlgnquQr1NID0IdtrTd4pqzHkGxXWhHSpkrPuYtDXjVbm3ODpwQ==", - "c_macKey": "ZKhu3nbUjoqV6ZGtU/gitauQBuC76iCwuDnLD6oa3Pav1srYcQN/zw==", - "salt": "OEpZjy18/dbbS+i2LlmKjA==", - "c_softwareEntropy": "T9dHlJg4WLmuKM4Qz1rcdIn0QsCdiGNNtU6EyzN03OA=", - "entropyNonce": "buZqCJGGsukm87mP", - "entropyTag": "AvsZawlWTMG3gBpuavmu4g==" + "c_encryptionKey": "wALUX7wq5cZ45yB3HncCiiXZ3OiQmOTZj5MU/T03dxldD3pyh11C5g==", + "c_macKey": "1DrsQmRH4X6CgM08aRqeANYXxN6NWlNrzsbp6DKeXErrE+KfYRS08g==", + "salt": "e5nJCCu+uJZ3uAwio+iXOg==", + "c_softwareEntropy": "bYWy61+0lXUb8e9ZLmasECuCZWmUTaKC8BIJvEofix4=", + "entropyNonce": "jzd/bdatTE2uOoMa", + "entropyTag": "OwjR5RFBmvl7Aaf0rwQ8yw==" } """; private const string V4_SFCONFIG_STRING = """ { - "contentCipherScheme": "XChaCha20-Poly1305", + "contentCipherScheme": "AES-GCM", "filenameCipherScheme": "AES-SIV", "filenameEncoding": "Base4K", - "recycleBinSize": -1, + "filenameShortening": 0, + "recycleBinSize": 0, "authMode": "password", - "vaultId": "8a1fbf8a-b986-4f8a-a3d7-59df8d203e3a", - "hmacsha256mac": "/gLIyGTCd8Y/bvj3YxY7JmmFEbCX3iDGYFVmeNMNw8w=", + "vaultId": "0b47eb66-1e58-451f-a72f-f1f5b34295b6", + "appPlatform": null, + "hmacsha256mac": "fpSCs1rfVwtWeCikRcSeJimORlfN3f+MCjed9nobofA=", "version": 4 } """; From 4cf01a38abb1cc214ae3cfe50334c2b632328009 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 17:07:32 +0000 Subject: [PATCH 13/14] fix: detect shortened directories during restore finalization Agent-Logs-Url: https://github.com/securefolderfs-community/SecureFolderFS/sessions/968f5df5-d3c9-4439-952b-bc291b08ad03 Co-authored-by: d2dyno1 <53011783+d2dyno1@users.noreply.github.com> --- .../Routines/Operational/RestoreRoutine.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/RestoreRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/RestoreRoutine.cs index 2384a44f0..ca87fc2de 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/RestoreRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/RestoreRoutine.cs @@ -73,9 +73,16 @@ public async Task FinalizeAsync(CancellationToken cancellationToken var minSidecarContentLength = int.MaxValue; var hasShortenedNames = false; - var folderScanner = new DeepFolderScanner(contentFolder, StorableType.File); + var folderScanner = new DeepFolderScanner(contentFolder, StorableType.All); await foreach (var item in folderScanner.ScanFolderAsync(cancellationToken)) { + // Shortened items are hashes that can't be decrypted directly + if (item.Name.EndsWith(FileSystem.Constants.Names.SHORTENED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) + { + hasShortenedNames = true; + continue; + } + if (item is not IFile file) continue; @@ -95,13 +102,6 @@ public async Task FinalizeAsync(CancellationToken cancellationToken continue; } - // Shortened files are hashes that can't be decrypted directly - if (item.Name.EndsWith(FileSystem.Constants.Names.SHORTENED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) - { - hasShortenedNames = true; - continue; - } - if (!item.Name.EndsWith(FileSystem.Constants.Names.ENCRYPTED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) noExtensions++; From e9be5a1f1f835080a8bf3a2cfe29cdceb1c6b6c5 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Tue, 26 May 2026 19:16:45 +0200 Subject: [PATCH 14/14] Apply code review --- .../Callbacks/OnDeviceFuse.cs | 33 +++++++++++++++++++ .../Routines/Operational/RestoreRoutine.cs | 8 ++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs b/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs index 10efa19e3..c8a6c5d92 100644 --- a/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs +++ b/src/Core/SecureFolderFS.Core.FUSE/Callbacks/OnDeviceFuse.cs @@ -2,6 +2,7 @@ using System.Text; using OwlCore.Storage; using SecureFolderFS.Core.FileSystem; +using SecureFolderFS.Core.FileSystem.Helpers; using SecureFolderFS.Core.FileSystem.Helpers.Paths; using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract; using SecureFolderFS.Core.FileSystem.Helpers.Paths.Native; @@ -450,6 +451,22 @@ public override int RmDir(ReadOnlySpan path) { NativeRecycleBinHelpers.DeleteOrRecycle(ciphertextPath, specifics, StorableType.Folder); } + catch (FileNotFoundException) + { + return -ENOENT; + } + catch (DirectoryNotFoundException) + { + return -ENOENT; + } + catch (UnauthorizedAccessException) + { + return -EACCES; + } + catch (IOException ioEx) when (ErrorHandlingHelpers.IsDiskFullException(ioEx)) + { + return -ENOSPC; + } catch { return -EIO; @@ -557,6 +574,22 @@ public override int Unlink(ReadOnlySpan path) { NativeRecycleBinHelpers.DeleteOrRecycle(ciphertextPath, specifics, StorableType.File); } + catch (FileNotFoundException) + { + return -ENOENT; + } + catch (DirectoryNotFoundException) + { + return -ENOENT; + } + catch (UnauthorizedAccessException) + { + return -EACCES; + } + catch (IOException ioEx) when (ErrorHandlingHelpers.IsDiskFullException(ioEx)) + { + return -ENOSPC; + } catch { return -EIO; diff --git a/src/Core/SecureFolderFS.Core/Routines/Operational/RestoreRoutine.cs b/src/Core/SecureFolderFS.Core/Routines/Operational/RestoreRoutine.cs index ca87fc2de..bfce58e5e 100644 --- a/src/Core/SecureFolderFS.Core/Routines/Operational/RestoreRoutine.cs +++ b/src/Core/SecureFolderFS.Core/Routines/Operational/RestoreRoutine.cs @@ -76,6 +76,8 @@ public async Task FinalizeAsync(CancellationToken cancellationToken var folderScanner = new DeepFolderScanner(contentFolder, StorableType.All); await foreach (var item in folderScanner.ScanFolderAsync(cancellationToken)) { + cancellationToken.ThrowIfCancellationRequested(); + // Shortened items are hashes that can't be decrypted directly if (item.Name.EndsWith(FileSystem.Constants.Names.SHORTENED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) { @@ -91,13 +93,17 @@ public async Task FinalizeAsync(CancellationToken cancellationToken { try { + cancellationToken.ThrowIfCancellationRequested(); await using var sidecarStream = await file.OpenReadAsync(cancellationToken); var buffer = new byte[4097]; var bytesRead = await sidecarStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); if (bytesRead is > 0 and <= 4096) minSidecarContentLength = Math.Min(minSidecarContentLength, Encoding.UTF8.GetString(buffer, 0, bytesRead).Length); } - catch { } + catch + { + cancellationToken.ThrowIfCancellationRequested(); + } continue; }