using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using Jellyfin.Plugin.JRay.Models; namespace Jellyfin.Plugin.JRay.Services; /// /// Re-validates a manifest received from a server. /// /// /// Every server is untrusted, including the pre-configured community one. /// Everything the server specification guarantees is a property of a *correctly /// operated* server; pointing the plugin at an arbitrary URL inherits none of /// it. So the plugin re-applies client-side what the server applies on upload: /// unknown-shaped data rejected, identifiers format-checked, windows /// bounds-checked against the item's real runtime. /// /// The honest framing for the configuration page is that adding a third-party /// server means trusting its operator not to serve you deliberately wrong actor /// data. These checks bound the damage to bad overlay content; they cannot make /// wrong data right. /// /// // TRACES: JR-027 | SR-004 public static class ManifestValidator { /// The exchange envelope version this plugin speaks (SR-003). public const int SupportedJmanifestVersion = 2; /// Server spec §6: no more than 500 actors in one manifest. public const int MaxActors = 500; /// Server spec §6: no more than 2000 windows for one actor. public const int MaxScenesPerActor = 2000; /// Server spec §6: no more than 20000 windows in total. public const int MaxTotalScenes = 20000; /// Server spec §6: names are capped at 200 characters. public const int MaxNameLength = 200; /// /// Windows may exceed the measured runtime by this much before being /// rejected, covering rounding and container-duration disagreement. /// public const double RuntimeToleranceSec = 5.0; /// /// Validates a manifest against the local item's measured runtime. /// /// The manifest as received. /// /// The runtime of the local file, or null when it is not known. Windows are /// bounds-checked against it when it is available. /// /// The first problem found, naming the offending field. /// true when the manifest is safe to store. public static bool TryValidate(Jmanifest? manifest, double? localRuntimeSec, out string error) { if (manifest is null) { error = "manifest: absent"; return false; } // An unknown envelope version is refused, never guessed at (JR-003). // A server one version ahead may have changed the meaning of a field // this plugin thinks it understands. if (manifest.JmanifestVersion != SupportedJmanifestVersion) { error = string.Create( CultureInfo.InvariantCulture, $"jmanifest_version: unsupported version {manifest.JmanifestVersion}, expected {SupportedJmanifestVersion}"); return false; } if (manifest.Identity is null) { error = "identity: absent"; return false; } if (manifest.Cut is null || !IsSaneRuntime(manifest.Cut.RuntimeSec)) { error = "cut.runtime_sec: absent or not a plausible duration"; return false; } if (manifest.Actors.Count == 0) { error = "actors: empty"; return false; } if (manifest.Actors.Count > MaxActors) { error = string.Create( CultureInfo.InvariantCulture, $"actors: more than {MaxActors} entries"); return false; } // Bounds are checked against the *local* file where known, because that // is what the overlay will index into. A window past the end of the file // is not merely useless, it is evidence the manifest is for another cut. var limit = (localRuntimeSec ?? manifest.Cut.RuntimeSec) + RuntimeToleranceSec; var total = 0; var seenTmdb = new HashSet(StringComparer.Ordinal); for (var i = 0; i < manifest.Actors.Count; i++) { var actor = manifest.Actors[i]; if (actor.Name is { Length: > MaxNameLength }) { error = string.Create(CultureInfo.InvariantCulture, $"actors[{i}].name: too long"); return false; } if (actor.Name is not null && ContainsControlCharacters(actor.Name)) { error = string.Create( CultureInfo.InvariantCulture, $"actors[{i}].name: contains control characters"); return false; } if (actor.TmdbId is { Length: > 0 } tmdb) { if (!IsDigits(tmdb, 9)) { error = string.Create( CultureInfo.InvariantCulture, $"actors[{i}].tmdb_id: not a TMDB id"); return false; } if (!seenTmdb.Add(tmdb)) { error = string.Create( CultureInfo.InvariantCulture, $"actors[{i}].tmdb_id: duplicate actor"); return false; } } if (actor.ImdbId is { Length: > 0 } imdb && !IsPersonImdbId(imdb)) { error = string.Create( CultureInfo.InvariantCulture, $"actors[{i}].imdb_id: not an IMDB person id"); return false; } if (actor.Scenes.Count > MaxScenesPerActor) { error = string.Create( CultureInfo.InvariantCulture, $"actors[{i}].scenes: more than {MaxScenesPerActor} entries"); return false; } total += actor.Scenes.Count; if (total > MaxTotalScenes) { error = string.Create( CultureInfo.InvariantCulture, $"actors: more than {MaxTotalScenes} windows in total"); return false; } for (var j = 0; j < actor.Scenes.Count; j++) { var scene = actor.Scenes[j]; if (!IsFinite(scene.Start) || !IsFinite(scene.End)) { error = string.Create( CultureInfo.InvariantCulture, $"actors[{i}].scenes[{j}]: non-finite value"); return false; } if (scene.Start < 0 || scene.End < scene.Start) { error = string.Create( CultureInfo.InvariantCulture, $"actors[{i}].scenes[{j}]: negative or inverted window"); return false; } if (scene.End > limit) { error = string.Create( CultureInfo.InvariantCulture, $"actors[{i}].scenes[{j}]: ends beyond the item's runtime"); return false; } // A posterior outside [0, 1] is not a probability. if (scene.Belief is { } b && (!IsFinite(b) || b < 0 || b > 1)) { error = string.Create( CultureInfo.InvariantCulture, $"actors[{i}].scenes[{j}].belief: outside [0, 1]"); return false; } } } error = string.Empty; return true; } private static bool IsSaneRuntime(double v) => IsFinite(v) && v > 0 && v < 200_000; private static bool IsFinite(double v) => !double.IsNaN(v) && !double.IsInfinity(v); private static bool IsDigits(string s, int maxLength) => s.Length > 0 && s.Length <= maxLength && s.All(char.IsAsciiDigit); private static bool IsPersonImdbId(string s) => s.StartsWith("nm", StringComparison.Ordinal) && (s.Length == 9 || s.Length == 10) && s.AsSpan(2).ToString().All(char.IsAsciiDigit); /// /// Control characters are refused outright. The overlay renders names as /// text nodes (JR-024), so markup is already inert, but a bidi override or a /// zero-width joiner can still make a name display as something other than /// what was stored. /// private static bool ContainsControlCharacters(string s) { foreach (var c in s) { if (char.IsControl(c)) { return true; } // Zero-width and bidi-control codepoints. if (c is >= '​' and <= '‏' or >= '‪' and <= '‮' or >= '⁦' and <= '⁩' or '') { return true; } } return false; } }