Skip to content

Commit 94e810f

Browse files
committed
Detect orphan sidecar files in vault health
1 parent 071f0c6 commit 94e810f

10 files changed

Lines changed: 194 additions & 32 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using System;
2+
3+
namespace SecureFolderFS.Core.FileSystem.Exceptions
4+
{
5+
/// <summary>
6+
/// Exception thrown when a sidecar file (.sffsi) exists without a matching shortened file (.sffsn).
7+
/// </summary>
8+
public sealed class OrphanSidecarException : Exception
9+
{
10+
public OrphanSidecarException(string sidecarName)
11+
: base($"Orphan sidecar file has no matching shortened file: {sidecarName}")
12+
{
13+
}
14+
}
15+
}

src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Directory.cs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
using System.Threading;
44
using System.Threading.Tasks;
55
using OwlCore.Storage;
6-
using SecureFolderFS.Core.Cryptography;
76
using SecureFolderFS.Core.FileSystem.Helpers.Paths;
87
using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract;
98
using SecureFolderFS.Shared.ComponentModel;
@@ -15,10 +14,10 @@ namespace SecureFolderFS.Core.FileSystem.Helpers.Health
1514
{
1615
public static partial class HealthHelpers
1716
{
18-
public static async Task<IResult> RepairDirectoryAsync(IFolder affected, Security security, CancellationToken cancellationToken)
17+
public static async Task<IResult> RepairDirectoryAsync(IFolder affected, FileSystemSpecifics specifics, CancellationToken cancellationToken)
1918
{
2019
// Return success if no encryption is used
21-
if (security.NameCrypt is null)
20+
if (specifics.Security.NameCrypt is null)
2221
return Result.Success;
2322

2423
if (affected is not IRenamableFolder renamableFolder)
@@ -39,9 +38,15 @@ public static async Task<IResult> RepairDirectoryAsync(IFolder affected, Securit
3938
if (PathHelpers.IsCoreName(item.Name))
4039
continue;
4140

42-
// Encrypt a new name and rename
43-
var encryptedName = AbstractPathHelpers.EncryptNewName(item.Name, directoryId, security);
41+
// Remember old name for sidecar cleanup
42+
var oldName = item.Name;
43+
44+
// Encrypt a new name (writes sidecar if shortening applies) and rename
45+
var encryptedName = await AbstractPathHelpers.EncryptNewNameForUseAsync(item.Name, directoryId, affected, specifics, cancellationToken);
4446
_ = await renamableFolder.RenameAsync(item, encryptedName, cancellationToken);
47+
48+
// Clean up old sidecar if the previous name was shortened
49+
await AbstractPathHelpers.DeleteSidecarFileAsync(oldName, renamableFolder, cancellationToken);
4550
}
4651

4752
return Result.Success;

src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Name.cs

Lines changed: 25 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
using System.Threading;
33
using System.Threading.Tasks;
44
using OwlCore.Storage;
5-
using SecureFolderFS.Core.Cryptography;
65
using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract;
76
using SecureFolderFS.Shared.ComponentModel;
87
using SecureFolderFS.Shared.Helpers;
@@ -14,48 +13,48 @@ namespace SecureFolderFS.Core.FileSystem.Helpers.Health
1413
public static partial class HealthHelpers
1514
{
1615
public static async Task<IResult> RepairNameAsync(IStorableChild affected, FileSystemSpecifics specifics, string newName, CancellationToken cancellationToken)
17-
{
18-
var repairResult = await RepairNameAsync(affected, specifics.Security, specifics.ContentFolder, newName, cancellationToken);
19-
if (!repairResult.Successful || !specifics.CiphertextFileNameCache.IsAvailable)
20-
return repairResult;
21-
22-
// Update cache
23-
await SafetyHelpers.NoFailureAsync(async () =>
24-
{
25-
var parent = await affected.GetParentAsync(cancellationToken);
26-
if (parent is null)
27-
return;
28-
29-
var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security);
30-
var isAllocated = await AbstractPathHelpers.GetDirectoryIdAsync(parent, specifics, directoryId, cancellationToken);
31-
specifics.CiphertextFileNameCache.CacheRemove(new(isAllocated ? directoryId : [], affected.Name));
32-
});
33-
34-
return repairResult;
35-
}
36-
37-
private static async Task<IResult> RepairNameAsync(IStorableChild affected, Security security, IFolder contentFolder, string newName, CancellationToken cancellationToken)
3816
{
3917
// Return success if no encryption is used
40-
if (security.NameCrypt is null)
18+
if (specifics.Security.NameCrypt is null)
4119
return Result.Success;
4220

4321
try
4422
{
45-
// Get Directory ID
23+
// Get parent folder
4624
var parentFolder = await affected.GetParentAsync(cancellationToken);
4725
if (parentFolder is not IRenamableFolder renamableFolder)
4826
return Result.Failure(FolderNotRenamable);
4927

50-
// Encrypt new name and rename
51-
var encryptedName = await AbstractPathHelpers.EncryptNameAsync(newName, parentFolder, contentFolder, security, cancellationToken);
28+
// Remember old name for sidecar cleanup
29+
var oldName = affected.Name;
30+
31+
// Encrypt new name (writes sidecar if shortening applies) and rename
32+
var encryptedName = await AbstractPathHelpers.EncryptNameForUseAsync(newName, parentFolder, specifics, cancellationToken);
5233
_ = await renamableFolder.RenameAsync(affected, encryptedName, cancellationToken);
34+
35+
// Clean up old sidecar if the previous name was shortened
36+
await AbstractPathHelpers.DeleteSidecarFileAsync(oldName, renamableFolder, cancellationToken);
5337
}
5438
catch (Exception ex)
5539
{
5640
return Result.Failure(ex);
5741
}
5842

43+
// Update cache
44+
if (specifics.CiphertextFileNameCache.IsAvailable)
45+
{
46+
await SafetyHelpers.NoFailureAsync(async () =>
47+
{
48+
var parent = await affected.GetParentAsync(cancellationToken);
49+
if (parent is null)
50+
return;
51+
52+
var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security);
53+
var isAllocated = await AbstractPathHelpers.GetDirectoryIdAsync(parent, specifics, directoryId, cancellationToken);
54+
specifics.CiphertextFileNameCache.CacheRemove(new(isAllocated ? directoryId : [], affected.Name));
55+
});
56+
}
57+
5958
return Result.Success;
6059
}
6160
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using OwlCore.Storage;
6+
using SecureFolderFS.Core.FileSystem.Exceptions;
7+
using SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract;
8+
using SecureFolderFS.Shared.ComponentModel;
9+
using SecureFolderFS.Shared.Models;
10+
11+
namespace SecureFolderFS.Core.FileSystem.Helpers.Health
12+
{
13+
public static partial class HealthHelpers
14+
{
15+
/// <summary>
16+
/// Detects orphan sidecar files in the specified folder and reports them as issues.
17+
/// An orphan sidecar is a <c>.sffsi</c> file with no matching <c>.sffsn</c> companion.
18+
/// </summary>
19+
/// <param name="folder">The folder to scan for orphan sidecars.</param>
20+
/// <param name="reporter">The progress reporter for reporting detected issues.</param>
21+
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that cancels this action.</param>
22+
/// <returns>A <see cref="Task"/> that represents the asynchronous operation.</returns>
23+
public static async Task DetectOrphanSidecarsAsync(IFolder folder, IProgress<IResult>? reporter, CancellationToken cancellationToken = default)
24+
{
25+
if (reporter is null)
26+
return;
27+
28+
var shortenedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
29+
var sidecars = new List<IStorableChild>();
30+
31+
await foreach (var item in folder.GetItemsAsync(StorableType.File, cancellationToken))
32+
{
33+
if (AbstractPathHelpers.IsSidecarName(item.Name))
34+
sidecars.Add(item);
35+
else if (item.Name.EndsWith(Constants.Names.SHORTENED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase))
36+
shortenedNames.Add(item.Name);
37+
}
38+
39+
foreach (var sidecar in sidecars)
40+
{
41+
// Derive the expected companion: replace .sffsi → .sffsn
42+
var baseName = sidecar.Name[..^Constants.Names.SIDECAR_FILE_EXTENSION.Length];
43+
var expectedCompanion = baseName + Constants.Names.SHORTENED_FILE_EXTENSION;
44+
45+
if (!shortenedNames.Contains(expectedCompanion))
46+
reporter.Report(Result<IStorable>.Failure(sidecar, new OrphanSidecarException(sidecar.Name)));
47+
}
48+
}
49+
50+
/// <summary>
51+
/// Deletes an orphan sidecar file.
52+
/// </summary>
53+
/// <param name="sidecar">The orphan sidecar file to delete.</param>
54+
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that cancels this action.</param>
55+
/// <returns>A <see cref="Task"/> that represents the asynchronous operation.</returns>
56+
public static async Task<IResult> DeleteOrphanSidecarAsync(IStorableChild sidecar, CancellationToken cancellationToken = default)
57+
{
58+
try
59+
{
60+
if (sidecar is not IChildFile childFile)
61+
return Result.Failure(new InvalidOperationException("Sidecar is not a child file."));
62+
63+
var parent = await childFile.GetParentAsync(cancellationToken);
64+
if (parent is not IModifiableFolder modifiableFolder)
65+
return Result.Failure(new InvalidOperationException("Parent folder does not support deletion."));
66+
67+
await modifiableFolder.DeleteAsync(childFile, cancellationToken);
68+
return Result.Success;
69+
}
70+
catch (Exception ex)
71+
{
72+
return Result.Failure(ex);
73+
}
74+
}
75+
}
76+
}

src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Abstract/AbstractPathHelpers.Names.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,31 @@ public static string EncryptNewName(string plaintextName, byte[] newDirectoryId,
168168
return security.NameCrypt.EncryptName(plaintextName, newDirectoryId) + Constants.Names.ENCRYPTED_FILE_EXTENSION;
169169
}
170170

171+
/// <summary>
172+
/// Encrypts a plaintext name using the specified Directory ID and materializes it, writing a sidecar file if shortening applies.
173+
/// </summary>
174+
/// <param name="plaintextName">The original plaintext name to encrypt.</param>
175+
/// <param name="newDirectoryId">A new Directory ID used for encryption.</param>
176+
/// <param name="ciphertextParentFolder">The ciphertext parent folder where the sidecar will be written if needed.</param>
177+
/// <param name="specifics">The <see cref="FileSystemSpecifics"/> instance associated with the item.</param>
178+
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that cancels this action.</param>
179+
/// <returns>A <see cref="Task"/> that represents the asynchronous operation. Value is an encrypted name with the appropriate file extension appended.</returns>
180+
public static async Task<string> EncryptNewNameForUseAsync(string plaintextName, byte[] newDirectoryId, IFolder ciphertextParentFolder, FileSystemSpecifics specifics, CancellationToken cancellationToken = default)
181+
{
182+
if (specifics.Security.NameCrypt is null)
183+
return plaintextName;
184+
185+
var encryptedName = specifics.Security.NameCrypt.EncryptName(plaintextName, newDirectoryId) + Constants.Names.ENCRYPTED_FILE_EXTENSION;
186+
if (specifics.Options.ShorteningThreshold > 0 && encryptedName.Length >= specifics.Options.ShorteningThreshold)
187+
{
188+
var shortenedBase = ComputeShortenedNameBase(encryptedName);
189+
await WriteSidecarAsync(ciphertextParentFolder, shortenedBase, encryptedName, cancellationToken);
190+
return shortenedBase + Constants.Names.SHORTENED_FILE_EXTENSION;
191+
}
192+
193+
return encryptedName;
194+
}
195+
171196
/// <inheritdoc cref="DecryptNameAsync(string,OwlCore.Storage.IFolder,SecureFolderFS.Core.FileSystem.FileSystemSpecifics,System.Byte[],System.Threading.CancellationToken)"/>
172197
public static async Task<string?> DecryptNameAsync(string ciphertextName, IFolder ciphertextParentFolder,
173198
FileSystemSpecifics specifics, CancellationToken cancellationToken = default)

src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureContentsValidator.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Threading;
33
using System.Threading.Tasks;
44
using OwlCore.Storage;
5+
using SecureFolderFS.Core.FileSystem.Helpers.Health;
56
using SecureFolderFS.Core.FileSystem.Helpers.Paths;
67
using SecureFolderFS.Shared.ComponentModel;
78
using SecureFolderFS.Shared.Models;
@@ -36,6 +37,10 @@ public override async Task<IResult> ValidateResultAsync((IFolder, IProgress<IRes
3637
var folderResult = await _folderValidator.ValidateResultAsync(scannedFolder, cancellationToken).ConfigureAwait(false);
3738
reporter?.Report(folderResult);
3839

40+
// Detect orphan sidecar files
41+
if (specifics.Options.ShorteningThreshold > 0)
42+
await HealthHelpers.DetectOrphanSidecarsAsync(scannedFolder, reporter, cancellationToken).ConfigureAwait(false);
43+
3944
await foreach (var item in scannedFolder.GetItemsAsync(StorableType.All, cancellationToken).ConfigureAwait(false))
4045
{
4146
cancellationToken.ThrowIfCancellationRequested();

src/Core/SecureFolderFS.Core.FileSystem/Validators/StructureValidator.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Threading;
33
using System.Threading.Tasks;
44
using OwlCore.Storage;
5+
using SecureFolderFS.Core.FileSystem.Helpers.Health;
56
using SecureFolderFS.Core.FileSystem.Helpers.Paths;
67
using SecureFolderFS.Shared.ComponentModel;
78
using SecureFolderFS.Shared.Models;
@@ -37,6 +38,10 @@ public override async Task<IResult> ValidateResultAsync((IFolder, IProgress<IRes
3738
if (!folderResult.Successful)
3839
return folderResult;
3940

41+
// Detect orphan sidecar files
42+
if (specifics.Options.ShorteningThreshold > 0)
43+
await HealthHelpers.DetectOrphanSidecarsAsync(scannedFolder, reporter, cancellationToken).ConfigureAwait(false);
44+
4045
await foreach (var item in scannedFolder.GetItemsAsync(StorableType.All, cancellationToken).ConfigureAwait(false))
4146
{
4247
if (PathHelpers.IsCoreName(item.Name))

src/Platforms/SecureFolderFS.UI/ServiceImplementation/VaultHealthService.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Threading.Tasks;
88
using OwlCore.Storage;
99
using SecureFolderFS.Core.FileSystem;
10+
using SecureFolderFS.Core.FileSystem.Exceptions;
1011
using SecureFolderFS.Core.FileSystem.Helpers.Health;
1112
using SecureFolderFS.Core.FileSystem.Validators;
1213
using SecureFolderFS.Sdk.Extensions;
@@ -40,6 +41,7 @@ public class VaultHealthService : IVaultHealthService
4041
_ => GetDefault(aggregate)
4142
},
4243
FormatException => new HealthNameIssueViewModel(storable, result, "InvalidItemName".ToLocalized()) { ErrorMessage = "GenerateNewName".ToLocalized() },
44+
OrphanSidecarException => new HealthOrphanSidecarIssueViewModel(storable, result, "OrphanSidecar".ToLocalized()) { ErrorMessage = "DeleteOrphanSidecar".ToLocalized() },
4345
FileHeaderCorruptedException => new HealthFileDataIssueViewModel(storable, result, "IrrecoverableFile".ToLocalized(), isRecoverable: false) { ErrorMessage = "FileHeaderCorrupted".ToLocalized() },
4446
FileChunksCorruptedException chunksEx => new HealthFileDataIssueViewModel(storable, result, "CorruptedFileChunks".ToLocalized(), chunksEx.CorruptedChunks, isRecoverable: true) { ErrorMessage = "FileHasCorruptedChunks".ToLocalized(chunksEx.CorruptedChunks.Count) },
4547
CryptographicException => new HealthFileDataIssueViewModel(storable, result, "InvalidFileContents".ToLocalized()) { ErrorMessage = "RegenerateFileContents".ToLocalized() },
@@ -90,7 +92,7 @@ 1. Corrupted file contents (HealthFileDataIssueViewModel)
9092
// Directory ID issue
9193
HealthDirectoryIssueViewModel directoryIssue => await HealthHelpers.RepairDirectoryAsync(
9294
directoryIssue.Folder ?? throw new ArgumentNullException(nameof(HealthDirectoryIssueViewModel.Folder)),
93-
specificsWrapper.Inner.Security,
95+
specificsWrapper.Inner,
9496
cancellationToken),
9597

9698
// File data issue - repair chunks or delete irrecoverable file
@@ -105,6 +107,11 @@ 1. Corrupted file contents (HealthFileDataIssueViewModel)
105107
dataIssue.File,
106108
cancellationToken),
107109

110+
// Orphan sidecar - delete it
111+
HealthOrphanSidecarIssueViewModel => await HealthHelpers.DeleteOrphanSidecarAsync(
112+
item.Inner,
113+
cancellationToken),
114+
108115
// Default
109116
_ => null
110117
};

src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1457,4 +1457,10 @@
14571457
<data name="LoginMethod" xml:space="preserve">
14581458
<value>Login method</value>
14591459
</data>
1460+
<data name="OrphanSidecar" xml:space="preserve">
1461+
<value>Unlinked shortening file</value>
1462+
</data>
1463+
<data name="DeleteOrphanSidecar" xml:space="preserve">
1464+
<value>The name shortening file has no data file and will be deleted.</value>
1465+
</data>
14601466
</root>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using System.ComponentModel;
2+
using OwlCore.Storage;
3+
using SecureFolderFS.Sdk.Enums;
4+
using SecureFolderFS.Sdk.ViewModels.Controls.Widgets.Health;
5+
using SecureFolderFS.Shared.ComponentModel;
6+
7+
namespace SecureFolderFS.UI.ViewModels.Health
8+
{
9+
/// <inheritdoc cref="HealthIssueViewModel"/>
10+
[Bindable(true)]
11+
public sealed class HealthOrphanSidecarIssueViewModel : HealthIssueViewModel
12+
{
13+
public HealthOrphanSidecarIssueViewModel(IStorableChild storable, IResult result, string? title = null)
14+
: base(storable, result, title)
15+
{
16+
Severity = Severity.Warning;
17+
}
18+
}
19+
}

0 commit comments

Comments
 (0)