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