journal/Journal.Core/Services/VaultStorageService.cs
Jacob Schmidt 8a29bd4bd1 style: apply dotnet format (round 2)
Co-Authored-By: Warp <agent@warp.dev>
2026-02-23 21:39:57 -06:00

274 lines
10 KiB
C#

using System.IO.Compression;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace Journal.Core.Services;
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();
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<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);
}
}
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<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);
}
}
}