Satisfies JRay-public-server UR-007. Servers are tried in configured order and the first result clearing the configured tier wins; first-match rather than best-match because querying every server for every item multiplies egress and leaks the library to more parties, and the ordering already encodes which source the admin prefers. Every server is untrusted, including the pre-configured community one, so a fetched manifest is re-validated against the same rules the server applies on upload: envelope version refused if unknown, identifiers format-checked, windows bounds-checked against the *local* file's runtime, belief bounded to [0, 1], control and bidi characters refused in names. Responses are capped while streaming rather than after buffering, since a hostile server can declare any Content-Length it likes. HTTPS is required away from loopback. A failing server is skipped with exponential backoff so one dead server cannot stall a sweep. The audio-tier offset is applied once, at store time, so stored truth is always in the local file's own timebase and no read path needs offset awareness. Windows are shifted, never reshaped — merging adjacent ones would answer "was a face visible" rather than "was the actor present" (SR-002). Also records why there is no `exact` tier, which was missing and led me to re-add one. The file-hash tier is withdrawn on legal grounds: a TMDB id discloses "some copy of this film", but an OpenSubtitles hash discloses "this exact release", which turns a catalogue lookup into a release-identification service and a server's database into a mapping from file fingerprints to the instances holding them. The reason now lives on MatchTier and in SPEC.md §JR-036, `TitleQuery` has no VideoHash property so there is nothing to send, and a test asserts the enum has no Exact member — the spec had still listed `exact` as a configurable tier, which is what made the removal look like an oversight. 42 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: JR-025, JR-027, JR-028, JR-029, JR-030, JR-031, JR-036, JR-037 | PR-005, PR-006
257 lines
9.1 KiB
C#
257 lines
9.1 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Globalization;
|
||
using System.Linq;
|
||
using Jellyfin.Plugin.JRay.Models;
|
||
|
||
namespace Jellyfin.Plugin.JRay.Services;
|
||
|
||
/// <summary>
|
||
/// Re-validates a manifest received from a server.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <b>Every server is untrusted, including the pre-configured community one.</b>
|
||
/// 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.
|
||
/// <para>
|
||
/// 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.
|
||
/// </para>
|
||
/// </remarks>
|
||
// TRACES: JR-027 | SR-004
|
||
public static class ManifestValidator
|
||
{
|
||
/// <summary>The exchange envelope version this plugin speaks (SR-003).</summary>
|
||
public const int SupportedJmanifestVersion = 2;
|
||
|
||
/// <summary>Server spec §6: no more than 500 actors in one manifest.</summary>
|
||
public const int MaxActors = 500;
|
||
|
||
/// <summary>Server spec §6: no more than 2000 windows for one actor.</summary>
|
||
public const int MaxScenesPerActor = 2000;
|
||
|
||
/// <summary>Server spec §6: no more than 20000 windows in total.</summary>
|
||
public const int MaxTotalScenes = 20000;
|
||
|
||
/// <summary>Server spec §6: names are capped at 200 characters.</summary>
|
||
public const int MaxNameLength = 200;
|
||
|
||
/// <summary>
|
||
/// Windows may exceed the measured runtime by this much before being
|
||
/// rejected, covering rounding and container-duration disagreement.
|
||
/// </summary>
|
||
public const double RuntimeToleranceSec = 5.0;
|
||
|
||
/// <summary>
|
||
/// Validates a manifest against the local item's measured runtime.
|
||
/// </summary>
|
||
/// <param name="manifest">The manifest as received.</param>
|
||
/// <param name="localRuntimeSec">
|
||
/// The runtime of the local file, or null when it is not known. Windows are
|
||
/// bounds-checked against it when it is available.
|
||
/// </param>
|
||
/// <param name="error">The first problem found, naming the offending field.</param>
|
||
/// <returns><c>true</c> when the manifest is safe to store.</returns>
|
||
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<string>(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);
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|