Skip to content

Commit 5c5e9ea

Browse files
fix: improve thread safety, regex caching, volume label sanitization, and logging
1 parent 2f7327b commit 5c5e9ea

10 files changed

Lines changed: 162 additions & 45 deletions

File tree

SimpleZipDrive.Core/DiagnosticLogger.cs

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System.Globalization;
2-
using System.Text;
32

43
namespace SimpleZipDrive.Core;
54

@@ -10,6 +9,7 @@ public static class DiagnosticLogger
109
{
1110
private static readonly object Lock = new();
1211
internal static volatile bool Initialized;
12+
private static StreamWriter? _writer;
1313

1414
public static bool IsEnabled { get; internal set; }
1515

@@ -75,6 +75,16 @@ public static void Initialize(string? logDir = null, bool enabled = true)
7575
{
7676
Directory.CreateDirectory(dir);
7777
LogFilePath = Path.Combine(dir, $"debug_{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}.log");
78+
79+
lock (Lock)
80+
{
81+
_writer?.Dispose();
82+
_writer = new StreamWriter(LogFilePath, false, System.Text.Encoding.UTF8)
83+
{
84+
AutoFlush = true
85+
};
86+
}
87+
7888
Initialized = true;
7989
}
8090
catch
@@ -83,6 +93,18 @@ public static void Initialize(string? logDir = null, bool enabled = true)
8393
}
8494
}
8595

96+
/// <summary>
97+
/// Closes the underlying log file writer. Call during application shutdown.
98+
/// </summary>
99+
public static void Close()
100+
{
101+
lock (Lock)
102+
{
103+
_writer?.Dispose();
104+
_writer = null;
105+
}
106+
}
107+
86108
/// <summary>
87109
/// Writes a message to the diagnostic log.
88110
/// </summary>
@@ -93,15 +115,13 @@ public static void Log(string message)
93115

94116
var timestamp = DateTime.Now.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture);
95117
var threadId = Environment.CurrentManagedThreadId;
96-
var line = $"[{timestamp}][T{threadId}] {message}{Environment.NewLine}";
118+
var line = $"[{timestamp}][T{threadId}] {message}";
97119

98120
try
99121
{
100122
lock (Lock)
101123
{
102-
var bytes = Encoding.UTF8.GetBytes(line);
103-
using var fs = new FileStream(LogFilePath, FileMode.Append, FileAccess.Write, FileShare.Read | FileShare.Delete);
104-
fs.Write(bytes, 0, bytes.Length);
124+
_writer?.WriteLine(line);
105125
}
106126
}
107127
catch

SimpleZipDrive.Core/Services/UpdateService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,6 @@ public async Task CheckForUpdateAsync(CancellationToken cancellationToken = defa
112112
}
113113
}
114114

115-
[GeneratedRegex(@"\d+\.\d+(?:\.\d+)?", RegexOptions.Compiled)]
115+
[GeneratedRegex(@"\d+\.\d+\.\d+", RegexOptions.Compiled)]
116116
private static partial Regex VersionRegex();
117117
}

SimpleZipDrive.Core/ZipFileSystemCore.cs

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public class ZipFileSystemCore : IDisposable
3737
private readonly object _memoryLock = new();
3838

3939
private readonly Func<string?> _passwordProvider;
40-
private volatile bool _disposed;
40+
private int _disposedInt;
4141

4242
public const string DefaultVolumeLabel = "SimpleZipDrive";
4343
public const long DefaultMaxMemorySize = 512L * 1024 * 1024;
@@ -103,7 +103,7 @@ public ZipFileSystemCore(
103103
}
104104
}
105105

106-
public bool IsDisposed => _disposed;
106+
public bool IsDisposed => Volatile.Read(ref _disposedInt) != 0;
107107
public string ArchiveType { get; }
108108

109109
public string TempDirectoryPath { get; }
@@ -427,8 +427,15 @@ public List<EntryNode> ListDirectory(string normalizedPath)
427427
}
428428
}
429429

430-
foreach (var dirPathKey in _directoryCreationTimes.Keys)
430+
List<KeyValuePair<string, DateTime>> dirSnapshot;
431+
lock (_archiveLock)
432+
{
433+
dirSnapshot = _directoryCreationTimes.ToList();
434+
}
435+
436+
foreach (var dirKvp in dirSnapshot)
431437
{
438+
var dirPathKey = dirKvp.Key;
432439
if (dirPathKey.Equals(searchPrefix, StringComparison.OrdinalIgnoreCase)) continue;
433440
if (!dirPathKey.StartsWith(searchPrefix, StringComparison.OrdinalIgnoreCase)) continue;
434441

@@ -438,9 +445,13 @@ public List<EntryNode> ListDirectory(string normalizedPath)
438445
var name = dirPathKey.Split('/').LastOrDefault(static s => !string.IsNullOrEmpty(s));
439446
if (!string.IsNullOrEmpty(name) && seenFileNames.Add(name))
440447
{
441-
_directoryCreationTimes.TryGetValue(dirPathKey, out var ct);
442-
_directoryLastWriteTimes.TryGetValue(dirPathKey, out var lwt);
443-
_directoryLastAccessTimes.TryGetValue(dirPathKey, out var lat);
448+
DateTime ct, lwt, lat;
449+
lock (_archiveLock)
450+
{
451+
_directoryCreationTimes.TryGetValue(dirPathKey, out ct);
452+
_directoryLastWriteTimes.TryGetValue(dirPathKey, out lwt);
453+
_directoryLastAccessTimes.TryGetValue(dirPathKey, out lat);
454+
}
444455

445456
var implicitPath = searchPrefix + name;
446457
result.Add(new EntryNode
@@ -804,7 +815,13 @@ public void DumpEntries(int maxEntries = 100)
804815
if (_failedEntries.Count > 0)
805816
{
806817
DiagnosticLogger.Log(" --- Failed entries ---");
807-
foreach (var failed in _failedEntries)
818+
string[] failedSnapshot;
819+
lock (_archiveLock)
820+
{
821+
failedSnapshot = _failedEntries.ToArray();
822+
}
823+
824+
foreach (var failed in failedSnapshot)
808825
{
809826
DiagnosticLogger.Log($" [FAILED] {failed}");
810827
}
@@ -821,16 +838,11 @@ public void DumpEntries(int maxEntries = 100)
821838

822839
public void Dispose()
823840
{
824-
if (_disposed)
841+
if (Interlocked.Exchange(ref _disposedInt, 1) != 0)
825842
return;
826843

827844
DiagnosticLogger.LogHeader("ZipFs DISPOSE");
828845

829-
lock (_archiveLock)
830-
{
831-
_disposed = true;
832-
}
833-
834846
_archive.Dispose();
835847
_sourceArchiveStream.Dispose();
836848

SimpleZipDrive.Core/ZipFsHelpers.cs

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ namespace SimpleZipDrive.Core;
1111
public static class ZipFsHelpers
1212
{
1313
// Cache for compiled regex patterns to avoid recompilation overhead.
14-
// Uses a plain Dictionary guarded by a lock because all accesses are protected
15-
// by the same lock to ensure atomic check-evict-add semantics.
16-
private static readonly Dictionary<string, Regex> RegexCache = new();
14+
// Uses a Dictionary + LinkedList for LRU eviction: the LinkedList tracks
15+
// insertion/re-access order (most recent at the end), and when the cache
16+
// is full the oldest entry (first in LinkedList) is evicted.
17+
private static readonly Dictionary<string, (Regex Regex, LinkedListNode<string> Node)> RegexCache = new();
18+
private static readonly LinkedList<string> RegexLruOrder = new();
1719
private static readonly object RegexCacheLock = new();
1820
private const int MaxRegexCacheSize = 100; // Limit cache size to prevent unbounded growth
1921

@@ -233,20 +235,29 @@ internal static bool IsMatchSimple(string input, string pattern)
233235

234236
lock (RegexCacheLock)
235237
{
236-
if (RegexCache.TryGetValue(regexPattern, out var regex))
237-
return regex.IsMatch(input);
238+
if (RegexCache.TryGetValue(regexPattern, out var entry))
239+
{
240+
// Move to end of LRU list (most recently used)
241+
RegexLruOrder.Remove(entry.Node);
242+
var newNode = RegexLruOrder.AddLast(regexPattern);
243+
RegexCache[regexPattern] = (entry.Regex, newNode);
244+
return entry.Regex.IsMatch(input);
245+
}
238246

239247
if (RegexCache.Count >= MaxRegexCacheSize)
240248
{
241-
var oldestKey = RegexCache.Keys.FirstOrDefault();
242-
if (oldestKey != null)
249+
// Evict the least recently used (first in the linked list)
250+
var oldest = RegexLruOrder.First;
251+
if (oldest != null)
243252
{
244-
RegexCache.Remove(oldestKey);
253+
RegexLruOrder.RemoveFirst();
254+
RegexCache.Remove(oldest.Value);
245255
}
246256
}
247257

248258
var newRegex = new Regex(regexPattern, RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
249-
RegexCache[regexPattern] = newRegex;
259+
var lruNode = RegexLruOrder.AddLast(regexPattern);
260+
RegexCache[regexPattern] = (newRegex, lruNode);
250261
return newRegex.IsMatch(input);
251262
}
252263
}
@@ -270,4 +281,52 @@ internal static bool IsNameMatch(string name, string pattern)
270281

271282
return IsMatchSimple(name, pattern);
272283
}
284+
285+
private const int MaxVolumeLabelLength = 32;
286+
private static readonly char[] InvalidVolumeLabelChars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|'];
287+
288+
/// <summary>
289+
/// Sanitizes a string for use as a Windows volume label.
290+
/// Strips invalid characters, trims whitespace, truncates to 32 characters,
291+
/// and falls back to <see cref="ZipFileSystemCore.DefaultVolumeLabel"/> if the result is empty.
292+
/// </summary>
293+
internal static string SanitizeVolumeLabel(string? label)
294+
{
295+
if (string.IsNullOrWhiteSpace(label))
296+
return ZipFileSystemCore.DefaultVolumeLabel;
297+
298+
Span<char> buffer = stackalloc char[label.Length];
299+
var written = 0;
300+
301+
foreach (var ch in label)
302+
{
303+
if (Array.IndexOf(InvalidVolumeLabelChars, ch) < 0)
304+
{
305+
buffer[written++] = ch;
306+
}
307+
}
308+
309+
// Trim trailing spaces/dots (Windows trims these)
310+
while (written > 0 && (buffer[written - 1] == ' ' || buffer[written - 1] == '.'))
311+
{
312+
written--;
313+
}
314+
315+
// Truncate to NTFS limit
316+
if (written > MaxVolumeLabelLength)
317+
{
318+
written = MaxVolumeLabelLength;
319+
}
320+
321+
// Final trim in case truncation exposed trailing spaces/dots
322+
while (written > 0 && (buffer[written - 1] == ' ' || buffer[written - 1] == '.'))
323+
{
324+
written--;
325+
}
326+
327+
if (written == 0)
328+
return ZipFileSystemCore.DefaultVolumeLabel;
329+
330+
return new string(buffer[..written]);
331+
}
273332
}

SimpleZipDrive.Tests/WinFsp/WinFspMountServiceTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
using SimpleZipDrive.Core.Services;
21
using SimpleZipDrive_WinFsp.Services;
2+
using SimpleZipDrive.Core.Services;
33

44
namespace SimpleZipDrive.Tests.WinFsp;
55

SimpleZipDrive/Services/MountService.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,13 @@ public void Dispose()
163163
{
164164
}
165165

166+
// Give the driver time to finish pending callbacks before disposing resources
167+
Thread.Sleep(500);
168+
166169
_mountCancellation?.Dispose();
167170
_currentZipFs?.Dispose();
168171
_currentZipFs = null;
172+
CurrentArchivePath = null;
169173
GC.SuppressFinalize(this);
170174
}
171175

@@ -312,6 +316,7 @@ private async Task MountWithSpecifiedPointAsync(string archivePath, string mount
312316

313317
private async Task<bool> AttemptMountLifecycleAsync(string archivePath, string mountPoint, Dokan dokan, string archiveType)
314318
{
319+
_mountCancellation?.Dispose();
315320
_mountCancellation = new CancellationTokenSource();
316321

317322
try
@@ -331,7 +336,7 @@ private async Task<bool> AttemptMountLifecycleAsync(string archivePath, string m
331336

332337
try
333338
{
334-
var volumeLabel = Path.GetFileNameWithoutExtension(archivePath);
339+
var volumeLabel = ZipFsHelpers.SanitizeVolumeLabel(Path.GetFileNameWithoutExtension(archivePath));
335340
_currentZipFs = new ZipFs(
336341
fileStream,
337342
mountPoint,
@@ -412,19 +417,22 @@ private async Task<bool> AttemptMountLifecycleAsync(string archivePath, string m
412417
_loggingService.LogError($"Dokan error: {ex.Message}");
413418
ErrorLoggerStatic.ReportSilentException(ex, $"MountService.AttemptMountLifecycleAsync: DokanException mounting '{archivePath}' to '{mountPoint}'", true);
414419
ShowDokanDriverErrorDialog(ex.Message);
420+
CurrentArchivePath = null;
415421
return false;
416422
}
417423
catch (Exception ex) when (ex.Message.Contains("drive", StringComparison.OrdinalIgnoreCase) ||
418424
ex.Message.Contains("mount", StringComparison.OrdinalIgnoreCase))
419425
{
420426
_loggingService.LogError($"Mount error: {ex.Message}");
421427
ErrorLoggerStatic.ReportSilentException(ex, $"MountService.AttemptMountLifecycleAsync: Drive/mount error for '{archivePath}' to '{mountPoint}'", true);
428+
CurrentArchivePath = null;
422429
return false;
423430
}
424431
catch (Exception ex)
425432
{
426433
_loggingService.LogError($"Mount error: {ex.Message}");
427434
ErrorLoggerStatic.LogErrorSync(ex, $"MountService.AttemptMountLifecycleAsync: Error mounting archive '{archivePath}' to '{mountPoint}'");
435+
CurrentArchivePath = null;
428436
return false;
429437
}
430438
}

SimpleZipDrive_WinFsp/App.xaml.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Text;
55
using System.Threading.Channels;
66
using System.Windows;
7+
using SimpleZipDrive_WinFsp.Services;
78

89
namespace SimpleZipDrive_WinFsp;
910

SimpleZipDrive_WinFsp/GlobalUsings.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,4 @@
77
global using SimpleZipDrive.Core;
88
global using SimpleZipDrive.Core.Models;
99
global using SimpleZipDrive.Core.Services;
10-
global using SimpleZipDrive.Core.Views;
11-
global using SimpleZipDrive_WinFsp.Services;
10+
global using SimpleZipDrive.Core.Views;

0 commit comments

Comments
 (0)