Move entry/template persistence to SQLCipher-backed repository

This commit is contained in:
Jacob Schmidt 2026-02-28 17:20:03 -06:00
parent c9c61a279e
commit 88d808e8b2
5 changed files with 337 additions and 311 deletions

View File

@ -0,0 +1,139 @@
using Journal.Core.Services.Database;
using Journal.Core.Services.Entries;
namespace Journal.Core.Repositories;
public sealed class SqliteEntryFileRepository(IDatabaseSessionService session) : IEntryFileRepository
{
private const string EntryPrefix = "db://entry/";
private const string TemplatePrefix = "db://template/";
private readonly IDatabaseSessionService _session = session;
public IReadOnlyList<string> ListMarkdownFiles(string dataDirectory)
{
var conn = _session.GetConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT file_name
FROM entry_documents
ORDER BY file_name;
""";
var paths = new List<string>();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
if (reader.IsDBNull(0))
continue;
var fileName = reader.GetString(0);
paths.Add(ToCanonicalPath(fileName));
}
return paths;
}
public string ReadFile(string filePath)
{
var fileName = ResolveFileName(filePath);
var conn = _session.GetConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT content
FROM entry_documents
WHERE file_name = @fileName;
""";
cmd.Parameters.AddWithValue("@fileName", fileName);
var result = cmd.ExecuteScalar();
if (result is null || result is DBNull)
throw new FileNotFoundException($"Entry file not found: {fileName}");
return Convert.ToString(result) ?? "";
}
public void WriteFile(string filePath, string content)
{
var fileName = ResolveFileName(filePath);
var isTemplate = EntryFileNaming.IsTemplateFileName(fileName) ? 1 : 0;
var conn = _session.GetConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO entry_documents (guid, file_name, content, is_template, updated_at)
VALUES (@guid, @fileName, @content, @isTemplate, @updatedAt)
ON CONFLICT(file_name) DO UPDATE SET
content = excluded.content,
is_template = excluded.is_template,
updated_at = excluded.updated_at;
""";
cmd.Parameters.AddWithValue("@guid", Guid.NewGuid().ToString("D"));
cmd.Parameters.AddWithValue("@fileName", fileName);
cmd.Parameters.AddWithValue("@content", content ?? "");
cmd.Parameters.AddWithValue("@isTemplate", isTemplate);
cmd.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
cmd.ExecuteNonQuery();
}
public void AppendFile(string filePath, string content)
{
var fileName = ResolveFileName(filePath);
var existing = FileExists(fileName) ? ReadFile(fileName) : "";
WriteFile(fileName, existing + content);
}
public bool FileExists(string filePath)
{
var fileName = ResolveFileName(filePath);
var conn = _session.GetConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT 1
FROM entry_documents
WHERE file_name = @fileName
LIMIT 1;
""";
cmd.Parameters.AddWithValue("@fileName", fileName);
return cmd.ExecuteScalar() is not null;
}
public string GetFullPath(string filePath)
{
var fileName = ResolveFileName(filePath);
return ToCanonicalPath(fileName);
}
public string GetFileName(string filePath) => ResolveFileName(filePath);
public string GetFileNameWithoutExtension(string filePath)
=> Path.GetFileNameWithoutExtension(ResolveFileName(filePath));
public void EnsureDirectory(string path) { }
public void DeleteFile(string filePath)
{
var fileName = ResolveFileName(filePath);
var conn = _session.GetConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM entry_documents WHERE file_name = @fileName;";
cmd.Parameters.AddWithValue("@fileName", fileName);
cmd.ExecuteNonQuery();
}
private static string ResolveFileName(string input)
{
if (string.IsNullOrWhiteSpace(input))
return "";
if (input.StartsWith(EntryPrefix, StringComparison.OrdinalIgnoreCase))
return Uri.UnescapeDataString(input[EntryPrefix.Length..]);
if (input.StartsWith(TemplatePrefix, StringComparison.OrdinalIgnoreCase))
return Uri.UnescapeDataString(input[TemplatePrefix.Length..]);
var fileName = Path.GetFileName(input);
return fileName ?? input.Trim();
}
private static string ToCanonicalPath(string fileName)
{
var prefix = EntryFileNaming.IsTemplateFileName(fileName) ? TemplatePrefix : EntryPrefix;
return prefix + Uri.EscapeDataString(fileName);
}
}

View File

@ -58,7 +58,7 @@ public static class ServiceCollectionExtensions
message: $"Python speech sidecar unavailable: {ex.Message}");
}
});
services.AddSingleton<IEntryFileRepository, DiskEntryFileRepository>();
services.AddSingleton<IEntryFileRepository, SqliteEntryFileRepository>();
services.AddSingleton<IEntryFileService, EntryFileService>();
services.AddSingleton<IListRepository, SqliteListRepository>();
services.AddSingleton<IListService, ListService>();

View File

@ -14,7 +14,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
private static readonly Lock SqliteInitLock = new();
private static bool _sqliteInitialized;
private static readonly IReadOnlyList<string> RequiredSchemaTables =
["entries", "sections", "fragments", "tags", "fragment_tags", "lists", "todo_lists", "todo_items"];
["entries", "sections", "fragments", "tags", "fragment_tags", "lists", "todo_lists", "todo_items", "entry_documents"];
private readonly IJournalConfigService _config = config;
@ -120,6 +120,16 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
sort_order INTEGER DEFAULT 0,
FOREIGN KEY (list_id) REFERENCES todo_lists (id)
);
""",
["entry_documents"] = """
CREATE TABLE IF NOT EXISTS entry_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT UNIQUE,
file_name TEXT NOT NULL UNIQUE,
content TEXT NOT NULL,
is_template INTEGER NOT NULL DEFAULT 0,
updated_at TEXT
);
"""
};
}

View File

@ -1,19 +1,23 @@
using Journal.Core.Dtos;
using Journal.Core.Repositories;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace Journal.Core.Services.Entries;
public class EntrySearchService : IEntrySearchService
public class EntrySearchService(IEntryFileRepository repo) : IEntrySearchService
{
private readonly IEntryFileRepository _repo = repo;
private readonly Lock _cacheLock = new();
private readonly Dictionary<string, CachedEntry> _entryCache = new(StringComparer.Ordinal);
public Task<IReadOnlyList<EntrySearchResultDto>> SearchEntriesAsync(EntrySearchRequestDto request)
{
ArgumentNullException.ThrowIfNull(request);
if (string.IsNullOrWhiteSpace(request.DataDirectory))
throw new ArgumentException("Data directory is required.", nameof(request.DataDirectory));
if (!Directory.Exists(request.DataDirectory))
return Task.FromResult<IReadOnlyList<EntrySearchResultDto>>([]);
var hasQuery = !string.IsNullOrWhiteSpace(request.Query);
var query = request.Query?.Trim() ?? "";
var hasSectionFilter = !string.IsNullOrWhiteSpace(request.Section);
@ -31,17 +35,20 @@ public class EntrySearchService : IEntrySearchService
if (startDate.HasValue && endDate.HasValue && startDate.Value > endDate.Value)
throw new ArgumentException("startDate cannot be after endDate.");
var currentFiles = _repo.ListMarkdownFiles(request.DataDirectory)
.OrderBy(Path.GetFileName, StringComparer.Ordinal)
.ToArray();
var currentFileSet = new HashSet<string>(currentFiles, StringComparer.Ordinal);
var results = new List<EntrySearchResultDto>();
foreach (var filePath in Directory.GetFiles(request.DataDirectory, "*.md")
.OrderBy(Path.GetFileName, StringComparer.Ordinal))
foreach (var filePath in currentFiles)
{
var fileName = Path.GetFileName(filePath);
var fileName = _repo.GetFileName(filePath);
if (EntryFileNaming.IsTemplateFileName(fileName))
continue;
var fileStem = Path.GetFileNameWithoutExtension(filePath);
var rawContent = File.ReadAllText(filePath);
var entry = JournalParser.ParseJournalContent(rawContent, fileStem);
var cached = GetOrBuildCachedEntry(filePath);
var entry = cached.Result.Entry;
if (startDate.HasValue || endDate.HasValue)
{
@ -57,7 +64,7 @@ public class EntrySearchService : IEntrySearchService
var contentMatch = true;
if (hasQuery)
{
var haystack = hasSectionFilter ? entry.GetSection(section) : entry.RawContent;
var haystack = hasSectionFilter ? GetSection(entry, section) : entry.RawContent;
contentMatch = haystack.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0;
}
if (!contentMatch)
@ -76,12 +83,103 @@ public class EntrySearchService : IEntrySearchService
if (!checkboxMatch)
continue;
results.Add(new EntrySearchResultDto(fileName, entry.ToDto()));
results.Add(cached.Result);
}
RemoveStaleCacheEntries(currentFileSet);
return Task.FromResult<IReadOnlyList<EntrySearchResultDto>>(results);
}
private CachedEntry GetOrBuildCachedEntry(string filePath)
{
var diskSignature = TryGetDiskFileSignature(filePath);
if (diskSignature is not null)
{
lock (_cacheLock)
{
if (_entryCache.TryGetValue(filePath, out var cached) &&
cached.Signature == diskSignature.Value)
{
return cached;
}
}
}
var fileName = _repo.GetFileName(filePath);
var fileStem = _repo.GetFileNameWithoutExtension(filePath);
var rawContent = _repo.ReadFile(filePath);
var signature = diskSignature ?? BuildContentSignature(rawContent);
lock (_cacheLock)
{
if (_entryCache.TryGetValue(filePath, out var cached) &&
cached.Signature == signature)
{
return cached;
}
}
var entry = JournalParser.ParseJournalContent(rawContent, fileStem).ToDto();
var built = new CachedEntry(signature, new EntrySearchResultDto(fileName, entry));
lock (_cacheLock)
{
_entryCache[filePath] = built;
}
return built;
}
private static FileSignature? TryGetDiskFileSignature(string filePath)
{
if (filePath.StartsWith("db://", StringComparison.OrdinalIgnoreCase))
return null;
if (!File.Exists(filePath))
return null;
var info = new FileInfo(filePath);
return new FileSignature(info.Length, info.LastWriteTimeUtc.Ticks, null);
}
private static FileSignature BuildContentSignature(string content)
{
var bytes = Encoding.UTF8.GetBytes(content ?? "");
var hash = Convert.ToHexString(SHA256.HashData(bytes));
return new FileSignature(bytes.Length, 0, hash);
}
private void RemoveStaleCacheEntries(HashSet<string> currentFileSet)
{
lock (_cacheLock)
{
if (_entryCache.Count == 0)
return;
var staleKeys = _entryCache.Keys
.Where(path => !currentFileSet.Contains(path))
.ToArray();
foreach (var key in staleKeys)
_entryCache.Remove(key);
}
}
private static string GetSection(JournalEntryDto entry, string sectionTitle)
{
if (string.IsNullOrWhiteSpace(sectionTitle))
return "";
foreach (var (key, value) in entry.Sections)
{
if (string.Equals(key, sectionTitle, StringComparison.OrdinalIgnoreCase))
return string.Join("\n", value.Content);
}
return "";
}
private static HashSet<string> NormalizeSet(IReadOnlyList<string>? values)
{
if (values is null || values.Count == 0)
@ -108,4 +206,7 @@ public class EntrySearchService : IEntrySearchService
throw new ArgumentException($"Invalid {argumentName} value. Expected yyyy-MM-dd.");
}
private readonly record struct FileSignature(long Length, long LastWriteUtcTicks, string? ContentHash);
private sealed record CachedEntry(FileSignature Signature, EntrySearchResultDto Result);
}

View File

@ -1,17 +1,16 @@
using System.Diagnostics;
using System.IO.Compression;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace Journal.Core.Services.Vault;
public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageService
{
private readonly IVaultCryptoService _crypto = crypto;
private readonly Dictionary<string, string> _monthFingerprintCache = new(StringComparer.Ordinal);
private readonly object _vaultIoLock = new();
private const string DatabaseVaultPrefix = "_db_";
private const string DatabaseVaultSuffix = ".vault";
public string GetMonthlyVaultFileName(DateTime date) => date.ToString("yyyy-MM") + ".vault";
public bool LoadAllVaults(string password, string vaultDirectory, string dataDirectory)
@ -20,64 +19,11 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ
lock (_vaultIoLock)
{
_monthFingerprintCache.Clear();
PrepareDataDirectory(dataDirectory);
Directory.CreateDirectory(dataDirectory);
if (!Directory.Exists(vaultDirectory))
return true;
var vaultFiles = Directory.GetFiles(vaultDirectory, "*.vault")
.OrderBy(Path.GetFileName, StringComparer.Ordinal)
.ToArray();
if (vaultFiles.Length == 0)
return true;
// Restore database vault files first
RestoreDatabaseVaults(password, vaultDirectory, dataDirectory);
var anyDecrypted = false;
var anyVaultFiles = false;
foreach (var vaultFile in vaultFiles)
{
var fileName = Path.GetFileName(vaultFile);
if (string.Equals(fileName, "_init_vault.vault", StringComparison.OrdinalIgnoreCase))
{
try
{
File.Delete(vaultFile);
}
catch (Exception ex)
{
Debug.WriteLine($"[VaultStorageService] Failed to delete legacy vault file {fileName}: {ex.Message}");
}
continue;
}
if (IsReservedVaultFile(fileName))
continue;
anyVaultFiles = true;
try
{
var encrypted = File.ReadAllBytes(vaultFile);
var decryptedZip = _crypto.DecryptData(encrypted, password);
ExtractZipContent(decryptedZip, dataDirectory);
anyDecrypted = true;
}
catch (CryptographicException)
{
Debug.WriteLine($"[VaultStorageService] Decryption failed for {fileName} (likely wrong password)");
}
catch (Exception ex)
{
Debug.WriteLine($"[VaultStorageService] Failed to load vault {fileName}: {ex.GetType().Name} - {ex.Message}");
}
}
if (!anyDecrypted && anyVaultFiles)
return false;
return true;
return RestoreDatabaseVaults(password, vaultDirectory, dataDirectory);
}
}
@ -91,28 +37,8 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ
if (!Directory.Exists(dataDirectory))
return false;
var monthKey = now.ToString("yyyy-MM", CultureInfo.InvariantCulture);
var filesInMonth = Directory.GetFiles(dataDirectory, "*.md")
.Where(path => Path.GetFileNameWithoutExtension(path).StartsWith(monthKey, StringComparison.Ordinal))
.OrderBy(Path.GetFileName, StringComparer.Ordinal)
.ToList();
var savedMonth = false;
if (filesInMonth.Count > 0)
{
var currentFingerprint = ComputeMonthFingerprint(filesInMonth);
if (!_monthFingerprintCache.TryGetValue(monthKey, out var cachedFingerprint) ||
!string.Equals(cachedFingerprint, currentFingerprint, StringComparison.Ordinal))
{
SaveMonth(password, monthKey, filesInMonth, vaultDirectory);
savedMonth = true;
}
}
// Also persist custom-named entries alongside the current month vault
SaveCustomEntries(password, vaultDirectory, dataDirectory);
return savedMonth;
SaveDatabaseVaults(password, vaultDirectory, dataDirectory);
return true;
}
}
@ -126,27 +52,6 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ
if (!Directory.Exists(dataDirectory))
return;
var monthlyFiles = new Dictionary<string, List<string>>(StringComparer.Ordinal);
foreach (var filePath in Directory.GetFiles(dataDirectory, "*.md"))
{
var stem = Path.GetFileNameWithoutExtension(filePath);
if (!DateTime.TryParseExact(stem, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var fileDate))
continue;
var monthKey = fileDate.ToString("yyyy-MM", CultureInfo.InvariantCulture);
if (!monthlyFiles.TryGetValue(monthKey, out var files))
{
files = [];
monthlyFiles[monthKey] = files;
}
files.Add(filePath);
}
foreach (var (monthKey, filesInMonth) in monthlyFiles.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
SaveMonth(password, monthKey, filesInMonth, vaultDirectory);
SaveCustomEntries(password, vaultDirectory, dataDirectory);
SaveDatabaseVaults(password, vaultDirectory, dataDirectory);
}
}
@ -158,15 +63,76 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ
lock (_vaultIoLock)
{
PrepareDataDirectory(dataDirectory);
_monthFingerprintCache.Clear();
DeleteDirectoryWithRetries(dataDirectory);
Directory.CreateDirectory(dataDirectory);
}
}
private static void PrepareDataDirectory(string dataDirectory)
private void SaveDatabaseVaults(string password, string vaultDirectory, string dataDirectory)
{
DeleteDirectoryWithRetries(dataDirectory);
Directory.CreateDirectory(dataDirectory);
var dbFiles = Directory.GetFiles(dataDirectory, "*.db");
foreach (var dbPath in dbFiles)
{
try
{
var dbFileName = Path.GetFileName(dbPath);
var vaultFileName = $"{DatabaseVaultPrefix}{dbFileName}{DatabaseVaultSuffix}";
var vaultPath = Path.Combine(vaultDirectory, vaultFileName);
var dbBytes = File.ReadAllBytes(dbPath);
var encrypted = _crypto.EncryptData(dbBytes, password);
File.WriteAllBytes(vaultPath, encrypted);
}
catch (Exception ex)
{
Debug.WriteLine($"[VaultStorageService] Failed to save database vault for {Path.GetFileName(dbPath)}: {ex.Message}");
}
}
}
private bool RestoreDatabaseVaults(string password, string vaultDirectory, string dataDirectory)
{
var dbVaultFiles = Directory.GetFiles(vaultDirectory, $"{DatabaseVaultPrefix}*{DatabaseVaultSuffix}");
if (dbVaultFiles.Length == 0)
return true;
var anyRestored = false;
foreach (var vaultFile in dbVaultFiles)
{
try
{
var vaultFileName = Path.GetFileName(vaultFile);
var dbFileName = vaultFileName[DatabaseVaultPrefix.Length..^DatabaseVaultSuffix.Length];
if (string.IsNullOrWhiteSpace(dbFileName))
continue;
var encrypted = File.ReadAllBytes(vaultFile);
var dbBytes = _crypto.DecryptData(encrypted, password);
var targetPath = Path.Combine(dataDirectory, dbFileName);
File.WriteAllBytes(targetPath, dbBytes);
anyRestored = true;
}
catch (CryptographicException)
{
Debug.WriteLine($"[VaultStorageService] Database vault decryption failed for {Path.GetFileName(vaultFile)} (likely wrong password)");
}
catch (Exception ex)
{
Debug.WriteLine($"[VaultStorageService] Failed to restore database vault {Path.GetFileName(vaultFile)}: {ex.Message}");
}
}
return anyRestored;
}
private static void EnsureRequiredArguments(string password, string vaultDirectory, string dataDirectory)
{
if (string.IsNullOrWhiteSpace(password))
throw new ArgumentException("Password cannot be empty.", nameof(password));
if (string.IsNullOrWhiteSpace(vaultDirectory))
throw new ArgumentException("Vault directory is required.", nameof(vaultDirectory));
if (string.IsNullOrWhiteSpace(dataDirectory))
throw new ArgumentException("Data directory is required.", nameof(dataDirectory));
}
private static void DeleteDirectoryWithRetries(string dataDirectory, int retries = 5, int delayMs = 200)
@ -193,194 +159,4 @@ public class VaultStorageService(IVaultCryptoService crypto) : IVaultStorageServ
Directory.Delete(dataDirectory, recursive: true);
}
// ── Custom entries vault helpers ──────────────────────────────
private const string CustomEntriesVaultFileName = "_custom_entries.vault";
private List<string> GetCustomEntryFiles(string dataDirectory)
{
return Directory.GetFiles(dataDirectory, "*.md")
.Where(path =>
{
var stem = Path.GetFileNameWithoutExtension(path);
return !DateTime.TryParseExact(stem, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _);
})
.OrderBy(Path.GetFileName, StringComparer.Ordinal)
.ToList();
}
private void SaveCustomEntries(string password, string vaultDirectory, string dataDirectory)
{
var customFiles = GetCustomEntryFiles(dataDirectory);
var vaultPath = Path.Combine(vaultDirectory, CustomEntriesVaultFileName);
if (customFiles.Count == 0)
{
// Remove stale custom vault if no custom entries remain
if (File.Exists(vaultPath))
File.Delete(vaultPath);
return;
}
var zipBytes = CreateMonthlyArchive(customFiles);
var encrypted = _crypto.EncryptData(zipBytes, password);
File.WriteAllBytes(vaultPath, encrypted);
}
private static bool IsCustomEntriesVaultFile(string fileName)
=> string.Equals(fileName, CustomEntriesVaultFileName, StringComparison.OrdinalIgnoreCase);
// ── Database vault helpers ─────────────────────────────────────
private const string DatabaseVaultPrefix = "_db_";
private const string DatabaseVaultSuffix = ".vault";
private static bool IsReservedVaultFile(string fileName)
=> fileName.StartsWith(DatabaseVaultPrefix, StringComparison.OrdinalIgnoreCase);
private void SaveDatabaseVaults(string password, string vaultDirectory, string dataDirectory)
{
var dbFiles = Directory.GetFiles(dataDirectory, "*.db");
foreach (var dbPath in dbFiles)
{
try
{
var dbFileName = Path.GetFileName(dbPath);
var vaultFileName = $"{DatabaseVaultPrefix}{dbFileName}{DatabaseVaultSuffix}";
var vaultPath = Path.Combine(vaultDirectory, vaultFileName);
var dbBytes = File.ReadAllBytes(dbPath);
var encrypted = _crypto.EncryptData(dbBytes, password);
File.WriteAllBytes(vaultPath, encrypted);
Debug.WriteLine($"[VaultStorageService] Saved database vault: {vaultFileName}");
}
catch (Exception ex)
{
Debug.WriteLine($"[VaultStorageService] Failed to save database vault for {Path.GetFileName(dbPath)}: {ex.Message}");
}
}
}
private void RestoreDatabaseVaults(string password, string vaultDirectory, string dataDirectory)
{
var dbVaultFiles = Directory.GetFiles(vaultDirectory, $"{DatabaseVaultPrefix}*{DatabaseVaultSuffix}");
foreach (var vaultFile in dbVaultFiles)
{
try
{
var vaultFileName = Path.GetFileName(vaultFile);
// Strip prefix and suffix to get original DB filename
var dbFileName = vaultFileName[DatabaseVaultPrefix.Length..^DatabaseVaultSuffix.Length];
if (string.IsNullOrWhiteSpace(dbFileName))
continue;
var encrypted = File.ReadAllBytes(vaultFile);
var dbBytes = _crypto.DecryptData(encrypted, password);
var targetPath = Path.Combine(dataDirectory, dbFileName);
File.WriteAllBytes(targetPath, dbBytes);
Debug.WriteLine($"[VaultStorageService] Restored database from vault: {vaultFileName} → {dbFileName}");
}
catch (CryptographicException)
{
Debug.WriteLine($"[VaultStorageService] Database vault decryption failed for {Path.GetFileName(vaultFile)} (likely wrong password)");
}
catch (Exception ex)
{
Debug.WriteLine($"[VaultStorageService] Failed to restore database vault {Path.GetFileName(vaultFile)}: {ex.Message}");
}
}
}
private static void EnsureRequiredArguments(string password, string vaultDirectory, string dataDirectory)
{
if (string.IsNullOrWhiteSpace(password))
throw new ArgumentException("Password cannot be empty.", nameof(password));
if (string.IsNullOrWhiteSpace(vaultDirectory))
throw new ArgumentException("Vault directory is required.", nameof(vaultDirectory));
if (string.IsNullOrWhiteSpace(dataDirectory))
throw new ArgumentException("Data directory is required.", nameof(dataDirectory));
}
private void SaveMonth(string password, string monthKey, List<string> filesInMonth, string vaultDirectory)
{
var monthDate = DateTime.ParseExact(monthKey, "yyyy-MM", CultureInfo.InvariantCulture);
var monthlyVaultPath = Path.Combine(vaultDirectory, GetMonthlyVaultFileName(monthDate));
var zipBytes = CreateMonthlyArchive(filesInMonth);
var encryptedPayload = _crypto.EncryptData(zipBytes, password);
File.WriteAllBytes(monthlyVaultPath, encryptedPayload);
_monthFingerprintCache[monthKey] = ComputeMonthFingerprint(filesInMonth);
}
private static byte[] CreateMonthlyArchive(List<string> filesInMonth)
{
using var memoryStream = new MemoryStream();
using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var filePath in filesInMonth.OrderBy(Path.GetFileName, StringComparer.Ordinal))
{
var fileName = Path.GetFileName(filePath);
if (string.IsNullOrWhiteSpace(fileName))
continue;
var entry = archive.CreateEntry(fileName, CompressionLevel.Optimal);
using var entryStream = entry.Open();
using var sourceStream = File.OpenRead(filePath);
sourceStream.CopyTo(entryStream);
}
}
return memoryStream.ToArray();
}
private static string ComputeMonthFingerprint(List<string> files)
{
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
foreach (var filePath in files.OrderBy(Path.GetFileName, StringComparer.Ordinal))
{
var fileInfo = new FileInfo(filePath);
if (!fileInfo.Exists)
continue;
AppendUtf8(hash, fileInfo.Name);
AppendAscii(hash, fileInfo.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture));
AppendAscii(hash, fileInfo.Length.ToString(CultureInfo.InvariantCulture));
}
return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
}
private static void AppendUtf8(IncrementalHash hash, string value) => hash.AppendData(Encoding.UTF8.GetBytes(value));
private static void AppendAscii(IncrementalHash hash, string value) => hash.AppendData(Encoding.ASCII.GetBytes(value));
private static void ExtractZipContent(byte[] zipBytes, string dataDirectory)
{
using var stream = new MemoryStream(zipBytes);
using var archive = new ZipArchive(stream, ZipArchiveMode.Read);
var dataRoot = Path.GetFullPath(dataDirectory);
if (!dataRoot.EndsWith(Path.DirectorySeparatorChar))
dataRoot += Path.DirectorySeparatorChar;
foreach (var entry in archive.Entries)
{
if (string.IsNullOrEmpty(entry.Name))
continue;
var destinationPath = Path.GetFullPath(Path.Combine(dataDirectory, entry.FullName));
if (!destinationPath.StartsWith(dataRoot, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("Zip entry path escapes target data directory.");
var destinationDir = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(destinationDir))
Directory.CreateDirectory(destinationDir);
entry.ExtractToFile(destinationPath, overwrite: true);
}
}
}