Files
JRay-public-server/src/content_id.rs
T
dtourolleandClaude Opus 5 a1e789a6fe
CI / fmt, clippy, test (push) Failing after 1m21s
CI / static musl binary (push) Has been skipped
CI / advisories and licences (push) Successful in 24s
Traceability: vendor the shared gate, annotate the source
Adds jray-project as a submodule at scripts/vendor/jray-project, so this repo
runs the same extractor as every other component rather than its own copy, and
gains the system spec that defines the PR/SR requirements its register traces
up to.

scripts/traceability-gate.sh is a thin wrapper holding only what is specific to
this repo: UR/DR prefixes, .rs sources, and REPO_ROOT — which the shared gate
cannot infer once vendored, since its default resolves to the submodule itself.
Each override fails silently in a way that looks like "no work done" rather
than "misconfigured", so the wrapper documents why each is needed.

Annotates 35 units with TRACES tags, on the code that decides rather than every
helper it calls. Coverage is 23/32 (71.9%) with no orphan tags. The nine
untraced are genuinely unimplemented: UR-007 is plugin-side, UR-008 is
federation, and UR-015..018 are the pending SR-003 schema bump.

The gate caught a real error in the first pass: several tags separated IDs of
different types with commas. A comma joins IDs within one type; a pipe
separates types. Fixed, and the diagnostics are now clean.

MIN_COVERAGE stays 0 deliberately. The gate still fails on orphan tags, a >100%
ratio, a register parsing to nothing, or an empty source scan — raise the
threshold as a ratchet once the remaining work lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:27:16 +02:00

292 lines
10 KiB
Rust

//! §9a content addressing.
//!
//! A validated manifest is immutable and content-addressable, which is what
//! makes replication *set reconciliation* rather than state synchronisation.
//! Even without the federation endpoints, computing `content_id` on upload gives
//! deduplication now and means stored manifests are already addressable when
//! federation lands.
//!
//! **This canonical form must be reimplemented byte-identically by the JRay
//! plugin** (§8 "Cost of choosing Rust": the extraction side is Python, so this
//! can no longer be shared as one implementation and must instead be specified
//! precisely and cross-tested). [`GOLDEN_VECTORS`] is that shared fixture.
use sha2::{Digest, Sha256};
/// One actor's contribution to the canonical form.
#[derive(Debug, Clone)]
pub struct CanonicalActor {
pub tmdb_person_id: u64,
/// Integer centiseconds — quantised, not formatted floats (§9a).
pub scenes_cs: Vec<(i64, i64)>,
}
/// The identity coordinates that enter the hash.
#[derive(Debug, Clone, Default)]
pub struct CanonicalIdentity {
pub kind: &'static str,
pub tmdb_id: Option<String>,
pub imdb_id: Option<String>,
pub season: Option<i64>,
pub episode: Option<i64>,
}
/// The cut coordinates that enter the hash.
///
/// **`audio_signature` is excluded, deliberately** (§9a): it is derived by
/// decoding audio, so two servers running different FFmpeg or resampler versions
/// could compute marginally different signatures for identical content, and
/// including it would silently break federation deduplication.
#[derive(Debug, Clone, Default)]
pub struct CanonicalCut {
/// Quantised to centiseconds for the same reason scene times are.
pub runtime_cs: i64,
pub video_hash: Option<String>,
}
/// Builds the canonical JSON form: keys sorted, no whitespace, actors sorted by
/// person id, scene times as integer centiseconds.
///
/// `extraction` metadata and all local state are excluded, so two servers that
/// validated the same upload independently arrive at the same `content_id`.
/// TRACES: DR-011 | SR-003
pub fn canonical_json(
identity: &CanonicalIdentity,
cut: &CanonicalCut,
actors: &[CanonicalActor],
) -> String {
let mut sorted: Vec<&CanonicalActor> = actors.iter().collect();
sorted.sort_by_key(|a| a.tmdb_person_id);
let mut s = String::new();
s.push_str("{\"actors\":[");
for (i, a) in sorted.iter().enumerate() {
if i > 0 {
s.push(',');
}
// Scene windows are emitted in stored order; validation has already
// established they are sorted by start time.
s.push_str("{\"scenes\":[");
for (j, (start, end)) in a.scenes_cs.iter().enumerate() {
if j > 0 {
s.push(',');
}
s.push('[');
s.push_str(&start.to_string());
s.push(',');
s.push_str(&end.to_string());
s.push(']');
}
s.push_str("],\"tmdb_person_id\":");
s.push_str(&a.tmdb_person_id.to_string());
s.push('}');
}
s.push_str("],\"cut\":{");
s.push_str("\"runtime_cs\":");
s.push_str(&cut.runtime_cs.to_string());
s.push_str(",\"video_hash\":");
push_opt_str(&mut s, cut.video_hash.as_deref());
s.push_str("},\"identity\":{");
s.push_str("\"episode\":");
push_opt_num(&mut s, cut_opt(identity.episode));
s.push_str(",\"imdb_id\":");
push_opt_str(&mut s, identity.imdb_id.as_deref());
s.push_str(",\"season\":");
push_opt_num(&mut s, cut_opt(identity.season));
s.push_str(",\"tmdb_id\":");
push_opt_str(&mut s, identity.tmdb_id.as_deref());
s.push_str(",\"type\":\"");
s.push_str(identity.kind);
s.push_str("\"}}");
s
}
fn cut_opt(v: Option<i64>) -> Option<i64> {
v
}
fn push_opt_str(s: &mut String, v: Option<&str>) {
match v {
// Only closed-vocabulary values reach here (regex-constrained ids and a
// fixed-format hash), so no string escaping is required.
Some(v) => {
s.push('"');
s.push_str(v);
s.push('"');
}
None => s.push_str("null"),
}
}
fn push_opt_num(s: &mut String, v: Option<i64>) {
match v {
Some(v) => s.push_str(&v.to_string()),
None => s.push_str("null"),
}
}
/// `sha256:` over the canonical form (§9a).
/// TRACES: DR-011 | SR-003
pub fn content_id(
identity: &CanonicalIdentity,
cut: &CanonicalCut,
actors: &[CanonicalActor],
) -> String {
let canonical = canonical_json(identity, cut, actors);
let mut h = Sha256::new();
h.update(canonical.as_bytes());
let digest = h.finalize();
let mut hex = String::with_capacity(64 + 7);
hex.push_str("sha256:");
for b in digest {
hex.push_str(&format!("{b:02x}"));
}
hex
}
/// Cross-implementation fixture (§8): the JRay plugin and any reimplementation
/// must reproduce these exactly, or federation deduplication silently breaks.
pub const GOLDEN_VECTORS: &[(&str, &str)] = &[(
// Movie, one actor, two windows, with a video hash.
r#"{"actors":[{"scenes":[[19160,20920],[43820,46560]],"tmdb_person_id":884}],"cut":{"runtime_cs":642050,"video_hash":"opensubtitles:8e245d9679d31e12"},"identity":{"episode":null,"imdb_id":"tt4686844","season":null,"tmdb_id":"504172","type":"movie"}}"#,
// Verified against an independent Python implementation:
// sha256(canonical.encode()).hexdigest()
"sha256:367f8b05c54a992a3a30fa016edaaac0b9b36b148fc76574f5b1ef326b56760f",
)];
#[cfg(test)]
mod tests {
use super::*;
fn movie_identity() -> CanonicalIdentity {
CanonicalIdentity {
kind: "movie",
tmdb_id: Some("504172".into()),
imdb_id: Some("tt4686844".into()),
season: None,
episode: None,
}
}
fn movie_cut() -> CanonicalCut {
CanonicalCut {
runtime_cs: 642050,
video_hash: Some("opensubtitles:8e245d9679d31e12".into()),
}
}
fn actors() -> Vec<CanonicalActor> {
vec![CanonicalActor {
tmdb_person_id: 884,
scenes_cs: vec![(19160, 20920), (43820, 46560)],
}]
}
#[test]
fn canonical_form_matches_the_documented_shape() {
let json = canonical_json(&movie_identity(), &movie_cut(), &actors());
assert_eq!(json, GOLDEN_VECTORS[0].0);
// Keys sorted, no whitespace (§9a).
assert!(!json.contains(' '));
}
#[test]
fn canonical_form_is_valid_json_with_sorted_keys() {
// Hand-built strings are easy to get subtly wrong, so assert the output
// actually parses and that its keys really are ordered.
let json = canonical_json(&movie_identity(), &movie_cut(), &actors());
let v: serde_json::Value =
serde_json::from_str(&json).expect("canonical form must be JSON");
let obj = v.as_object().unwrap();
let keys: Vec<&String> = obj.keys().collect();
assert_eq!(keys, vec!["actors", "cut", "identity"]);
let id_keys: Vec<&String> = v["identity"].as_object().unwrap().keys().collect();
assert_eq!(id_keys, vec!["episode", "imdb_id", "season", "tmdb_id", "type"]);
let cut_keys: Vec<&String> = v["cut"].as_object().unwrap().keys().collect();
assert_eq!(cut_keys, vec!["runtime_cs", "video_hash"]);
}
#[test]
fn actor_order_does_not_affect_the_hash() {
// §9a: actors sorted by person id, so two servers that stored them in
// different orders still agree.
let a = vec![
CanonicalActor { tmdb_person_id: 884, scenes_cs: vec![(0, 100)] },
CanonicalActor { tmdb_person_id: 17419, scenes_cs: vec![(200, 300)] },
];
let b = vec![a[1].clone(), a[0].clone()];
assert_eq!(
content_id(&movie_identity(), &movie_cut(), &a),
content_id(&movie_identity(), &movie_cut(), &b)
);
}
#[test]
fn accumulated_float_error_hashes_identically() {
// The failure mode §9a exists to remove: real corpus values look like
// 8045.066666660665, and two servers may compute them slightly
// differently. Quantising first means both hash the same.
let a = vec![CanonicalActor {
tmdb_person_id: 1,
scenes_cs: vec![(crate::validate::to_centiseconds(8045.066666660665), 900000)],
}];
let b = vec![CanonicalActor {
tmdb_person_id: 1,
scenes_cs: vec![(crate::validate::to_centiseconds(8045.066666666), 900000)],
}];
assert_eq!(
content_id(&movie_identity(), &movie_cut(), &a),
content_id(&movie_identity(), &movie_cut(), &b)
);
}
#[test]
fn differing_content_produces_differing_ids() {
let base = content_id(&movie_identity(), &movie_cut(), &actors());
let mut other_actors = actors();
other_actors[0].scenes_cs[0].1 += 1;
assert_ne!(base, content_id(&movie_identity(), &movie_cut(), &other_actors));
let mut other_cut = movie_cut();
other_cut.runtime_cs += 1;
assert_ne!(base, content_id(&movie_identity(), &other_cut, &actors()));
let mut other_id = movie_identity();
other_id.tmdb_id = Some("999".into());
assert_ne!(base, content_id(&other_id, &movie_cut(), &actors()));
}
#[test]
fn episode_and_movie_coordinates_are_distinguished() {
let ep = CanonicalIdentity {
kind: "episode",
tmdb_id: Some("1396".into()),
imdb_id: None,
season: Some(2),
episode: Some(5),
};
let other = CanonicalIdentity { season: Some(3), ..ep.clone() };
assert_ne!(
content_id(&ep, &movie_cut(), &actors()),
content_id(&other, &movie_cut(), &actors())
);
}
#[test]
fn content_id_is_prefixed_and_hex() {
let id = content_id(&movie_identity(), &movie_cut(), &actors());
let hex = id.strip_prefix("sha256:").expect("prefixed");
assert_eq!(hex.len(), 64);
assert!(hex.bytes().all(|b| b.is_ascii_hexdigit()));
}
#[test]
fn golden_vector_hash_is_stable() {
// Locks the hash so an accidental change to the canonical form is caught
// here rather than by silent federation divergence.
let id = content_id(&movie_identity(), &movie_cut(), &actors());
assert_eq!(id, GOLDEN_VECTORS[0].1, "canonical form or hash changed");
}
}