From 2909500bb88c4b11d0721a01ab51b368bf7cd6af Mon Sep 17 00:00:00 2001 From: EonaCat Date: Thu, 8 Oct 2026 21:33:54 +0200 Subject: [PATCH] Updated --- EonaCat.LogStack.Server/Server.cs | 9 +- EonaCat.LogStack.sln | 19 ++ .../Features/LogReplayEngine.cs | 12 +- .../EonaCatLoggerCore/Flows/AuditFlow.cs | 38 ++-- .../Flows/EncryptedFileFlow.cs | 11 +- .../EonaCatLoggerCore/Flows/FileFlow.cs | 184 ++++++++++-------- .../Flows/RotatingFileFlow.cs | 4 +- .../EonaCatLoggerCore/Flows/StatusFlow.cs | 3 +- .../EonaCatLoggerCore/Flows/TcpFlow.cs | 2 +- .../Helpers/DirectoryPermissionHelper.cs | 2 +- EonaCat.LogStack/Server.cs | 6 +- README.md | 3 + .../EonaCat.LogStack.FileDeletionTests.csproj | 15 ++ .../FileDeletionTests.cs | 148 ++++++++++++++ 14 files changed, 342 insertions(+), 114 deletions(-) create mode 100644 Testers/EonaCat.LogStack.FileDeletionTests/EonaCat.LogStack.FileDeletionTests.csproj create mode 100644 Testers/EonaCat.LogStack.FileDeletionTests/FileDeletionTests.cs diff --git a/EonaCat.LogStack.Server/Server.cs b/EonaCat.LogStack.Server/Server.cs index c84aa32..dfa0c44 100644 --- a/EonaCat.LogStack.Server/Server.cs +++ b/EonaCat.LogStack.Server/Server.cs @@ -36,7 +36,7 @@ namespace EonaCat.LogStack.Server _maxLogDirectorySize = maxLogDirectorySize; } - protected virtual Task ProcessLogAsync(string logData) + protected virtual async Task ProcessLogAsync(string logData) { string logsRootDirectory = "logs"; @@ -67,7 +67,12 @@ namespace EonaCat.LogStack.Server // After processing log, check directory size and clean up if needed CleanUpOldLogs(); - return File.AppendAllTextAsync(logFilePath, logData + Environment.NewLine); + using (var stream = new FileStream(logFilePath, FileMode.Append, FileAccess.Write, FileShare.Read | FileShare.Delete, 4096, FileOptions.Asynchronous)) + using (var writer = new StreamWriter(stream)) + { + await writer.WriteLineAsync(logData).ConfigureAwait(false); + await writer.FlushAsync().ConfigureAwait(false); + } } private void CleanUpOldLogs() diff --git a/EonaCat.LogStack.sln b/EonaCat.LogStack.sln index 8603db3..1a1429c 100644 --- a/EonaCat.LogStack.sln +++ b/EonaCat.LogStack.sln @@ -24,6 +24,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EonaCat.LogStack.Status", "EonaCat.LogStack.Status\EonaCat.LogStack.Status.csproj", "{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Testers", "Testers", "{A930DAA9-4165-776A-E33D-6691CB934F50}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EonaCat.LogStack.FileDeletionTests", "Testers\EonaCat.LogStack.FileDeletionTests\EonaCat.LogStack.FileDeletionTests.csproj", "{25A49D02-5463-4662-B064-55293976DF0A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -130,10 +134,25 @@ Global {34C47EBC-BB59-0A5C-9D93-416E4F3D7816}.Release|x64.Build.0 = Release|Any CPU {34C47EBC-BB59-0A5C-9D93-416E4F3D7816}.Release|x86.ActiveCfg = Release|Any CPU {34C47EBC-BB59-0A5C-9D93-416E4F3D7816}.Release|x86.Build.0 = Release|Any CPU + {25A49D02-5463-4662-B064-55293976DF0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {25A49D02-5463-4662-B064-55293976DF0A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {25A49D02-5463-4662-B064-55293976DF0A}.Debug|x64.ActiveCfg = Debug|Any CPU + {25A49D02-5463-4662-B064-55293976DF0A}.Debug|x64.Build.0 = Debug|Any CPU + {25A49D02-5463-4662-B064-55293976DF0A}.Debug|x86.ActiveCfg = Debug|Any CPU + {25A49D02-5463-4662-B064-55293976DF0A}.Debug|x86.Build.0 = Debug|Any CPU + {25A49D02-5463-4662-B064-55293976DF0A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {25A49D02-5463-4662-B064-55293976DF0A}.Release|Any CPU.Build.0 = Release|Any CPU + {25A49D02-5463-4662-B064-55293976DF0A}.Release|x64.ActiveCfg = Release|Any CPU + {25A49D02-5463-4662-B064-55293976DF0A}.Release|x64.Build.0 = Release|Any CPU + {25A49D02-5463-4662-B064-55293976DF0A}.Release|x86.ActiveCfg = Release|Any CPU + {25A49D02-5463-4662-B064-55293976DF0A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {25A49D02-5463-4662-B064-55293976DF0A} = {A930DAA9-4165-776A-E33D-6691CB934F50} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B01183F3-D85E-45FB-9749-DA281F465A0F} EndGlobalSection diff --git a/EonaCat.LogStack/EonaCat.LogStack/Features/LogReplayEngine.cs b/EonaCat.LogStack/EonaCat.LogStack/Features/LogReplayEngine.cs index e6538fa..8f4849a 100644 --- a/EonaCat.LogStack/EonaCat.LogStack/Features/LogReplayEngine.cs +++ b/EonaCat.LogStack/EonaCat.LogStack/Features/LogReplayEngine.cs @@ -134,7 +134,11 @@ namespace EonaCat.LogStack.Features DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }); - File.WriteAllText(filePath, json); + using (var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read | FileShare.Delete)) + using (var writer = new StreamWriter(stream)) + { + writer.Write(json); + } } } @@ -329,7 +333,11 @@ namespace EonaCat.LogStack.Features { WriteIndented = true }); - File.WriteAllText(filePath, json); + using (var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read | FileShare.Delete)) + using (var writer = new StreamWriter(stream)) + { + writer.Write(json); + } } public static LogReplayScenario Load(string filePath) diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/AuditFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/AuditFlow.cs index 86a64a0..8075825 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/Flows/AuditFlow.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/AuditFlow.cs @@ -37,7 +37,7 @@ namespace EonaCat.LogStack.Flows /// This creates a hash-chain so any deletion or modification of a past /// entry invalidates all subsequent hashes, making tampering detectable. /// - /// The file is opened with FileShare.Read only (no concurrent writers). + /// The file permits external readers and deletion, but no concurrent writers. /// The flow is synchronous-by-design: audit entries must land on disk /// before the method returns, so blocks until /// the entry is flushed. @@ -53,8 +53,8 @@ namespace EonaCat.LogStack.Flows private readonly bool _includeProperties; private readonly object _writeLock = new object(); - private readonly FileStream _stream; - private readonly StreamWriter _writer; + private FileStream _stream; + private StreamWriter _writer; private long _sequence; private string _previousHash; @@ -117,19 +117,23 @@ namespace EonaCat.LogStack.Flows string date = DateTime.UtcNow.ToString("yyyyMMdd"); _filePath = Path.Combine(directory, $"{filePrefix}_{Environment.MachineName}_{date}.audit"); - // Exclusive write access + OpenFile(); + } + + private void OpenFile() + { _stream = new FileStream( _filePath, FileMode.Append, FileAccess.Write, - FileShare.Read, // allow external readers, but no other writers + FileShare.Read | FileShare.Delete, bufferSize: 4096, FileOptions.WriteThrough); // WriteThrough = no OS cache, hits disk immediately _writer = new StreamWriter(_stream, Encoding.UTF8) { AutoFlush = true }; // Derive starting hash from the last line already in the file (for continuity) - _previousHash = ReadLastHash(directory, filePrefix, date); + _previousHash = ReadLastHash(_filePath); _sequence = CountExistingLines(_filePath); } @@ -153,7 +157,10 @@ namespace EonaCat.LogStack.Flows string previousHash = new string('0', HashLength); long expectedSeq = 1; - foreach (string raw in File.ReadLines(filePath, Encoding.UTF8)) + using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var reader = new StreamReader(stream, Encoding.UTF8); + string raw; + while ((raw = reader.ReadLine()) != null) { if (string.IsNullOrWhiteSpace(raw) || raw.StartsWith("#")) { @@ -252,6 +259,7 @@ namespace EonaCat.LogStack.Flows public override async ValueTask DisposeAsync() { IsEnabled = false; + await base.DisposeAsync().ConfigureAwait(false); lock (_writeLock) { try { _writer.Flush(); } catch { } @@ -259,13 +267,19 @@ namespace EonaCat.LogStack.Flows try { _writer.Dispose(); } catch { } try { _stream.Dispose(); } catch { } } - await base.DisposeAsync().ConfigureAwait(false); } private void WriteEntry(LogEvent log) { lock (_writeLock) { + if (!File.Exists(_filePath)) + { + _writer.Dispose(); + _stream.Dispose(); + OpenFile(); + } + long seq = Interlocked.Increment(ref _sequence); var sb = new StringBuilder(256); @@ -356,10 +370,8 @@ namespace EonaCat.LogStack.Flows } } - private static string ReadLastHash(string directory, string prefix, string date) + private static string ReadLastHash(string path) { - string path = Path.Combine(directory, $"{prefix}_{Environment.MachineName}_{date}.audit"); - if (!File.Exists(path)) { return new string('0', HashLength); @@ -368,7 +380,7 @@ namespace EonaCat.LogStack.Flows string lastLine = null; // Open file with FileShare.ReadWrite to allow reading while it's being written to - using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) using (var reader = new StreamReader(fileStream, Encoding.UTF8)) { // Read lines from the file @@ -402,7 +414,7 @@ namespace EonaCat.LogStack.Flows long count = 0; // Open the file with FileShare.ReadWrite to allow concurrent read/write access - using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) using (var reader = new StreamReader(fileStream, Encoding.UTF8)) { while (!reader.EndOfStream) diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/EncryptedFileFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/EncryptedFileFlow.cs index 91feb42..69ac436 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/Flows/EncryptedFileFlow.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/EncryptedFileFlow.cs @@ -571,7 +571,7 @@ namespace EonaCat.LogStack.Flows try { - using (FileStream source = File.OpenRead(encryptedPath)) + using (FileStream source = new FileStream(encryptedPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) { byte[] magic = new byte[4]; ReadExact(source, magic, 4); @@ -604,7 +604,8 @@ namespace EonaCat.LogStack.Flows } using (ICryptoTransform dec = aes.CreateDecryptor()) - using (StreamWriter out_ = new StreamWriter(outputPath, false, Encoding.UTF8)) + using (FileStream output = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.Read | FileShare.Delete)) + using (StreamWriter out_ = new StreamWriter(output, Encoding.UTF8)) { byte[] buffer = new byte[4]; while (source.Position < source.Length) @@ -847,7 +848,7 @@ namespace EonaCat.LogStack.Flows ? DateTime.Now.Date : DateTime.UtcNow.Date; - if (_currentStream == null || _currentDate != today || _currentSize > _maxFileSize) + if (_currentStream == null || _currentDate != today || _currentSize > _maxFileSize || !File.Exists(_currentPath)) { if (_currentStream != null) { @@ -934,7 +935,7 @@ namespace EonaCat.LogStack.Flows bool isNew = !File.Exists(_currentPath) || new FileInfo(_currentPath).Length == 0; - _currentStream = new FileStream(_currentPath, FileMode.Append, FileAccess.Write, FileShare.Read, 65536, _durableWrites ? FileOptions.WriteThrough : FileOptions.None); + _currentStream = new FileStream(_currentPath, FileMode.Append, FileAccess.Write, FileShare.Read | FileShare.Delete, 65536, _durableWrites ? FileOptions.WriteThrough : FileOptions.None); byte[] salt = new byte[SaltSize]; byte[] iv = new byte[IvSize]; @@ -956,7 +957,7 @@ namespace EonaCat.LogStack.Flows else { // Re-read header so we continue the same key/IV session - using (FileStream hdr = File.OpenRead(_currentPath)) + using (FileStream hdr = new FileStream(_currentPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) { hdr.Seek(4, SeekOrigin.Begin); ReadExact(hdr, salt, SaltSize); diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/FileFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/FileFlow.cs index 473107b..4508e97 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/Flows/FileFlow.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/FileFlow.cs @@ -64,6 +64,7 @@ namespace EonaCat.LogStack.Flows private readonly BlockingCollection _queue; private readonly ConcurrentQueue _compressionQueue = new ConcurrentQueue(); + private readonly object _archiveCompressionLock = new object(); private readonly CancellationTokenSource _cts = new CancellationTokenSource(); private volatile bool _isDisposing; // Track disposal state to prevent accessing disposed objects private readonly Thread _writerThread; @@ -1385,7 +1386,7 @@ namespace EonaCat.LogStack.Flows const string footer = ""; const int tailSize = 512; - using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite)) + using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete)) { if (fs.Length < footer.Length) { @@ -1801,62 +1802,66 @@ namespace EonaCat.LogStack.Flows private string ArchiveFile(string filePath) { - try + lock (_archiveCompressionLock) { - string dir = Path.GetDirectoryName(filePath); - string fileName = Path.GetFileName(filePath); - int extIdx = fileName.LastIndexOf(_fileExtension, StringComparison.OrdinalIgnoreCase); - string baseName = extIdx >= 0 ? fileName.Substring(0, extIdx) : fileName; - int maxFiles = _retention.MaxRolledFiles > 0 ? _retention.MaxRolledFiles : 999; - - for (int i = maxFiles - 1; i >= 1; i--) + DrainCompressionQueue(); + try { - string src = Path.Combine(dir, baseName + "_" + i + _fileExtension); - string srcGz = src + ".gz"; - string dst = Path.Combine(dir, baseName + "_" + (i + 1) + _fileExtension); - string dstGz = dst + ".gz"; + string dir = Path.GetDirectoryName(filePath); + string fileName = Path.GetFileName(filePath); + int extIdx = fileName.LastIndexOf(_fileExtension, StringComparison.OrdinalIgnoreCase); + string baseName = extIdx >= 0 ? fileName.Substring(0, extIdx) : fileName; + int maxFiles = _retention.MaxRolledFiles > 0 ? _retention.MaxRolledFiles : 999; - if (File.Exists(srcGz)) + for (int i = maxFiles - 1; i >= 1; i--) { - if (File.Exists(dstGz)) + string src = Path.Combine(dir, baseName + "_" + i + _fileExtension); + string srcGz = src + ".gz"; + string dst = Path.Combine(dir, baseName + "_" + (i + 1) + _fileExtension); + string dstGz = dst + ".gz"; + + if (File.Exists(srcGz)) { - File.Delete(dstGz); + if (File.Exists(dstGz)) + { + File.Delete(dstGz); + } + File.Move(srcGz, dstGz); } - File.Move(srcGz, dstGz); + + if (!File.Exists(src)) + { + continue; + } + + if (File.Exists(dst)) + { + File.Delete(dst); + } + File.Move(src, dst); } - if (!File.Exists(src)) + string archive = Path.Combine(dir, baseName + "_1" + _fileExtension); + if (File.Exists(archive)) { - continue; + File.Delete(archive); } - if (File.Exists(dst)) + File.Move(filePath, archive); + + if (_compressionFormat != CompressionFormat.None) { - File.Delete(dst); + _compressionQueue.Enqueue(archive); + _compressionSignal.Release(1); } - File.Move(src, dst); - } - string archive = Path.Combine(dir, baseName + "_1" + _fileExtension); - if (File.Exists(archive)) + return archive; + } + catch (Exception ex) { - File.Delete(archive); + OnException?.Invoke(this, "[FileFlow] Archive error '" + filePath + "': " + ex.Message); + return null; } - - File.Move(filePath, archive); - - if (_compressionFormat != CompressionFormat.None) - { - _compressionQueue.Enqueue(archive); - _compressionSignal.Release(1); - } - - return archive; - } - catch (Exception ex) - { - OnException?.Invoke(this, "[FileFlow] Archive error '" + filePath + "': " + ex.Message); - return null; } } @@ -1891,12 +1896,15 @@ namespace EonaCat.LogStack.Flows private void DrainCompressionQueue() { - while (_compressionQueue.TryDequeue(out string path)) + lock (_archiveCompressionLock) { - try { CompressFile(path); } - catch (Exception ex) + while (_compressionQueue.TryDequeue(out string path)) { - OnException?.Invoke(this, "[FileFlow] Compress error '" + path + "': " + ex.Message); + try { CompressFile(path); } + catch (Exception ex) + { + OnException?.Invoke(this, "[FileFlow] Compress error '" + path + "': " + ex.Message); + } } } } @@ -1919,8 +1927,8 @@ namespace EonaCat.LogStack.Flows const int bufSize = 65536; byte[] buffer = new byte[bufSize]; - using (FileStream src = File.OpenRead(path)) - using (FileStream dst = File.Create(outPath)) + using (FileStream src = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete)) + using (FileStream dst = new FileStream(outPath, FileMode.Create, FileAccess.Write, FileShare.Delete)) using (GZipStream gz = new GZipStream(dst, CompressionLevel.Optimal)) { int read; @@ -2063,54 +2071,58 @@ namespace EonaCat.LogStack.Flows private void ApplyRetention() { - try + lock (_archiveCompressionLock) { - DirectoryInfo dir = new DirectoryInfo(_directory); - if (!dir.Exists) + DrainCompressionQueue(); + try { - return; - } - - FileInfo[] files = dir.GetFiles("*" + _fileExtension) - .Concat(dir.GetFiles("*" + _fileExtension + ".gz")) - .OrderByDescending(f => f.LastWriteTimeUtc) - .ToArray(); - - long totalBytes = 0; - int kept = 0; - int keptCompressed = 0; - - foreach (FileInfo f in files) - { - bool isCompressed = f.Extension.Equals(".gz", StringComparison.OrdinalIgnoreCase); - bool tooOld = _retention.MaxAgeDays > 0 - && (DateTime.UtcNow - f.LastWriteTimeUtc).TotalDays > _retention.MaxAgeDays; - bool tooMany = _retention.MaxRolledFiles > 0 && kept >= _retention.MaxRolledFiles; - bool tooManyCompressed = isCompressed - && _retention.MaxCompressedFiles > 0 - && keptCompressed >= _retention.MaxCompressedFiles; - bool tooLarge = _retention.MaxTotalArchiveBytes > 0 - && totalBytes + f.Length > _retention.MaxTotalArchiveBytes; - bool directoryTooLarge = totalBytes + f.Length > _maxDirectorySize; - - if (tooOld || tooMany || tooManyCompressed || tooLarge || directoryTooLarge) + DirectoryInfo dir = new DirectoryInfo(_directory); + if (!dir.Exists) { - try { f.Delete(); } catch { /* ignore */ } + return; } - else + + FileInfo[] files = dir.GetFiles("*" + _fileExtension) + .Concat(dir.GetFiles("*" + _fileExtension + ".gz")) + .OrderByDescending(f => f.LastWriteTimeUtc) + .ToArray(); + + long totalBytes = 0; + int kept = 0; + int keptCompressed = 0; + + foreach (FileInfo f in files) { - totalBytes += f.Length; - kept++; - if (isCompressed) + bool isCompressed = f.Extension.Equals(".gz", StringComparison.OrdinalIgnoreCase); + bool tooOld = _retention.MaxAgeDays > 0 + && (DateTime.UtcNow - f.LastWriteTimeUtc).TotalDays > _retention.MaxAgeDays; + bool tooMany = _retention.MaxRolledFiles > 0 && kept >= _retention.MaxRolledFiles; + bool tooManyCompressed = isCompressed + && _retention.MaxCompressedFiles > 0 + && keptCompressed >= _retention.MaxCompressedFiles; + bool tooLarge = _retention.MaxTotalArchiveBytes > 0 + && totalBytes + f.Length > _retention.MaxTotalArchiveBytes; + bool directoryTooLarge = totalBytes + f.Length > _maxDirectorySize; + + if (tooOld || tooMany || tooManyCompressed || tooLarge || directoryTooLarge) { - keptCompressed++; + try { f.Delete(); } catch { /* ignore */ } + } + else + { + totalBytes += f.Length; + kept++; + if (isCompressed) + { + keptCompressed++; + } } } } - } - catch (Exception ex) - { - OnException?.Invoke(this, "[FileFlow] Retention error: " + ex.Message); + catch (Exception ex) + { + OnException?.Invoke(this, "[FileFlow] Retention error: " + ex.Message); + } } } diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/RotatingFileFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/RotatingFileFlow.cs index f89d750..dcd5cf2 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/Flows/RotatingFileFlow.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/RotatingFileFlow.cs @@ -91,7 +91,7 @@ namespace EonaCat.LogStack.Flows { var targetFileName = GetCurrentFileName(); - if (_currentWriter != null && _currentFileName == targetFileName && !ShouldRotate()) + if (_currentWriter != null && _currentFileName == targetFileName && !ShouldRotate() && File.Exists(targetFileName)) { return; } @@ -107,7 +107,7 @@ namespace EonaCat.LogStack.Flows _currentFileName, FileMode.Append, FileAccess.Write, - FileShare.Read, + FileShare.Read | FileShare.Delete, 4096, FileOptions.SequentialScan); diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/StatusFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/StatusFlow.cs index c636760..8623e2e 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/Flows/StatusFlow.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/StatusFlow.cs @@ -294,7 +294,8 @@ namespace ServiceMonitoring try { string logFilePath = Path.Combine(_statusDirectory, "status_log.txt"); - using (var writer = new StreamWriter(logFilePath, append: true)) + using (var stream = new FileStream(logFilePath, FileMode.Append, FileAccess.Write, FileShare.Read | FileShare.Delete)) + using (var writer = new StreamWriter(stream)) { writer.WriteLine(statusMessage); } diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/TcpFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/TcpFlow.cs index 4b588e0..2933006 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/Flows/TcpFlow.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/TcpFlow.cs @@ -106,7 +106,7 @@ public sealed class TcpFlow : FlowBase await EnsureConnectedAsync(cancellationToken); // Send file in chunks - using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read)) + using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) { byte[] buffer = new byte[4096]; int bytesRead; diff --git a/EonaCat.LogStack/Helpers/DirectoryPermissionHelper.cs b/EonaCat.LogStack/Helpers/DirectoryPermissionHelper.cs index 1674e59..ab4df59 100644 --- a/EonaCat.LogStack/Helpers/DirectoryPermissionHelper.cs +++ b/EonaCat.LogStack/Helpers/DirectoryPermissionHelper.cs @@ -39,7 +39,7 @@ namespace EonaCat.LogStack.Helpers var testFile = Path.Combine(dirPath, ".permission_test_" + Guid.NewGuid().ToString("N")); try { - using (var fs = File.Create(testFile, 1, FileOptions.DeleteOnClose)) + using (var fs = new FileStream(testFile, FileMode.Create, FileAccess.Write, FileShare.Delete, 1, FileOptions.DeleteOnClose)) { fs.WriteByte(0); } diff --git a/EonaCat.LogStack/Server.cs b/EonaCat.LogStack/Server.cs index eeb18d8..9452b08 100644 --- a/EonaCat.LogStack/Server.cs +++ b/EonaCat.LogStack/Server.cs @@ -698,7 +698,11 @@ namespace EonaCat.LogStack.Server filePath = $"{basePath}_{idx++}.log"; } - File.AppendAllText(filePath, logData + Environment.NewLine); + using (var stream = new FileStream(filePath, FileMode.Append, FileAccess.Write, FileShare.Read | FileShare.Delete)) + using (var writer = new StreamWriter(stream)) + { + writer.WriteLine(logData); + } Interlocked.Increment(ref Metrics.TotalWritten); LogWritten?.Invoke(logData); diff --git a/README.md b/README.md index 7b56f95..67f730e 100644 --- a/README.md +++ b/README.md @@ -1083,6 +1083,9 @@ await using var logger = new LogBuilder("Production") Encrypt sensitive logs with AES encryption and password protection. +Encrypted log files can be deleted while logging is active, including on Windows. +The next log entry recreates the file with a fresh encryption header; deleted entries are not restored. + ### Writing Encrypted Logs ```csharp diff --git a/Testers/EonaCat.LogStack.FileDeletionTests/EonaCat.LogStack.FileDeletionTests.csproj b/Testers/EonaCat.LogStack.FileDeletionTests/EonaCat.LogStack.FileDeletionTests.csproj new file mode 100644 index 0000000..949372a --- /dev/null +++ b/Testers/EonaCat.LogStack.FileDeletionTests/EonaCat.LogStack.FileDeletionTests.csproj @@ -0,0 +1,15 @@ + + + net8.0 + enable + enable + false + true + + + + + + + + \ No newline at end of file diff --git a/Testers/EonaCat.LogStack.FileDeletionTests/FileDeletionTests.cs b/Testers/EonaCat.LogStack.FileDeletionTests/FileDeletionTests.cs new file mode 100644 index 0000000..45eadea --- /dev/null +++ b/Testers/EonaCat.LogStack.FileDeletionTests/FileDeletionTests.cs @@ -0,0 +1,148 @@ +using EonaCat.LogStack.Core; +using EonaCat.LogStack.Flows; +using Xunit; + +namespace EonaCat.LogStack.FileDeletionTests; + +public sealed class FileDeletionTests : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), "EonaCat.FileDeletionTests", Guid.NewGuid().ToString("N")); + + public FileDeletionTests() + { + Directory.CreateDirectory(_directory); + } + + [Fact] + public async Task EncryptedLogCanBeDeletedAndRecreatedWithAValidNewHeader() + { + await using var flow = new EncryptedFileFlow(_directory, "test-password", template: "{message}").WithDurableWrites(); + await WriteEncryptedAsync(flow, "before-deletion"); + string path = Assert.Single(Directory.GetFiles(_directory, "*.eona")); + byte[] originalHeader = ReadHeader(path); + + Assert.Throws(() => File.Open(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete)); + File.Delete(path); + Assert.False(File.Exists(path)); + + await WriteEncryptedAsync(flow, "after-deletion"); + Assert.True(File.Exists(path)); + Assert.False(originalHeader.SequenceEqual(ReadHeader(path))); + string outputPath = Path.Combine(_directory, "decrypted.txt"); + Assert.True(EncryptedFileFlow.DecryptToFile(path, outputPath, "test-password")); + string contents = File.ReadAllText(outputPath); + Assert.Contains("after-deletion", contents); + Assert.DoesNotContain("before-deletion", contents); + Assert.Equal(0, flow.GetTotalErrors()); + } + + [Fact] + public async Task EncryptedLogCanResumeAnExistingFile() + { + byte[] originalHeader; + string path; + await using (var first = new EncryptedFileFlow(_directory, "test-password", template: "{message}").WithDurableWrites()) + { + await WriteEncryptedAsync(first, "first-session"); + path = Assert.Single(Directory.GetFiles(_directory, "*.eona")); + originalHeader = ReadHeader(path); + } + + await using var second = new EncryptedFileFlow(_directory, "test-password", template: "{message}").WithDurableWrites(); + await WriteEncryptedAsync(second, "second-session"); + Assert.Equal(originalHeader, ReadHeader(path)); + string outputPath = Path.Combine(_directory, "decrypted.txt"); + Assert.True(EncryptedFileFlow.DecryptToFile(path, outputPath, "test-password")); + string contents = File.ReadAllText(outputPath); + Assert.Contains("first-session", contents); + Assert.Contains("second-session", contents); + } + + [Fact] + public async Task RotatingLogCanBeDeletedAndRecreated() + { + await using var flow = new RotatingFileFlow(_directory); + Assert.Equal(WriteResult.Success, await flow.BlastAsync(CreateEvent("before-deletion"))); + string path = Assert.Single(Directory.GetFiles(_directory)); + File.Delete(path); + Assert.False(File.Exists(path)); + + Assert.Equal(WriteResult.Success, await flow.BlastAsync(CreateEvent("after-deletion"))); + string contents = ReadSharedText(path); + Assert.Contains("after-deletion", contents); + Assert.DoesNotContain("before-deletion", contents); + } + + [Fact] + public async Task AuditLogCanBeDeletedAndRecreatedWithAValidHashChain() + { + var flow = new AuditFlow(_directory); + try + { + await flow.BlastAsync(CreateEvent("before-deletion")); + Assert.Throws(() => File.Open(flow.FilePath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete)); + File.Delete(flow.FilePath); + Assert.False(File.Exists(flow.FilePath)); + + await flow.BlastAsync(CreateEvent("after-deletion")); + await flow.BlastAsync(CreateEvent("next-entry")); + var liveVerification = AuditFlow.Verify(flow.FilePath); + Assert.True(liveVerification.ok, liveVerification.reason); + } + finally + { + await flow.DisposeAsync(); + } + + var verification = AuditFlow.Verify(flow.FilePath); + Assert.True(verification.ok, verification.reason); + string contents = File.ReadAllText(flow.FilePath); + Assert.Contains("after-deletion", contents); + Assert.Contains("next-entry", contents); + Assert.DoesNotContain("before-deletion", contents); + } + + [Fact] + public async Task PlainLogCanBeDeletedWhileOpen() + { + await using var flow = new FileFlow(_directory); + Assert.Equal(WriteResult.Success, await flow.BlastAsync(CreateEvent("before-deletion"))); + Assert.True(SpinWait.SpinUntil(() => flow.GetStats().BytesWritten > 0 || flow.GetTotalErrors() > 0, TimeSpan.FromSeconds(10)), "Plain writer did not complete."); + Assert.Equal(0, flow.GetTotalErrors()); + await flow.FlushAsync(); + string path = Assert.Single(Directory.GetFiles(_directory, "*.log")); + File.Delete(path); + Assert.False(File.Exists(path)); + } + + private static LogEvent CreateEvent(string message) => new LogEventBuilder().WithMessage(message).Build(); + + private static async Task WriteEncryptedAsync(EncryptedFileFlow flow, string message) + { + long previousBytes = flow.GetStats().BytesWritten; + Assert.Equal(WriteResult.Success, await flow.BlastAsync(CreateEvent(message))); + Assert.True(SpinWait.SpinUntil(() => flow.GetStats().BytesWritten > previousBytes || flow.GetTotalErrors() > 0, TimeSpan.FromSeconds(10)), "Encrypted writer did not complete."); + Assert.Equal(0, flow.GetTotalErrors()); + await flow.FlushAsync(); + } + + private static byte[] ReadHeader(string path) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + byte[] header = new byte[52]; + stream.ReadExactly(header); + return header; + } + + private static string ReadSharedText(string path) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + public void Dispose() + { + Directory.Delete(_directory, recursive: true); + } +} \ No newline at end of file