using MareSynchronos.Interop; using MareSynchronos.MareConfiguration; using MareSynchronos.Utils; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Globalization; using System.Text; namespace MareSynchronos.FileCache; public sealed class FileCacheManager : IDisposable { public const string CsvSplit = "|"; private const string _cachePrefix = "{cache}"; private const string _penumbraPrefix = "{penumbra}"; private readonly MareConfigService _configService; private readonly string _csvPath; private readonly ConcurrentDictionary> _fileCaches = new(StringComparer.Ordinal); private readonly object _fileWriteLock = new(); private readonly IpcManager _ipcManager; private readonly ILogger _logger; public FileCacheManager(ILogger logger, IpcManager ipcManager, MareConfigService configService) { _logger = logger; _ipcManager = ipcManager; _configService = configService; _csvPath = Path.Combine(configService.ConfigurationDirectory, "FileCache.csv"); lock (_fileWriteLock) { try { if (File.Exists(CsvBakPath)) { File.Move(CsvBakPath, _csvPath, overwrite: true); } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to move BAK to ORG, deleting BAK"); try { if (File.Exists(CsvBakPath)) File.Delete(CsvBakPath); } catch (Exception ex1) { _logger.LogWarning(ex1, "Could not delete bak file"); } } } if (File.Exists(_csvPath)) { var entries = File.ReadAllLines(_csvPath); foreach (var entry in entries) { var splittedEntry = entry.Split(CsvSplit, StringSplitOptions.None); try { var hash = splittedEntry[0]; var path = splittedEntry[1]; var time = splittedEntry[2]; AddHashedFile(ReplacePathPrefixes(new FileCacheEntity(hash, path, time))); } catch (Exception) { _logger.LogWarning("Failed to initialize entry {entry}, ignoring", entry); } } } } private string CsvBakPath => _csvPath + ".bak"; public FileCacheEntity? CreateCacheEntry(string path) { _logger.LogTrace("Creating cache entry for {path}", path); FileInfo fi = new(path); if (!fi.Exists) return null; var fullName = fi.FullName.ToLowerInvariant(); if (!fullName.Contains(_configService.Current.CacheFolder.ToLowerInvariant(), StringComparison.Ordinal)) return null; string prefixedPath = fullName.Replace(_configService.Current.CacheFolder.ToLowerInvariant(), _cachePrefix + "\\", StringComparison.Ordinal).Replace("\\\\", "\\", StringComparison.Ordinal); return CreateFileCacheEntity(fi, prefixedPath); } public FileCacheEntity? CreateFileEntry(string path) { _logger.LogTrace("Creating file entry for {path}", path); FileInfo fi = new(path); if (!fi.Exists) return null; var fullName = fi.FullName.ToLowerInvariant(); if (!fullName.Contains(_ipcManager.PenumbraModDirectory!.ToLowerInvariant(), StringComparison.Ordinal)) return null; string prefixedPath = fullName.Replace(_ipcManager.PenumbraModDirectory!.ToLowerInvariant(), _penumbraPrefix + "\\", StringComparison.Ordinal).Replace("\\\\", "\\", StringComparison.Ordinal); return CreateFileCacheEntity(fi, prefixedPath); } public void Dispose() { _logger.LogTrace("Disposing {type}", GetType()); WriteOutFullCsv(); GC.SuppressFinalize(this); } public List GetAllFileCaches() => _fileCaches.Values.SelectMany(v => v).ToList(); public string GetCacheFilePath(string hash, bool isTemporaryFile) { return Path.Combine(_configService.Current.CacheFolder, hash + (isTemporaryFile ? ".tmp" : string.Empty)); } public FileCacheEntity? GetFileCacheByHash(string hash) { if (_fileCaches.TryGetValue(hash, out var hashes)) { var item = hashes.FirstOrDefault(); if (item != null) return GetValidatedFileCache(item); } return null; } public FileCacheEntity? GetFileCacheByPath(string path) { var cleanedPath = path.Replace("/", "\\", StringComparison.OrdinalIgnoreCase).ToLowerInvariant().Replace(_ipcManager.PenumbraModDirectory!.ToLowerInvariant(), "", StringComparison.OrdinalIgnoreCase); var entry = _fileCaches.SelectMany(v => v.Value).FirstOrDefault(f => f.ResolvedFilepath.EndsWith(cleanedPath, StringComparison.OrdinalIgnoreCase)); if (entry == null) { _logger.LogDebug("Found no entries for {path}", cleanedPath); return CreateFileEntry(path); } var validatedCacheEntry = GetValidatedFileCache(entry); return validatedCacheEntry; } public void RemoveHashedFile(FileCacheEntity? fileCache) { if (fileCache == null) return; if (_fileCaches.TryGetValue(fileCache.Hash, out var caches)) { caches?.RemoveAll(c => string.Equals(c.PrefixedFilePath, fileCache.PrefixedFilePath, StringComparison.Ordinal)); if (_fileCaches.Count == 0) { _fileCaches.Remove(fileCache.Hash, out _); } } } public void UpdateHashedFile(FileCacheEntity fileCache) { _logger.LogTrace("Updating hash for {path}", fileCache.ResolvedFilepath); fileCache.Hash = Crypto.GetFileHash(fileCache.ResolvedFilepath); fileCache.LastModifiedDateTicks = new FileInfo(fileCache.ResolvedFilepath).LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture); RemoveHashedFile(fileCache); AddHashedFile(fileCache); } public (FileState, FileCacheEntity) ValidateFileCacheEntity(FileCacheEntity fileCache) { fileCache = ReplacePathPrefixes(fileCache); FileInfo fi = new(fileCache.ResolvedFilepath); if (!fi.Exists) { return (FileState.RequireDeletion, fileCache); } if (!string.Equals(fi.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture), fileCache.LastModifiedDateTicks, StringComparison.Ordinal)) { return (FileState.RequireUpdate, fileCache); } return (FileState.Valid, fileCache); } public void WriteOutFullCsv() { StringBuilder sb = new(); foreach (var entry in _fileCaches.SelectMany(k => k.Value).OrderBy(f => f.PrefixedFilePath, StringComparer.OrdinalIgnoreCase)) { sb.AppendLine(entry.CsvEntry); } if (File.Exists(_csvPath)) { File.Copy(_csvPath, CsvBakPath, overwrite: true); } lock (_fileWriteLock) { try { File.WriteAllText(_csvPath, sb.ToString()); File.Delete(CsvBakPath); } catch { File.WriteAllText(CsvBakPath, sb.ToString()); } } } private void AddHashedFile(FileCacheEntity fileCache) { if (!_fileCaches.TryGetValue(fileCache.Hash, out var entries)) { _fileCaches[fileCache.Hash] = entries = new(); } entries.Add(fileCache); } private FileCacheEntity? CreateFileCacheEntity(FileInfo fileInfo, string prefixedPath, string? hash = null) { hash ??= Crypto.GetFileHash(fileInfo.FullName); var entity = new FileCacheEntity(hash, prefixedPath, fileInfo.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture)); entity = ReplacePathPrefixes(entity); AddHashedFile(entity); lock (_fileWriteLock) { File.AppendAllLines(_csvPath, new[] { entity.CsvEntry }); } var result = GetFileCacheByPath(fileInfo.FullName); _logger.LogDebug("Creating file cache for {name} success: {success}", fileInfo.FullName, (result != null)); return result; } private FileCacheEntity? GetValidatedFileCache(FileCacheEntity fileCache) { var resulingFileCache = ReplacePathPrefixes(fileCache); resulingFileCache = Validate(resulingFileCache); return resulingFileCache; } private FileCacheEntity ReplacePathPrefixes(FileCacheEntity fileCache) { if (fileCache.PrefixedFilePath.StartsWith(_penumbraPrefix, StringComparison.OrdinalIgnoreCase)) { fileCache.SetResolvedFilePath(fileCache.PrefixedFilePath.Replace(_penumbraPrefix, _ipcManager.PenumbraModDirectory, StringComparison.Ordinal)); } else if (fileCache.PrefixedFilePath.StartsWith(_cachePrefix, StringComparison.OrdinalIgnoreCase)) { fileCache.SetResolvedFilePath(fileCache.PrefixedFilePath.Replace(_cachePrefix, _configService.Current.CacheFolder, StringComparison.Ordinal)); } return fileCache; } private FileCacheEntity? Validate(FileCacheEntity fileCache) { var file = new FileInfo(fileCache.ResolvedFilepath); if (!file.Exists) { RemoveHashedFile(fileCache); return null; } if (!string.Equals(file.LastWriteTimeUtc.Ticks.ToString(), fileCache.LastModifiedDateTicks, StringComparison.Ordinal)) { UpdateHashedFile(fileCache); } return fileCache; } }