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