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;
}
}