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 _monthFingerprintCache = new(StringComparer.Ordinal); private readonly object _vaultIoLock = new(); public string GetMonthlyVaultFileName(DateTime date) => date.ToString("yyyy-MM") + ".vault"; public bool LoadAllVaults(string password, string vaultDirectory, string dataDirectory) { EnsureRequiredArguments(password, vaultDirectory, dataDirectory); lock (_vaultIoLock) { _monthFingerprintCache.Clear(); PrepareDataDirectory(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; 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 { // Legacy file cleanup should never block loading. } continue; } anyVaultFiles = true; try { var encrypted = File.ReadAllBytes(vaultFile); var decryptedZip = _crypto.DecryptData(encrypted, password); ExtractZipContent(decryptedZip, dataDirectory); anyDecrypted = true; } catch (CryptographicException) { // Wrong password for this vault file; continue trying others. } catch { // Non-password vault read/decrypt/extract error; continue loading others. } } if (!anyDecrypted && anyVaultFiles) return false; return true; } } public bool SaveCurrentMonthVault(string password, string vaultDirectory, string dataDirectory, DateTime now) { EnsureRequiredArguments(password, vaultDirectory, dataDirectory); lock (_vaultIoLock) { Directory.CreateDirectory(vaultDirectory); 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(); if (filesInMonth.Count == 0) return false; var currentFingerprint = ComputeMonthFingerprint(filesInMonth); if (_monthFingerprintCache.TryGetValue(monthKey, out var cachedFingerprint) && string.Equals(cachedFingerprint, currentFingerprint, StringComparison.Ordinal)) { return false; } SaveMonth(password, monthKey, filesInMonth, vaultDirectory); return true; } } public void RebuildAllVaults(string password, string vaultDirectory, string dataDirectory) { EnsureRequiredArguments(password, vaultDirectory, dataDirectory); lock (_vaultIoLock) { Directory.CreateDirectory(vaultDirectory); if (!Directory.Exists(dataDirectory)) return; var monthlyFiles = new Dictionary>(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); } } public void ClearDataDirectory(string dataDirectory) { if (string.IsNullOrWhiteSpace(dataDirectory)) throw new ArgumentException("Data directory is required.", nameof(dataDirectory)); lock (_vaultIoLock) { PrepareDataDirectory(dataDirectory); _monthFingerprintCache.Clear(); } } private static void PrepareDataDirectory(string dataDirectory) { DeleteDirectoryWithRetries(dataDirectory); Directory.CreateDirectory(dataDirectory); } private static void DeleteDirectoryWithRetries(string dataDirectory, int retries = 5, int delayMs = 200) { if (!Directory.Exists(dataDirectory)) return; for (var attempt = 0; attempt < retries; attempt++) { try { Directory.Delete(dataDirectory, recursive: true); return; } catch (IOException) when (attempt < retries - 1) { Thread.Sleep(delayMs); } catch (UnauthorizedAccessException) when (attempt < retries - 1) { Thread.Sleep(delayMs); } } // Final attempt should throw with the underlying exception if deletion still fails. Directory.Delete(dataDirectory, recursive: true); } 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 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 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 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); } } }