Skip to content

Commit c28b045

Browse files
committed
Implemented sidecar logic for Dokany and WinFsp
1 parent f8a398e commit c28b045

11 files changed

Lines changed: 334 additions & 18 deletions

File tree

src/Core/SecureFolderFS.Core.Dokany/Callbacks/OnDeviceDokany.cs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ public override NtStatus CreateFile(string fileName, FileAccess access, FileShar
9090
{
9191
}
9292

93+
// Materialize sidecar for the new directory name if shortened
94+
ciphertextPath = GetCiphertextPathForUse(fileName) ?? ciphertextPath;
95+
9396
// Create directory
9497
_ = Directory.CreateDirectory(ciphertextPath);
9598

@@ -172,6 +175,10 @@ public override NtStatus CreateFile(string fileName, FileAccess access, FileShar
172175
if (specifics.Options.IsReadOnly && mode.IsWriteFlag())
173176
throw FileSystemExceptions.FileSystemReadOnly;
174177

178+
// Materialize sidecar for the new file name if shortened
179+
if (mode is FileMode.CreateNew or FileMode.Create or FileMode.OpenOrCreate)
180+
ciphertextPath = GetCiphertextPathForUse(fileName) ?? ciphertextPath;
181+
175182
var openAccess = readAccess ? System.IO.FileAccess.Read : System.IO.FileAccess.ReadWrite;
176183
if (mode == FileMode.CreateNew && readAccess)
177184
openAccess = System.IO.FileAccess.ReadWrite;
@@ -252,6 +259,11 @@ public override void Cleanup(string fileName, IDokanFileInfo info)
252259
{
253260
NativeRecycleBinHelpers.DeleteOrRecycle(ciphertextPath, specifics, StorableType.File);
254261
}
262+
263+
// Clean up sidecar after successful delete/recycle
264+
NativePathHelpers.DeleteSidecarFile(
265+
Path.GetFileName(ciphertextPath),
266+
Path.GetDirectoryName(ciphertextPath) ?? string.Empty);
255267
}
256268
catch (UnauthorizedAccessException)
257269
{
@@ -537,7 +549,7 @@ public override NtStatus DeleteDirectory(string fileName, IDokanFileInfo info)
537549
public override NtStatus MoveFile(string oldName, string newName, bool replace, IDokanFileInfo info)
538550
{
539551
var oldCiphertextPath = GetCiphertextPath(oldName);
540-
var newCiphertextPath = GetCiphertextPath(newName);
552+
var newCiphertextPath = GetCiphertextPathForUse(newName);
541553
var fileNameCombined = $"{oldName} -> {newName}";
542554

543555
if (oldCiphertextPath is null || newCiphertextPath is null)
@@ -564,6 +576,11 @@ public override NtStatus MoveFile(string oldName, string newName, bool replace,
564576
File.Move(oldCiphertextPath, newCiphertextPath);
565577
}
566578

579+
// Clean up old sidecar after successful move
580+
NativePathHelpers.DeleteSidecarFile(
581+
Path.GetFileName(oldCiphertextPath),
582+
Path.GetDirectoryName(oldCiphertextPath) ?? string.Empty);
583+
567584
return Trace(DokanResult.Success, fileNameCombined, info);
568585
}
569586
else if (replace)
@@ -579,6 +596,11 @@ public override NtStatus MoveFile(string oldName, string newName, bool replace,
579596
File.Delete(newCiphertextPath);
580597
File.Move(oldCiphertextPath, newCiphertextPath);
581598

599+
// Clean up old sidecar after successful move
600+
NativePathHelpers.DeleteSidecarFile(
601+
Path.GetFileName(oldCiphertextPath),
602+
Path.GetDirectoryName(oldCiphertextPath) ?? string.Empty);
603+
582604
return Trace(DokanResult.Success, fileNameCombined, info);
583605
}
584606
else
@@ -824,5 +846,10 @@ public override NtStatus FindStreams(string fileName, out IList<FileInformation>
824846
{
825847
return NativePathHelpers.GetCiphertextPath(plaintextName, specifics);
826848
}
849+
850+
private string? GetCiphertextPathForUse(string plaintextName)
851+
{
852+
return NativePathHelpers.GetCiphertextPathForUse(plaintextName, specifics);
853+
}
827854
}
828855
}

src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Sidecar.cs renamed to src/Core/SecureFolderFS.Core.FileSystem/Helpers/Health/HealthHelpers.Shortening.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public static async Task DetectOrphanSidecarsAsync(IFolder folder, IProgress<IRe
3838

3939
foreach (var sidecar in sidecars)
4040
{
41-
// Derive the expected companion: replace .sffsi .sffsn
41+
// Derive the expected companion (replace .sffsi with .sffsn)
4242
var baseName = sidecar.Name[..^Constants.Names.SIDECAR_FILE_EXTENSION.Length];
4343
var expectedCompanion = baseName + Constants.Names.SHORTENED_FILE_EXTENSION;
4444

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Abstract
1313
{
1414
public static partial class AbstractPathHelpers
1515
{
16-
private const int MAX_SIDECAR_BYTES = 4096; // No legitimate ciphertext name approaches this upper bound
16+
internal const int MAX_SIDECAR_BYTES = 4096; // No legitimate ciphertext name approaches this upper bound
1717

1818
/// <summary>
1919
/// Tries to generate the name of the sidecar file associated with the given disk name.
@@ -79,7 +79,7 @@ public static ReadOnlySpan<char> RemoveShortenedExtension(string shortenedName)
7979
/// <param name="ciphertextName">The full ciphertext name to compute the base for.</param>
8080
/// <returns>A deterministic, filesystem-safe name base (no extension) for <paramref name="ciphertextName"/>.</returns>
8181
[SkipLocalsInit]
82-
private static string ComputeShortenedNameBase(string ciphertextName)
82+
internal static string ComputeShortenedNameBase(string ciphertextName)
8383
{
8484
var nameBytes = Encoding.UTF8.GetBytes(ciphertextName);
8585
Span<byte> hash = stackalloc byte[32];

src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Directory.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Native
55
{
66
public static partial class NativePathHelpers
77
{
8+
/// <summary>
9+
/// Gets the Directory ID for the specified ciphertext folder.
10+
/// </summary>
11+
/// <param name="ciphertextFolderPath">The ciphertext folder path on disk.</param>
12+
/// <param name="specifics">The <see cref="FileSystemSpecifics"/> instance associated with the item.</param>
13+
/// <param name="directoryId">A <see cref="Span{T}"/> of size <see cref="Constants.DIRECTORY_ID_SIZE"/> which will be filled with the Directory ID data.</param>
14+
/// <returns>True if a Directory ID was read; false if the folder is the root.</returns>
815
public static bool GetDirectoryId(string ciphertextFolderPath, FileSystemSpecifics specifics, Span<byte> directoryId)
916
{
1017
// Check if we're at the root

src/Core/SecureFolderFS.Core.FileSystem/Helpers/Paths/Native/NativePathHelpers.Names.cs

Lines changed: 145 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,46 +5,184 @@ namespace SecureFolderFS.Core.FileSystem.Helpers.Paths.Native
55
{
66
public static partial class NativePathHelpers
77
{
8-
public static string EncryptName(string plaintextName, string plaintextParentFolder,
8+
#region Encrypt Name Non-Materialized
9+
10+
/// <inheritdoc cref="EncryptName(string,string,FileSystemSpecifics,Span{byte})"/>
11+
public static string EncryptName(string plaintextName, string ciphertextParentFolder,
912
FileSystemSpecifics specifics)
1013
{
1114
if (specifics.Security.NameCrypt is null)
1215
return plaintextName;
1316

1417
var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security, plaintextName);
15-
return EncryptName(plaintextName, plaintextParentFolder, specifics, directoryId);
18+
return EncryptName(plaintextName, ciphertextParentFolder, specifics, directoryId);
1619
}
1720

18-
public static string EncryptName(string plaintextName, string plaintextParentFolder,
21+
/// <summary>
22+
/// Encrypts the provided <paramref name="plaintextName"/>.
23+
/// </summary>
24+
/// <param name="plaintextName">The name to encrypt.</param>
25+
/// <param name="ciphertextParentFolder">The ciphertext parent folder path.</param>
26+
/// <param name="specifics">The <see cref="FileSystemSpecifics"/> instance associated with the item.</param>
27+
/// <param name="expendableDirectoryId">A <see cref="Span{T}"/> of size <see cref="Constants.DIRECTORY_ID_SIZE"/> which will be used to hold the Directory ID data.</param>
28+
/// <returns>An encrypted name with the appropriate file extension appended.</returns>
29+
public static string EncryptName(string plaintextName, string ciphertextParentFolder,
1930
FileSystemSpecifics specifics, Span<byte> expendableDirectoryId)
2031
{
2132
if (specifics.Security.NameCrypt is null)
2233
return plaintextName;
2334

24-
var result = GetDirectoryId(plaintextParentFolder, specifics, expendableDirectoryId);
35+
var result = GetDirectoryId(ciphertextParentFolder, specifics, expendableDirectoryId);
2536
return specifics.Security.NameCrypt.EncryptName(plaintextName, result ? expendableDirectoryId : ReadOnlySpan<byte>.Empty) + Constants.Names.ENCRYPTED_FILE_EXTENSION;
2637
}
2738

39+
#endregion
40+
41+
#region Encrypt Name Materialized
42+
43+
/// <inheritdoc cref="EncryptNameForUse(string,string,FileSystemSpecifics,Span{byte})"/>
44+
public static string EncryptNameForUse(string plaintextName, string ciphertextParentFolder,
45+
FileSystemSpecifics specifics)
46+
{
47+
if (specifics.Security.NameCrypt is null)
48+
return plaintextName;
49+
50+
var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security, plaintextName);
51+
return EncryptNameForUse(plaintextName, ciphertextParentFolder, specifics, directoryId);
52+
}
53+
54+
/// <summary>
55+
/// Encrypts the provided <paramref name="plaintextName"/> and materializes it.
56+
/// If the encrypted name exceeds the shortening threshold, a sidecar file is written and the shortened name is returned.
57+
/// </summary>
58+
/// <param name="plaintextName">The name to encrypt.</param>
59+
/// <param name="ciphertextParentFolder">The ciphertext parent folder path.</param>
60+
/// <param name="specifics">The <see cref="FileSystemSpecifics"/> instance associated with the item.</param>
61+
/// <param name="expendableDirectoryId">A <see cref="Span{T}"/> of size <see cref="Constants.DIRECTORY_ID_SIZE"/> which will be used to hold the Directory ID data.</param>
62+
/// <returns>An encrypted name with the appropriate file extension appended.</returns>
63+
public static string EncryptNameForUse(string plaintextName, string ciphertextParentFolder,
64+
FileSystemSpecifics specifics, Span<byte> expendableDirectoryId)
65+
{
66+
if (specifics.Security.NameCrypt is null)
67+
return plaintextName;
68+
69+
var result = GetDirectoryId(ciphertextParentFolder, specifics, expendableDirectoryId);
70+
var encryptedName = specifics.Security.NameCrypt.EncryptName(plaintextName, result ? expendableDirectoryId : ReadOnlySpan<byte>.Empty) + Constants.Names.ENCRYPTED_FILE_EXTENSION;
71+
72+
if (specifics.Options.ShorteningThreshold > 0 && encryptedName.Length >= specifics.Options.ShorteningThreshold)
73+
{
74+
var shortenedBase = AbstractPathHelpers.ComputeShortenedNameBase(encryptedName);
75+
WriteSidecar(ciphertextParentFolder, shortenedBase, encryptedName);
76+
return shortenedBase + Constants.Names.SHORTENED_FILE_EXTENSION;
77+
}
78+
79+
return encryptedName;
80+
}
81+
82+
#endregion
83+
84+
#region Encrypt Name Discoverability
85+
86+
/// <inheritdoc cref="EncryptNameForDiscovery(string,string,FileSystemSpecifics,Span{byte})"/>
87+
public static string EncryptNameForDiscovery(string plaintextName, string ciphertextParentFolder,
88+
FileSystemSpecifics specifics)
89+
{
90+
if (specifics.Security.NameCrypt is null)
91+
return plaintextName;
92+
93+
var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security, plaintextName);
94+
return EncryptNameForDiscovery(plaintextName, ciphertextParentFolder, specifics, directoryId);
95+
}
96+
97+
/// <summary>
98+
/// Encrypts the provided <paramref name="plaintextName"/> and discovers potential shortening branch.
99+
/// Unlike <see cref="EncryptNameForUse(string,string,FileSystemSpecifics,Span{byte})"/>, this method does not write a sidecar file.
100+
/// </summary>
101+
/// <param name="plaintextName">The name to encrypt.</param>
102+
/// <param name="ciphertextParentFolder">The ciphertext parent folder path.</param>
103+
/// <param name="specifics">The <see cref="FileSystemSpecifics"/> instance associated with the item.</param>
104+
/// <param name="expendableDirectoryId">A <see cref="Span{T}"/> of size <see cref="Constants.DIRECTORY_ID_SIZE"/> which will be used to hold the Directory ID data.</param>
105+
/// <returns>An encrypted name with the appropriate file extension appended.</returns>
106+
public static string EncryptNameForDiscovery(string plaintextName, string ciphertextParentFolder,
107+
FileSystemSpecifics specifics, Span<byte> expendableDirectoryId)
108+
{
109+
if (specifics.Security.NameCrypt is null)
110+
return plaintextName;
111+
112+
var result = GetDirectoryId(ciphertextParentFolder, specifics, expendableDirectoryId);
113+
var encryptedName = specifics.Security.NameCrypt.EncryptName(plaintextName, result ? expendableDirectoryId : ReadOnlySpan<byte>.Empty) + Constants.Names.ENCRYPTED_FILE_EXTENSION;
114+
115+
if (specifics.Options.ShorteningThreshold > 0 && encryptedName.Length >= specifics.Options.ShorteningThreshold)
116+
{
117+
var shortenedBase = AbstractPathHelpers.ComputeShortenedNameBase(encryptedName);
118+
return shortenedBase + Constants.Names.SHORTENED_FILE_EXTENSION;
119+
}
120+
121+
return encryptedName;
122+
}
123+
124+
#endregion
125+
126+
#region Decrypt Name
127+
128+
/// <inheritdoc cref="DecryptName(string,string,FileSystemSpecifics,Span{byte})"/>
28129
public static string? DecryptName(string ciphertextName, string ciphertextParentFolder,
29130
FileSystemSpecifics specifics)
30131
{
31132
if (specifics.Security.NameCrypt is null)
32133
return ciphertextName;
33134

135+
// Sidecar files are internal bookkeeping - they have no plaintext name
136+
if (AbstractPathHelpers.IsSidecarName(ciphertextName))
137+
return null;
138+
34139
var directoryId = AbstractPathHelpers.AllocateDirectoryId(specifics.Security, ciphertextName);
35140
return DecryptName(ciphertextName, ciphertextParentFolder, specifics, directoryId);
36141
}
37142

143+
/// <summary>
144+
/// Decrypts the provided <paramref name="ciphertextName"/>.
145+
/// Resolves shortened names (<c>.sffsn</c>) to their full ciphertext name via the paired sidecar before decrypting.
146+
/// </summary>
147+
/// <param name="ciphertextName">The name to decrypt.</param>
148+
/// <param name="ciphertextParentFolder">The ciphertext parent folder path.</param>
149+
/// <param name="specifics">The <see cref="FileSystemSpecifics"/> instance associated with the item.</param>
150+
/// <param name="expendableDirectoryId">A <see cref="Span{T}"/> of size <see cref="Constants.DIRECTORY_ID_SIZE"/> which will be used to hold the Directory ID data.</param>
151+
/// <returns>A decrypted name, or <see langword="null"/> if decryption fails.</returns>
38152
public static string? DecryptName(string ciphertextName, string ciphertextParentFolder,
39153
FileSystemSpecifics specifics, Span<byte> expendableDirectoryId)
40154
{
41155
if (specifics.Security.NameCrypt is null)
42156
return ciphertextName;
43157

44-
var result = GetDirectoryId(ciphertextParentFolder, specifics, expendableDirectoryId);
45-
var normalizedName = AbstractPathHelpers.RemoveCiphertextExtension(ciphertextName);
158+
// Sidecar files are internal bookkeeping - they have no plaintext name
159+
if (AbstractPathHelpers.IsSidecarName(ciphertextName))
160+
return null;
161+
162+
try
163+
{
164+
// Resolve shortened names to their full ciphertext name via the paired sidecar
165+
if (ciphertextName.EndsWith(Constants.Names.SHORTENED_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase))
166+
{
167+
var shortenedBase = AbstractPathHelpers.RemoveShortenedExtension(ciphertextName).ToString();
168+
var resolvedName = ReadSidecar(ciphertextParentFolder, shortenedBase);
169+
if (resolvedName is null)
170+
return null;
46171

47-
return specifics.Security.NameCrypt.DecryptName(normalizedName, result ? expendableDirectoryId : ReadOnlySpan<byte>.Empty);
172+
ciphertextName = resolvedName;
173+
}
174+
175+
var result = GetDirectoryId(ciphertextParentFolder, specifics, expendableDirectoryId);
176+
var normalizedName = AbstractPathHelpers.RemoveCiphertextExtension(ciphertextName);
177+
178+
return specifics.Security.NameCrypt.DecryptName(normalizedName, result ? expendableDirectoryId : ReadOnlySpan<byte>.Empty);
179+
}
180+
catch (Exception)
181+
{
182+
return null;
183+
}
48184
}
185+
186+
#endregion
49187
}
50188
}

0 commit comments

Comments
 (0)