using System.Security.Cryptography; namespace AvParser.ImgTestService; /// Top-level service options, bound from the ImgTest configuration section. public sealed class ImgTestOptions { /// Fraction of otherwise-valid ids that answer 404, to emulate a sparse site. 0 disables. public double MissRate { get; set; } /// The endpoints to expose, each with its own id shape. public List Endpoints { get; set; } = []; /// Resolves every endpoint and indexes it by path segment. public Dictionary BuildLookup() { var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var endpoint in Endpoints) { var resolved = endpoint.Resolve(); if (resolved is not null) { lookup[resolved.Path] = resolved; } } return lookup; } /// /// Whether an id should 404 despite being well-formed, deterministically by id. /// /// /// Deterministic on purpose: a given id is either always present or always absent, so a /// collector's "seen this url" journal and its duplicate detection stay meaningful across runs. /// public static bool IsMiss(string core, double missRate) { if (missRate <= 0) { return false; } if (missRate >= 1) { return true; } var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes("miss:" + core)); var value = ((uint)hash[0] << 24) | ((uint)hash[1] << 16) | ((uint)hash[2] << 8) | hash[3]; return value / (double)uint.MaxValue < missRate; } } /// One endpoint's configuration, as written in appsettings. public sealed class EndpointOptions { /// First path segment, e.g. test1 for /test1/{id}. public string Path { get; set; } = string.Empty; /// Shortest id core (excluding any extension). public int MinLength { get; set; } /// Longest id core (excluding any extension). public int MaxLength { get; set; } /// Named character set: Letters, LettersLower, LettersUpper, Digits, Alphanumeric, HexLower, or Custom. public string Alphabet { get; set; } = "Alphanumeric"; /// Exact characters, used when is Custom. public string? Chars { get; set; } /// Accepted trailing extensions, e.g. .jpg; empty means none is expected. public List Extensions { get; set; } = []; /// Turns this into a resolved endpoint, or null when unusable. public ResolvedEndpoint? Resolve() { var allowed = ResolveAlphabet(Alphabet, Chars); if (string.IsNullOrWhiteSpace(Path) || MinLength < 1 || MaxLength < MinLength || allowed.Count == 0) { return null; } return new ResolvedEndpoint( Path.Trim('/'), MinLength, MaxLength, allowed, [.. Extensions.Where(e => !string.IsNullOrWhiteSpace(e)).Select(Normalise)] ); } private static string Normalise(string extension) { var trimmed = extension.Trim(); return trimmed.StartsWith('.') ? trimmed : "." + trimmed; } private static HashSet ResolveAlphabet(string name, string? chars) { const string lower = "abcdefghijklmnopqrstuvwxyz"; const string upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const string digit = "0123456789"; const string hex = "0123456789abcdef"; var set = name.Trim().ToLowerInvariant() switch { "letterslower" or "lower" => lower, "lettersupper" or "upper" => upper, "letters" => lower + upper, "digits" or "digit" => digit, "alphanumeric" or "alnum" => lower + upper + digit, "hexlower" or "hex" => hex, "custom" => chars ?? string.Empty, _ => string.Empty, }; return [.. set]; } } /// A validated endpoint ready to match requests. public sealed record ResolvedEndpoint( string Path, int MinLength, int MaxLength, HashSet Allowed, string[] Extensions ) { /// Removes a recognised trailing extension from the raw path segment. public string StripExtension(string id) { foreach (var extension in Extensions) { if (id.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) { return id[..^extension.Length]; } } return id; } /// Whether an id core is the right length and made only of allowed characters. public bool Matches(string core) { if (core.Length < MinLength || core.Length > MaxLength) { return false; } foreach (var c in core) { if (!Allowed.Contains(c)) { return false; } } return true; } }