//! §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, pub imdb_id: Option, pub season: Option, pub episode: Option, } /// 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, } /// 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) -> Option { 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) { 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 { 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"); } }