diff --git a/TinfoilVibeServer.sln.DotSettings b/TinfoilVibeServer.sln.DotSettings index 355a6ee..ba97206 100644 --- a/TinfoilVibeServer.sln.DotSettings +++ b/TinfoilVibeServer.sln.DotSettings @@ -1,3 +1,4 @@  + IP NSP PFS \ No newline at end of file diff --git a/TinfoilVibeServer.sln.DotSettings.user b/TinfoilVibeServer.sln.DotSettings.user index dc418dc..103bd5f 100644 --- a/TinfoilVibeServer.sln.DotSettings.user +++ b/TinfoilVibeServer.sln.DotSettings.user @@ -3,28 +3,52 @@ True ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded <AssemblyExplorer> <Assembly Path="D:\Cloud\Git\TinfoilVibeServer\TinfoilVibeServer\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" /> -</AssemblyExplorer> \ No newline at end of file +</AssemblyExplorer> + + <SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> +</SessionState> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> +</SessionState> \ No newline at end of file diff --git a/TinfoilVibeServer/Authentication/AuthStore.cs b/TinfoilVibeServer/Authentication/AuthStore.cs index 2fd3518..b58b59e 100644 --- a/TinfoilVibeServer/Authentication/AuthStore.cs +++ b/TinfoilVibeServer/Authentication/AuthStore.cs @@ -1,53 +1,55 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; -using System.Linq; +using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text; using System.Text.Json; -using System.Threading.Tasks; -using TinfoilVibeServer.Models; -using LibHac.Common; -using LibHac.Common.Keys; +using TinfoilVibeServer.Services; +using TinfoilVibeServer.Utilities; namespace TinfoilVibeServer.Authentication; +public interface IAuthStore +{ + void Dispose(); + bool TryValidate(string username, + string password, + int? uid, + string ip, + out string? error); + + int IncrementFailed(string username, string ip); + bool IsIPBlacklisted(string ipAddress); +} + /// /// Holds authentication configuration and runtime state. /// It watches credentials.json for changes and updates the in‑memory /// user list (including the Verified flag) on the fly. /// -public sealed class AuthStore : IDisposable +public class AuthStore : IDisposable, IAuthStore { private readonly ILogger _logger; - public readonly AuthSettings Settings; + private readonly ConfigManager _configManager; public readonly ConcurrentDictionary Credentials = new(); public readonly ConcurrentDictionary> Fingerprints = new(); public readonly ConcurrentDictionary FailedAttempts = new(); - public readonly HashSet BlacklistIPs = new(); + private readonly HashSet BlacklistIPs = new(); private readonly object _sync = new(); private readonly FileSystemWatcher _credentialsWatcher; - public AuthStore(ILogger logger) + public AuthStore(ILogger logger, ConfigManager configManager) { _logger = logger; - Settings = new AuthSettings( - "credentials.json", - "fingerprints.json", - "blacklist.json", - 5 - ); - + _configManager = configManager; + LoadAll(); - var directoryName = Path.GetDirectoryName(Settings.CredentialsFile); + var directoryName = Path.GetDirectoryName(_configManager.Settings.CredentialsFile); _credentialsWatcher = new FileSystemWatcher { Path = (!string.IsNullOrEmpty(directoryName))?directoryName : AppContext.BaseDirectory, - Filter = Path.GetFileName(Settings.CredentialsFile), + Filter = Path.GetFileName(_configManager.Settings.CredentialsFile), NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes }; _credentialsWatcher.Changed += (_, _) => OnCredentialsChanged(); @@ -63,33 +65,45 @@ public sealed class AuthStore : IDisposable private void LoadAll() { - _logger.LogInformation("Loading authentication data from {File}", Settings.CredentialsFile); + _logger.LogInformation("Loading authentication data from {File}", _configManager.Settings.CredentialsFile); // credentials - if (File.Exists(Settings.CredentialsFile)) + if (File.Exists(_configManager.Settings.CredentialsFile)) { - var txt = File.ReadAllText(Settings.CredentialsFile); + var txt = File.ReadAllText(_configManager.Settings.CredentialsFile); var dict = JsonSerializer.Deserialize>(txt)!; foreach (var kv in dict) Credentials[kv.Key] = kv.Value; } + else + { + FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.CredentialsFile))); + } // fingerprints - if (File.Exists(Settings.FingerprintsFile)) + if (File.Exists(_configManager.Settings.FingerprintsFile)) { - var txt = File.ReadAllText(Settings.FingerprintsFile); + var txt = File.ReadAllText(_configManager.Settings.FingerprintsFile); var dict = JsonSerializer.Deserialize>>(txt)!; foreach (var kv in dict) Fingerprints[kv.Key] = kv.Value; } + else + { + FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.FingerprintsFile))); + } // blacklist - if (File.Exists(Settings.BlacklistFile)) + if (File.Exists(_configManager.Settings.BlacklistFile)) { - var txt = File.ReadAllText(Settings.BlacklistFile); + var txt = File.ReadAllText(_configManager.Settings.BlacklistFile); var arr = JsonSerializer.Deserialize(txt)!; foreach (var ip in arr) BlacklistIPs.Add(ip); } + else + { + FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.BlacklistFile))); + } _logger.LogInformation("Loaded {UserCount} users, {FpCount} fingerprints, {IpCount} IPs", Credentials.Count, Fingerprints.Count, BlacklistIPs.Count); } @@ -110,15 +124,15 @@ public sealed class AuthStore : IDisposable private void ReloadCredentials() { - if (!File.Exists(Settings.CredentialsFile)) + if (!File.Exists(_configManager.Settings.CredentialsFile)) { - _logger.LogError("Credentials file {File} does not exist", Settings.CredentialsFile); + _logger.LogError("Credentials file {File} does not exist", _configManager.Settings.CredentialsFile); return; } try { - var txt = File.ReadAllText(Settings.CredentialsFile); + var txt = File.ReadAllText(_configManager.Settings.CredentialsFile); var newDict = JsonSerializer.Deserialize>(txt)!; lock (_sync) @@ -150,7 +164,7 @@ public sealed class AuthStore : IDisposable } catch(Exception ex) { - _logger.LogError(ex, "Failed to reload credentials from {File}", Settings.CredentialsFile); + _logger.LogError(ex, "Failed to reload credentials from {File}", _configManager.Settings.CredentialsFile); // ignore – malformed JSON or IO error – keep old state } } @@ -159,8 +173,6 @@ public sealed class AuthStore : IDisposable #region Authentication logic - public bool IsBlacklisted(string ip) => BlacklistIPs.Contains(ip); - public bool TryValidate(string username, string password, int? uid, @@ -231,18 +243,25 @@ public sealed class AuthStore : IDisposable } } - private void IncrementFailed(string username, string ip) + public int IncrementFailed(string username, string ip) { var newCount = FailedAttempts.GetOrAdd(username, 0) + 1; - FailedAttempts[username] = newCount; + lock (_sync) + { + FailedAttempts[username] = newCount; + } _logger.LogInformation("Failed attempts for {Username} increased to {Count}", username, newCount); - if (newCount < Settings.MaxFailedAttempts) return; + if (newCount < _configManager.Settings.MaxFailedAttempts+1) return newCount; BlacklistIPs.Add(ip); PersistBlacklist(); - FailedAttempts[username] = 0; + lock (_sync) + { + FailedAttempts[username] = 0; + } _logger.LogWarning("IP {IP} blacklisted after {Count} failures", ip, newCount); + return newCount; } #endregion @@ -262,19 +281,37 @@ public sealed class AuthStore : IDisposable private void PersistCredentials() { var json = JsonSerializer.Serialize(Credentials, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(Settings.CredentialsFile, json); + File.WriteAllText(_configManager.Settings.CredentialsFile, json); } private void PersistFingerprints() { var json = JsonSerializer.Serialize(Fingerprints, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(Settings.FingerprintsFile, json); + File.WriteAllText(_configManager.Settings.FingerprintsFile, json); } private void PersistBlacklist() { var json = JsonSerializer.Serialize(BlacklistIPs.ToArray(), new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(Settings.BlacklistFile, json); + File.WriteAllText(_configManager.Settings.BlacklistFile, json); + } + + #endregion + + #region Blacklist helpers + public bool IsIPBlacklisted(string ipAddress) + { + return BlacklistIPs.Contains(ipAddress); + } + + public bool UnbanIp(string ipAddress) + { + return BlacklistIPs.Remove(ipAddress); + } + + public bool BlacklistActive() + { + return BlacklistIPs.Count > 0; } #endregion diff --git a/TinfoilVibeServer/Controllers/IndexController.cs b/TinfoilVibeServer/Controllers/IndexController.cs index c4d6301..f8c6387 100644 --- a/TinfoilVibeServer/Controllers/IndexController.cs +++ b/TinfoilVibeServer/Controllers/IndexController.cs @@ -37,6 +37,10 @@ public sealed class IndexController : ControllerBase /// public IActionResult Index() { + if (HttpContext.Request.Headers.CacheControl == "no-cache") + { + _snapshotService.RebuildSnapshot(); + } var index = _indexBuilderService.Build(); return Ok(index); @@ -69,7 +73,7 @@ public sealed class IndexController : ControllerBase var titleId = match.Groups["id"].Value.ToUpperInvariant(); // ---- 2️⃣ Find the file that contains this TitleId ------------ - var entry = _snapshotService.GetSnapshot() + var entry = _snapshotService.GetSnapshot().Files .FirstOrDefault(e => e.Title?.TitleId == titleId); if (entry == null) diff --git a/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs b/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs index 337c663..a0f245c 100644 --- a/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs +++ b/TinfoilVibeServer/Middleware/BasicAuthMiddleware.cs @@ -15,13 +15,13 @@ public sealed class BasicAuthMiddleware _next = next; } - public async Task InvokeAsync(HttpContext context, AuthStore store, ILogger logger) + public async Task InvokeAsync(HttpContext context, IAuthStore store, ILogger logger) { logger.LogInformation("Incoming request from {IP} – {Method} {Path}", context.Connection.RemoteIpAddress, context.Request.Method, context.Request.Path); var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; // 1) IP blacklist - if (store.IsBlacklisted(ip)) + if (store.IsIPBlacklisted(ip)) { logger.LogWarning("Blocked request from blacklisted IP {IP}", ip); context.Response.StatusCode = StatusCodes.Status403Forbidden; diff --git a/TinfoilVibeServer/Models/AppSettings.cs b/TinfoilVibeServer/Models/AppSettings.cs index 1d61982..d4bfd6d 100644 --- a/TinfoilVibeServer/Models/AppSettings.cs +++ b/TinfoilVibeServer/Models/AppSettings.cs @@ -7,8 +7,6 @@ public sealed record AppSettings( string[] RootDirectories, string[] WhitelistExtensions, string[] RomExtensions, - string SnapshotFile, - string SnapshotBackupFile, string CredentialsFile, string FingerprintsFile, string BlacklistFile, diff --git a/TinfoilVibeServer/Models/SnapshotOptions.cs b/TinfoilVibeServer/Models/SnapshotOptions.cs new file mode 100644 index 0000000..417060a --- /dev/null +++ b/TinfoilVibeServer/Models/SnapshotOptions.cs @@ -0,0 +1,88 @@ +using System.ComponentModel; + +namespace TinfoilVibeServer.Models; + +public sealed class SnapshotOptions : INotifyPropertyChanged +{ + private List _rootDirectories = new(); + public List RootDirectories + { + get => _rootDirectories; + set + { + if (_rootDirectories != value) + { + _rootDirectories = value; + OnPropertyChanged(nameof(RootDirectories)); + } + } + } + private List _whitelistExtensions = new(); + public List WhitelistExtensions + { + get => _whitelistExtensions; + set + { + if (_whitelistExtensions != value) + { + _whitelistExtensions = value; + OnPropertyChanged(nameof(_whitelistExtensions)); + } + } + } + private List _romExtensions = new(); + public List RomExtensions + { + get => _romExtensions; + set + { + if (_romExtensions != value) + { + _romExtensions = value; + OnPropertyChanged(nameof(_romExtensions)); + } + } + } + private TimeSpan _cacheTtl = TimeSpan.FromHours(1); + public TimeSpan CacheTtl + { + get => _cacheTtl; + set + { + if (_cacheTtl != value) + { + _cacheTtl = value; + OnPropertyChanged(nameof(CacheTtl)); + } + } + } + + private string _snapshotFile = "snapshot.json"; + + public string SnapshotFile + { + get => _snapshotFile; + set + { + if (string.Equals(_snapshotFile,value, StringComparison.InvariantCultureIgnoreCase)) return; + _snapshotFile = value; + OnPropertyChanged(nameof(SnapshotFile)); + } + } + + private string _snapshotBackupFile = "snapshot.bak"; + public string SnapshotBackupFile + { + get => _snapshotBackupFile; + set + { + if (string.Equals(_snapshotBackupFile,value, StringComparison.InvariantCultureIgnoreCase)) return; + _snapshotBackupFile = value; + OnPropertyChanged(nameof(SnapshotBackupFile)); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + private void OnPropertyChanged(string propertyName) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); +} \ No newline at end of file diff --git a/TinfoilVibeServer/Program.cs b/TinfoilVibeServer/Program.cs index a801d7a..b2196ba 100644 --- a/TinfoilVibeServer/Program.cs +++ b/TinfoilVibeServer/Program.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Options; using TinfoilVibeServer.Authentication; using TinfoilVibeServer.Middleware; using TinfoilVibeServer.Services; @@ -13,19 +14,20 @@ builder.Logging.AddDebug(); // ------------------------------------------------------------------- builder.Services.AddMemoryCache(); builder.Services.Configure(builder.Configuration.GetSection("TitleDb")); +builder.Services.Configure(builder.Configuration.GetSection("AuthSettings")); +builder.Services.Configure(builder.Configuration.GetSection("Snapshot")); builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => +builder.Services.AddSingleton(sp => { var config = sp.GetRequiredService(); - var logger = sp.GetRequiredService>(); + var logger = sp.GetRequiredService>(); var keySet = KeySetHolder.KeySet; // already loaded by ConfigManager return new NSPExtractor(keySet, logger); }); builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => - new AuthStore(sp.GetRequiredService>())); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(provider => provider.GetRequiredService()).AddHttpClient(); builder.Services.AddHostedService(provider => provider.GetRequiredService()); @@ -45,9 +47,9 @@ app.MapControllers(); // routes the /index.json & /download endpoints app.MapGet("/debug", () => new SnapshotService( - app.Services.GetRequiredService(), - app.Services.GetRequiredService(), - app.Services.GetRequiredService(), + app.Services.GetRequiredService>(), + app.Services.GetRequiredService(), + app.Services.GetRequiredService(), app.Services.GetRequiredService>()) .GetSnapshot()); app.Lifetime.ApplicationStarted.Register(() => diff --git a/TinfoilVibeServer/Services/ArchiveHandler.cs b/TinfoilVibeServer/Services/ArchiveHandler.cs index 4202f7f..434c0d0 100644 --- a/TinfoilVibeServer/Services/ArchiveHandler.cs +++ b/TinfoilVibeServer/Services/ArchiveHandler.cs @@ -8,17 +8,25 @@ using ZipArchive = SharpCompress.Archives.Zip.ZipArchive; namespace TinfoilVibeServer.Services; +public interface IArchiveHandler +{ + /// + /// Return TitleInfo if an embedded Nintendo archive is found; otherwise null. + /// + NcaMetadataWithHash? TryExtractTitleInfo(string filePath); +} + /// /// Tries to open a file as an archive and look for an embedded NSP/XCI. /// The extractor is injected so that the hash of the first stream can be accessed /// while the file is being read. /// -public sealed class ArchiveHandler +public sealed class ArchiveHandler : IArchiveHandler { - private readonly NSPExtractor _nspExtractor; + private readonly INSPExtractor _nspExtractor; private readonly ILogger _logger; - public ArchiveHandler(NSPExtractor nspExtractor, ILogger logger) + public ArchiveHandler(INSPExtractor nspExtractor, ILogger logger) { _nspExtractor = nspExtractor; _logger = logger; diff --git a/TinfoilVibeServer/Services/ConfigManager.cs b/TinfoilVibeServer/Services/ConfigManager.cs index 54ed4d2..9d75d05 100644 --- a/TinfoilVibeServer/Services/ConfigManager.cs +++ b/TinfoilVibeServer/Services/ConfigManager.cs @@ -9,7 +9,7 @@ namespace TinfoilVibeServer.Services; /// Reads the JSON config file on startup, watches it for changes, and also /// loads the KeySet from the file specified in the config. /// -public sealed class ConfigManager +public class ConfigManager { public AppSettings Settings { get; private set; } @@ -42,8 +42,6 @@ public sealed class ConfigManager RootDirectories: Array.Empty(), WhitelistExtensions: Array.Empty(), RomExtensions: Array.Empty(), - SnapshotFile: "index.tfl", - SnapshotBackupFile: "snapshot.bin", CredentialsFile: "credentials.json", FingerprintsFile: "fingerprints.json", BlacklistFile: "blacklist.json", diff --git a/TinfoilVibeServer/Services/IndexBuilderService.cs b/TinfoilVibeServer/Services/IndexBuilderService.cs index 26b26d9..9d32d6e 100644 --- a/TinfoilVibeServer/Services/IndexBuilderService.cs +++ b/TinfoilVibeServer/Services/IndexBuilderService.cs @@ -41,18 +41,23 @@ public sealed class IndexBuilderService: IHostedService // 1️⃣ Load cache if it exists var cached = LoadCache(); var snapshot = _snapshotService.GetSnapshot(); + if (string.IsNullOrEmpty(snapshot.Hash)) + { + _snapshotService.BuildSnapshot(); + snapshot = _snapshotService.GetSnapshot(); + } // 2️⃣ Re‑build only if the snapshot hash changed - string snapshotHash = ComputeSnapshotHash(snapshot); + string snapshotHash = ComputeSnapshotHash(snapshot.Files); if (cached != null && cached.SnapshotHash == snapshotHash) { _logger.LogInformation("Using cached index (snapshot hash={Hash}", snapshotHash); return cached.Index; } - _logger.LogInformation("Building index (snapshot size={Count})", snapshot.Count); + _logger.LogInformation("Building index (snapshot size={Count})", snapshot.Files.Count); // 3️⃣ Build new index from snapshot entries - var files = snapshot + var files = snapshot.Files .Where(e => e.Title != null) .Select(e => { @@ -63,7 +68,11 @@ public sealed class IndexBuilderService: IHostedService var vProcessed = e.Title.Version * 0x10000; var patchOrApp = e.Title.ContentMetaType == ContentMetaType.Application ? "Base" : "Update"; - var url = $"{name}[{titleId}][{vProcessed:X}][{patchOrApp}].nsp"; + if (e.Title.ContentMetaType == ContentMetaType.Patch) + { + name = _titleDb.TryGetTitle(e.Title.ApplicationTitle, out var appTitle) ? appTitle.Name : "Unknown"; + } + var url = $"{name}[{titleId}][{vProcessed}][{patchOrApp}].nsp"; return new FileDto(url, e.Size); }) diff --git a/TinfoilVibeServer/Services/NSPExtractor.cs b/TinfoilVibeServer/Services/NSPExtractor.cs index 5d4ca52..4bd4487 100644 --- a/TinfoilVibeServer/Services/NSPExtractor.cs +++ b/TinfoilVibeServer/Services/NSPExtractor.cs @@ -18,15 +18,30 @@ using TinfoilVibeServer.Models; namespace TinfoilVibeServer.Services { + public interface INSPExtractor + { + /// + /// Public convenience wrapper that opens the file on disk. + /// + NcaMetadataWithHash? ExtractFromFile(string filePath); + + /// + /// Core implementation – works on any seekable stream that contains a full NSP/XCI container. + /// + NcaMetadataWithHash? ExtractFromStream(Stream stream); + + string ExtractHashFromStream(Stream nspStream); + } + /// /// Extracts the TitleId, version, type *and* the SHA‑256 of the first NCA stream. /// - public sealed class NSPExtractor + public sealed class NSPExtractor : INSPExtractor { private readonly KeySet _keySet; - private readonly ILogger _logger; + private readonly ILogger _logger; - public NSPExtractor(KeySet keySet, ILogger logger) + public NSPExtractor(KeySet keySet, ILogger logger) { _keySet = keySet; _logger = logger; @@ -84,16 +99,16 @@ namespace TinfoilVibeServer.Services using var sha256 = SHA256.Create(); var hash = sha256.ComputeHash(ncaStream); - var contentMetaType = GetMetaDataType(nca); + var (contentMetaType,applicationTitle) = GetMetaDataType(nca); if (contentMetaType != null) - return new NcaMetadataWithHash(titleId, version, contentMetaType.Value, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant()); + return new NcaMetadataWithHash(titleId, applicationTitle.ToString("X16"), version, contentMetaType.Value, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant()); } return null; // no meta NCA found } - private static ContentMetaType? GetMetaDataType(Nca nca) + private static (ContentMetaType?,ulong) GetMetaDataType(Nca nca) { - if (nca.Header.ContentType != NcaContentType.Meta) return null; + if (nca.Header.ContentType != NcaContentType.Meta) return (null,0); using var openFileSystem = nca.OpenFileSystem(0, IntegrityCheckLevel.ErrorOnInvalid); foreach (var entry in openFileSystem.EnumerateEntries("*.cnmt", SearchOptions.Default)) { @@ -105,10 +120,11 @@ namespace TinfoilVibeServer.Services using var asStream = nacpFile.AsStream(); var cnmt = new Cnmt(asStream); - return cnmt.Type; + var applicationTitle = cnmt.ApplicationTitleId; + return (cnmt.Type,applicationTitle); } - return null; + return (null,0); } /// /// Quick sanity check that the stream looks like a PFS0 file system. @@ -186,14 +202,16 @@ namespace TinfoilVibeServer.Services public sealed class NcaMetadataWithHash { public string TitleId { get; } + public string ApplicationTitle { get; set; } public int Version { get; } public ContentMetaType ContentMetaType { get; set; } public string Hash { get; } - public NcaMetadataWithHash(string titleId, int version, ContentMetaType contentMetaType, string hash) + public NcaMetadataWithHash(string titleId, string applicationTitle, int version, ContentMetaType contentMetaType, string hash) { TitleId = titleId; + ApplicationTitle = applicationTitle; Version = version; ContentMetaType = contentMetaType; Hash = hash; diff --git a/TinfoilVibeServer/Services/SnapshotService.cs b/TinfoilVibeServer/Services/SnapshotService.cs index d3d4dd3..c787d1b 100644 --- a/TinfoilVibeServer/Services/SnapshotService.cs +++ b/TinfoilVibeServer/Services/SnapshotService.cs @@ -1,14 +1,17 @@ using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text.Json; +using Microsoft.Extensions.Options; using TinfoilVibeServer.Models; +using TinfoilVibeServer.Utilities; namespace TinfoilVibeServer.Services; public interface ISnapshotService { event EventHandler SnapshotRebuilt; // raised after a rebuild void RebuildSnapshot(); - IReadOnlyList GetSnapshot(); + SnapshotService.ROMSnapshot GetSnapshot(); + void BuildSnapshot(); } /// @@ -17,38 +20,54 @@ public interface ISnapshotService /// public sealed class SnapshotService : IDisposable, ISnapshotService { - private readonly ConfigManager _config; - private readonly NSPExtractor _nspExtractor; - private readonly ArchiveHandler _archiveHandler; + private readonly SnapshotOptions _options; + private readonly INSPExtractor _nspExtractor; + private readonly IArchiveHandler _archiveHandler; private readonly ILogger _logger; private readonly string _jsonPath; private readonly string _snapshotPath; private readonly List _watchers = new(); private readonly ConcurrentDictionary _cache = new(); private string? _currentSnapshotHash; + private readonly Timer _debounceTimer; public event EventHandler? SnapshotRebuilt; - public SnapshotService(ConfigManager config, NSPExtractor nspExtractor, ArchiveHandler archiveHandler, ILogger logger) + public SnapshotService( + IOptionsMonitor options, + INSPExtractor nspExtractor, + IArchiveHandler archiveHandler, + ILogger logger) { - _config = config; + _options = options.CurrentValue; _nspExtractor = nspExtractor; _archiveHandler = archiveHandler; _logger = logger; - _jsonPath = Path.Combine(AppContext.BaseDirectory, _config.Settings.SnapshotFile); - _snapshotPath = Path.Combine(AppContext.BaseDirectory, _config.Settings.SnapshotBackupFile); - + _jsonPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotFile); + FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(_jsonPath)); + if (!File.Exists(_jsonPath)) + { + File.WriteAllText(_jsonPath, "[]"); + } + _snapshotPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotBackupFile); + FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(_snapshotPath)); + // 1️⃣ Register for *property* changes + _options.PropertyChanged += (s, e) => OnOptionsChanged(e.PropertyName); + BuildSnapshot(); // initial scan File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(GetSnapshot())); + _debounceTimer = new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite); - foreach (var path in _config.Settings.RootDirectories) + foreach (var path in _options.RootDirectories) { InitializeFileSystemWatcher(path); } - - - _config.OnChange += cfg => + } + // --------- Private helpers --------- + private void OnOptionsChanged(string propertyName) + { + if (propertyName == nameof(SnapshotOptions.RootDirectories)) { - var fileSystemWatchers = _watchers.Where(watcher => !cfg.RootDirectories.Contains(watcher.Path)); + var fileSystemWatchers = _watchers.Where(watcher => !_options.RootDirectories.Contains(watcher.Path)); foreach (var watcher in fileSystemWatchers) { watcher.EnableRaisingEvents = false; @@ -56,7 +75,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService _watchers.Remove(watcher); } - var newWatchedDirectories = cfg.RootDirectories.Where(newWatchedDirectory => + var newWatchedDirectories = _options.RootDirectories.Where(newWatchedDirectory => !_watchers.Any(watcher => string.Equals(watcher.Path, newWatchedDirectory, StringComparison.OrdinalIgnoreCase))); @@ -65,11 +84,11 @@ public sealed class SnapshotService : IDisposable, ISnapshotService InitializeFileSystemWatcher(newWatchedDirectory); } + BuildSnapshot(); // rebuild everything PersistSnapshot(); - }; + } } - private void InitializeFileSystemWatcher(string path) { if (!Directory.Exists(path)) return; @@ -96,42 +115,65 @@ public sealed class SnapshotService : IDisposable, ISnapshotService private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs) { - Task.Run(async () => + lock (_lock) + { + _debounceTimer.Change(_debounceMs, Timeout.Infinite); // reset the timer + _logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss")); + } + /*Task.Run(async () => { await Task.Delay(250); _logger.LogDebug("File system event {EventType} on {Path}", fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath); UpdateSnapshot(); - }); + });*/ + } + + private readonly object _lock = new object(); + private int _debounceMs = 200; + + private void DebounceElapsed() + { + UpdateSnapshot(); } #endregion #region Snapshot logic - private void BuildSnapshot() + public void BuildSnapshot() { - var cfg = _config.Settings; - _logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", cfg.RootDirectories.Length); - var entries = new List(); var index = LoadSnapshotIndex(); - + var latestModifiedUtcParallel = FileSystemExtensions.GetLatestModifiedUtcParallel(_options.RootDirectories); + var fileInfo = new FileInfo(_snapshotPath); + if (latestModifiedUtcParallel.HasValue && latestModifiedUtcParallel.Value < fileInfo.LastWriteTimeUtc) + { + _logger.LogInformation("Snapshot is up to date"); + return; + } + _logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", _options.RootDirectories.Count); + var entries = new List(); + var snapshotChanged = false; - foreach (var dir in cfg.RootDirectories) + foreach (var dir in _options.RootDirectories) { if (!Directory.Exists(dir)) continue; foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories)) { var ext = Path.GetExtension(file).ToLowerInvariant(); - if (!(cfg.WhitelistExtensions.Contains(ext) || cfg.RomExtensions.Contains(ext))) + if (!(_options.WhitelistExtensions.Contains(ext) || _options.RomExtensions.Contains(ext))) continue; - if (index.ContainsKey(file)) continue; + if (index.TryGetValue(file, out var value)) + { + entries.Add(value); + continue; + } // 3) extract title if applicable string hash; NcaMetadataWithHash? title = null; - if (cfg.RomExtensions.Contains(ext)) + if (_options.RomExtensions.Contains(ext)) { using var nspStream = File.OpenRead(file); hash = ComputeFirstStreamHash(nspStream); @@ -170,6 +212,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries)); if (snapshotChanged) { + _logger.LogInformation("Snapshot rebuilt"); SnapshotRebuilt?.Invoke(this, EventArgs.Empty); } } @@ -183,14 +226,14 @@ public sealed class SnapshotService : IDisposable, ISnapshotService private void PersistSnapshot() { - var entries = GetSnapshot(); - var newHash = ComputeSnapshotHash(entries); + var snapshot = GetSnapshot(); + var newHash = ComputeSnapshotHash(snapshot.Files); if (_currentSnapshotHash != newHash) { _logger.LogInformation("Snapshot hash changed – persisting new snapshot"); _currentSnapshotHash = newHash; - File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries)); - File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(entries)); + File.WriteAllText(_jsonPath, JsonSerializer.Serialize(snapshot.Files)); + File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(snapshot.Files)); } } @@ -219,10 +262,18 @@ public sealed class SnapshotService : IDisposable, ISnapshotService } #endregion - public IReadOnlyList GetSnapshot() + public ROMSnapshot GetSnapshot() { + if (!File.Exists(_jsonPath)) return new(); var json = File.ReadAllText(_jsonPath); - return JsonSerializer.Deserialize>(json, new JsonSerializerOptions(){IncludeFields = true})!; + var hash = ComputeHash(_jsonPath); + var romSnapshot = new ROMSnapshot() + { + Hash = hash, + Files = JsonSerializer.Deserialize>(json, + new JsonSerializerOptions() { IncludeFields = true })! + }; + return romSnapshot; } public void RebuildSnapshot() @@ -285,4 +336,10 @@ public sealed class SnapshotService : IDisposable, ISnapshotService var hash = sha256.ComputeHash(stream); return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); } + + public class ROMSnapshot + { + public string Hash { get; set; } + public IReadOnlyList Files { get; set; } = new List(); + } } \ No newline at end of file diff --git a/TinfoilVibeServer/Services/TitleDatabaseService.cs b/TinfoilVibeServer/Services/TitleDatabaseService.cs index 0c08766..6194130 100644 --- a/TinfoilVibeServer/Services/TitleDatabaseService.cs +++ b/TinfoilVibeServer/Services/TitleDatabaseService.cs @@ -24,7 +24,7 @@ public sealed class TitleDatabaseService : IHostedService private readonly IOptionsMonitor _options; private readonly ILogger _logger; private readonly IHttpClientFactory _httpFactory; - private readonly NSPExtractor _nspExtractor; + private readonly INSPExtractor _nspExtractor; private readonly string _cacheFolder; // Where the JSON is cached. private readonly List _rootDirectories; // directories that contain game files @@ -62,7 +62,7 @@ public sealed class TitleDatabaseService : IHostedService ILogger logger, ISnapshotService snapshotService, IHttpClientFactory httpFactory, - NSPExtractor nspExtractor, + INSPExtractor nspExtractor, IMemoryCache cache) { _options = options; diff --git a/TinfoilVibeServer/TinfoilVibeServer.csproj b/TinfoilVibeServer/TinfoilVibeServer.csproj index fe4bf34..5740f29 100644 --- a/TinfoilVibeServer/TinfoilVibeServer.csproj +++ b/TinfoilVibeServer/TinfoilVibeServer.csproj @@ -9,7 +9,7 @@ - + diff --git a/TinfoilVibeServer/Utilities/FileSystemExtensions.cs b/TinfoilVibeServer/Utilities/FileSystemExtensions.cs new file mode 100644 index 0000000..457b56c --- /dev/null +++ b/TinfoilVibeServer/Utilities/FileSystemExtensions.cs @@ -0,0 +1,124 @@ +namespace TinfoilVibeServer.Utilities; + +public static class FileSystemExtensions +{ + /// + /// Returns the most recent last‑write time (UTC) of any file under the supplied + /// root directories, traversing all sub‑directories. If no files are found, + /// null is returned. + /// + /// + /// A collection of absolute paths that must point to existing directories. + /// Paths that do not exist or are inaccessible are silently skipped. + /// + /// + /// The UTC of the newest file, or null if there are none. + /// + public static DateTime? GetLatestModifiedUtc(IEnumerable rootDirectories) + { + if (rootDirectories == null) throw new ArgumentNullException(nameof(rootDirectories)); + + // We keep a mutable variable because we don't want to materialise the entire + // sequence into memory. + DateTime? latest = null; + + foreach (var root in rootDirectories) + { + if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root)) + continue; // skip bad paths + + try + { + // Enumerate lazily and process each file as soon as it’s yielded. + foreach (var filePath in Directory.EnumerateFiles( + root, + "*", + SearchOption.AllDirectories)) + { + try + { + // Using FileSystemInfo to fetch only the property we need. + var fsi = new FileInfo(filePath); + var lastWrite = fsi.LastWriteTimeUtc; + + if (!latest.HasValue || lastWrite > latest.Value) + latest = lastWrite; + } + catch (FileNotFoundException) // file vanished while we were enumerating + { + // ignore and keep going + } + catch (UnauthorizedAccessException) + { + // file exists but we can’t read its attributes – skip it + } + } + } + catch (UnauthorizedAccessException) + { + // the root directory itself is inaccessible – skip it + } + } + + return latest; + } + + /// + /// Parallelised version that may be faster on very large directory trees. + /// + public static DateTime? GetLatestModifiedUtcParallel(IEnumerable rootDirectories) + { + if (rootDirectories == null) throw new ArgumentNullException(nameof(rootDirectories)); + + // Flatten all file paths into a single stream first (this is the only + // part that needs to be thread‑safe). + var allFiles = rootDirectories + .Where(r => !string.IsNullOrWhiteSpace(r) && Directory.Exists(r)) + .SelectMany(r => Directory.EnumerateFiles(r, "*", SearchOption.AllDirectories)) + .ToArray(); // materialise once, then parallelise + + // Now fetch the dates in parallel. The LINQ overload of Max() that takes + // an async selector is not available, so we just use Parallel.ForEach. + DateTime? latest = null; + var lockObj = new object(); + + Parallel.ForEach(allFiles, filePath => + { + try + { + var lastWrite = new FileInfo(filePath).LastWriteTimeUtc; + lock (lockObj) + { + if (!latest.HasValue || lastWrite > latest.Value) + latest = lastWrite; + } + } + catch (Exception) + { + // swallow all exceptions – the caller only cares about the max date + } + }); + + return latest; + } + + /// + /// Creates the directory (and all missing parent directories) if it does not already exist. + /// + /// Absolute or relative path to the directory to create. + /// Thrown if is null. + /// Thrown if is empty or contains only whitespace. + /// Thrown if the caller does not have permission. + /// Thrown if a file exists at the target path or the directory cannot be created. + public static void EnsureDirectoryExists(string path) + { + if (path is null) + throw new ArgumentNullException(nameof(path)); + + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("Path must not be empty or whitespace.", nameof(path)); + + // Directory.CreateDirectory is already idempotent – it only creates missing parts. + Directory.CreateDirectory(path); + } +} diff --git a/TinfoilVibeServer/appsettings.json b/TinfoilVibeServer/appsettings.json index 3e7e63a..da1fc0d 100644 --- a/TinfoilVibeServer/appsettings.json +++ b/TinfoilVibeServer/appsettings.json @@ -7,16 +7,19 @@ }, "AllowedHosts": "*", - "RootDirectories": [ "\\\\NAS\\Roms\\Switch", "Z:\\imgs\\roms\\Switch" ], - "WhitelistExtensions": [ ".bin", ".jpg", ".png", ".txt" ], - "RomExtensions": [ ".xci", ".nsp", ".xcz" ], - "SnapshotFile": "index.tfl", - "SnapshotBackupFile": "snapshot.bin", + "KeySetFile": "prod.keys", "CredentialsFile": "credentials.json", "FingerprintsFile": "fingerprints.json", "BlacklistFile": "blacklist.json", "MaxFailedAttempts": 5, - "KeySetFile": "prod.keys", + "Snapshot" : { + "RootDirectories": [ "\\\\NAS\\Roms\\Switch", "Z:\\imgs\\roms\\Switch" ], + "WhitelistExtensions": [ ".bin", ".jpg", ".png", ".txt" ], + "RomExtensions": [ ".xci", ".nsp", ".xcz" ], + "CacheTtl": 60, + "SnapshotFile": "index.tfl", + "SnapshotBackupFile": "snapshot.bin" + }, "TitleDb": { "CountryCode": "AU", "Language": "en", diff --git a/TinfoilVibeServerTest/Tests/AuthStoreTests.cs b/TinfoilVibeServerTest/Tests/AuthStoreTests.cs new file mode 100644 index 0000000..9633b35 --- /dev/null +++ b/TinfoilVibeServerTest/Tests/AuthStoreTests.cs @@ -0,0 +1,111 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using TinfoilVibeServer.Authentication; +using TinfoilVibeServer.Services; + +// <-- adjust namespace + +namespace TinfoilVibeServerTest.Tests +{ + [TestFixture] + public class AuthStoreTests + { + private Mock> _loggerMock; + private AuthStore _authStore; + + [SetUp] + public void SetUp() + { + _loggerMock = new Mock>(); + // Assume Settings is static and can be patched for tests + MockConfigManager = new Mock(); + _authStore = new AuthStore(_loggerMock.Object, MockConfigManager.Object); + } + + public Mock MockConfigManager { get; set; } + + [TearDown] + public void TearDown() + { + _authStore.Dispose(); + } + + [Test] + public void LoadAll_ShouldPopulateCollections() + { + // Act + var users = _authStore.Credentials.Count; + var fprs = _authStore.Fingerprints.Count; + + // Assert + Assert.That(users, Is.GreaterThan(0), "At least one user must be loaded"); + Assert.That(fprs, Is.GreaterThanOrEqualTo(0)); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Loaded")), + null, + (Func)It.IsAny()), + Times.AtLeastOnce); + } + + [Test] + public void TryValidate_NewUser_ShouldCreateAndVerify() + { + // Arrange + var newUser = "newuser"; + var ip = "127.0.0.1"; + var password = ""; + var uid = null as int?; + // Act + var result = _authStore.TryValidate(newUser, password, uid, ip, out var cred); + + // Assert + Assert.That(result, Is.False, "New user should be not be verified automatically"); + Assert.That(_authStore.Credentials[newUser], Is.Not.Null); + Assert.That(_authStore.Credentials[newUser].Verified, Is.False); + + // New user should now exist + Assert.That(_authStore.Credentials.Any(u => u.Value.Username == newUser), Is.True); + } + + [Test] + public void IncrementFailed_BeforeBlacklist_ShouldNotBlacklist() + { + // Arrange + var ip = "203.0.113.5"; + var cred = new Credential("dummy", "hash", 1, false); + _authStore.UnbanIp(ip); // ensure clean + + // Act + var counter = _authStore.IncrementFailed(cred.Username, ip); + + // Assert + Assert.That(counter, Is.EqualTo(1)); + Assert.That(_authStore.IsIPBlacklisted(ip), Is.False); + } + + [Test] + public void IncrementFailed_ExceedingThreshold_ShouldBlacklist() + { + // Arrange + var ip = "203.0.113.5"; + var cred = new Credential("dummy", "hash", MockConfigManager.Object.Settings.MaxFailedAttempts, false); + int threshold = MockConfigManager.Object.Settings.MaxFailedAttempts; + + // Simulate threshold failures + for (int i = 0; i < threshold; i++) + _authStore.IncrementFailed(cred.Username, ip); + + // Act + int final = _authStore.IncrementFailed(cred.Username, ip); + + // Assert + Assert.That(final, Is.EqualTo(threshold + 1)); + Assert.That(_authStore.IsIPBlacklisted(ip), Is.True); + } + } +} \ No newline at end of file diff --git a/TinfoilVibeServerTest/Tests/BasicAuthMiddlewareTests.cs b/TinfoilVibeServerTest/Tests/BasicAuthMiddlewareTests.cs new file mode 100644 index 0000000..c661eb6 --- /dev/null +++ b/TinfoilVibeServerTest/Tests/BasicAuthMiddlewareTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using TinfoilVibeServer.Authentication; +using TinfoilVibeServer.Middleware; + +namespace TinfoilVibeServerTest.Tests +{ + [TestFixture] + public class BasicAuthMiddlewareTests + { + private Mock> _loggerMock; + private Mock _authMock; + private BasicAuthMiddleware _middleware; + private RequestDelegate _next; + + [SetUp] + public void SetUp() + { + _loggerMock = new Mock>(); + _authMock = new Mock(); + _next = (HttpContext ctx) => Task.CompletedTask; + + _middleware = new BasicAuthMiddleware(_next); + } + + private HttpContext CreateContext(string authHeader = null, string ip = "127.0.0.1", int? uid = null) + { + var ctx = new DefaultHttpContext(); + ctx.Connection.RemoteIpAddress = IPAddress.Parse(ip); + + if (!string.IsNullOrEmpty(authHeader)) + { + ctx.Request.Headers["Authorization"] = authHeader; + } + + if (uid!= null) + { + ctx.Request.Headers["UID"] = uid.ToString(); + } + + return ctx; + } + + [Test] + public async Task InvokeAsync_NoAuthHeader_ShouldReturn401() + { + // Arrange + var ctx = CreateContext(); + + // Act + await _middleware.InvokeAsync(ctx, _authMock.Object, _loggerMock.Object); + + // Assert + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status401Unauthorized)); + _loggerMock.Verify(l => l.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Missing Authorization header")), + null, + It.IsAny>()), Times.Once); + } + + [Test] + public async Task InvokeAsync_BlacklistedIP_ShouldReturn403() + { + // Arrange + var ctx = CreateContext("Basic dXNlcjpwYXNz"); + + _authMock.Setup(a => a.IsIPBlacklisted("127.0.0.1")).Returns(true); + + // Act + + await _middleware.InvokeAsync(ctx, _authMock.Object, _loggerMock.Object); + + // Assert + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status403Forbidden)); + } + + [Test] + public async Task InvokeAsync_ValidCredentials_ShouldCallNext() + { + // Arrange + var user = "alice"; + var pw = "secret"; + var uid = 1234; + var header = $"Basic {Convert.ToBase64String(Encoding.ASCII.GetBytes($"{user}:{pw}"))}"; + + var ip = "127.0.0.1"; + var ctx = CreateContext(header,ip, uid); + + string? error; + _authMock.Setup(a => + a.TryValidate(user, pw, uid, ip, out error)) + .Returns(true); + + bool nextCalled = false; + _next = (HttpContext _) => { nextCalled = true; return Task.CompletedTask; }; + _middleware = new BasicAuthMiddleware(_next); + + // Act + await _middleware.InvokeAsync(ctx, _authMock.Object, _loggerMock.Object); + + // Assert + Assert.That(nextCalled, Is.True); + Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK)); + } + } +} \ No newline at end of file diff --git a/TinfoilVibeServerTest/Tests/SnapshotServiceTests.cs b/TinfoilVibeServerTest/Tests/SnapshotServiceTests.cs new file mode 100644 index 0000000..913bbc6 --- /dev/null +++ b/TinfoilVibeServerTest/Tests/SnapshotServiceTests.cs @@ -0,0 +1,119 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using LibHac.Ncm; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using TinfoilVibeServer.Models; +using TinfoilVibeServer.Services; +using TinfoilVibeServer.Utilities; + +namespace TinfoilVibeServerTest.Tests +{ + [TestFixture] + public class SnapshotServiceTests + { + private Mock> _loggerMock; + private SnapshotService _service; + private Mock _nspExtractorMock; + private Mock _archiveHander; + private Mock> _mockOptions; + private SnapshotOptions _options; + + [SetUp] + public void SetUp() + { + _mockOptions = new Mock>(); + _options = new SnapshotOptions() + { + RomExtensions = [".nsp"], + RootDirectories = ["TestData/ROMS"], + SnapshotFile = "TestData/snapshot.json", + SnapshotBackupFile = "TestData/snapshot.bak" + }; + /*// ensure ROM test directory has test files removed + foreach (var file in Directory.GetFiles("TestData/ROMS")) + { + File.Delete(file); + }*/ + _mockOptions.Setup(m => m.CurrentValue).Returns(_options); + _loggerMock = new Mock>(); + _archiveHander = new Mock(); + _nspExtractorMock = new Mock(); + _nspExtractorMock.Setup(extractor => extractor.ExtractHashFromStream(It.IsAny())).Returns("HASH"); + _nspExtractorMock.Setup(extractor => extractor.ExtractFromStream(It.IsAny())).Returns( + new NcaMetadataWithHash(titleId: "0000000000000000","0000000000000000", version: 1, ContentMetaType.Application, "HASH")); + //Settings.RootDirs = new List { "TestData/Root1", "TestData/Root2" }; + _service = new SnapshotService(_mockOptions.Object, _nspExtractorMock.Object, _archiveHander.Object, _loggerMock.Object); + } + + [TearDown] + public void TearDown() + { + _service.Dispose(); + } + + [Test] + public async Task BuildSnapshot_WhenFilesChanged_ShouldPersist() + { + // Arrange + await File.WriteAllTextAsync(_options.SnapshotFile, "[]"); + var initialHash = _service.GetSnapshot()?.Hash; + // Add a file to Root1 + var newFile = Path.Combine(_options.RootDirectories.First(), "new.nsp"); + FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(newFile)); + // Create a new valid NSP file + // copy to temp to touch modified date + foreach (var file in Directory.GetFiles("../../../Data/")) + { + var filename = Path.GetFileName(file); + var destFilename = Path.Combine(Path.GetTempPath(), filename); + File.Copy(file, destFilename, true); + var info = new FileInfo(destFilename) + { + LastWriteTimeUtc = DateTime.UtcNow + }; + info.CopyTo(Path.Combine(_options.RootDirectories.First(),filename), true); + } + + // Act + _service.SnapshotRebuilt+= (sender, args) => + { + // Assert + var newHash = _service.GetSnapshot()?.Hash; + Assert.That(newHash, Is.Not.EqualTo(initialHash)); + + + }; + Task.Delay(300).Wait(); + _loggerMock.Verify( + l => l.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Snapshot rebuilt")), + null, + It.IsAny>()), Times.Once); + } + + [Test] + public async Task BuildSnapshot_NoChange_ShouldNotPersist() + { + // Act + _service.BuildSnapshot(); + + // Act again – snapshot should be identical + _service.BuildSnapshot(); + + // Assert + _loggerMock.Verify( + l => l.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("persisting new snapshot")), + null, + It.IsAny>()), Times.Never); + } + } +} \ No newline at end of file diff --git a/TinfoilVibeServerTest/TinfoilVibeServerTest.csproj b/TinfoilVibeServerTest/TinfoilVibeServerTest.csproj index d3517ca..06cc596 100644 --- a/TinfoilVibeServerTest/TinfoilVibeServerTest.csproj +++ b/TinfoilVibeServerTest/TinfoilVibeServerTest.csproj @@ -6,18 +6,41 @@ enable enable false + true + opencover + ../coverage/ + **/Program.cs - - - - - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + - + + + + + + + + + ..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll + + diff --git a/TinfoilVibeServerTest/UnitTest1.cs b/TinfoilVibeServerTest/UnitTest1.cs deleted file mode 100644 index 0429a91..0000000 --- a/TinfoilVibeServerTest/UnitTest1.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace TinfoilVibeServerTest; - -public class Tests -{ - [SetUp] - public void Setup() - { - } - - [Test] - public void Test1() - { - Assert.Pass(); - } -} \ No newline at end of file