Completes UR-009. The register recorded the signature as stored; it was not. `ingest` validated `cut.audio_signature` and wrote NULL, so every served manifest came back without one — which also meant the plugin's own alignment (jRay JR-047, Done) had nothing to align against and could never run. That is the failure mode a status field is least able to catch: every validation test passed and the feature delivered nothing. Now stored, coarse-indexed and served back byte-identically, with a held manifest adopting an incoming signature it lacked (§9a). `audio`-tier matching runs on every read endpoint via an `audio_signature` parameter, and `POST /manifests/search` answers the unknown-providence case with a runtime prefilter, a bounded scan and honest truncation reporting. Three rules §3 did not previously state, now normative: - **±1 frame of slack in the score.** scene-actor-extraction VR-014 measured the exact-frame rule demoting 27 of 40 correctly aligned releases to `loose`, because the two windows are cut on their own file's frame grid and those grids do not coincide. With ±1 frame all 40 reach `audio` (worst 0.906) and the strongest false match is unmoved at 0.16. - **The offset has two terms.** Both windows are anchored at their own file's runtime/2, so the slide alone is wrong by half the runtime difference on every shifted release. A signature without a runtime therefore cannot align, and is refused by name rather than answered at a lower tier. - **A signature verdict is final**, including its refusals. Falling back to the runtime tier after the audio declined would let a coincidence overturn direct evidence, inverting the ordering the tier table exists to state. The slide precomputes each frame's neighbourhood as a 32-bit bin set rather than re-deriving it across 1201 slides — 3.3 ms to 1.1 ms per candidate, with a test asserting exact equivalence to the rule written the obvious way. The 1000-candidate search cap follows from that measurement as a ~1.1 s ceiling per request, not a round number. jRay's matcher still implements the pre-slack rule and will label some alignments `loose` that this server calls `audio`. Nothing misaligns — JR-047 makes the local answer win — but that register now carries the follow-up. TRACES: UR-008, UR-009 | SR-003
407 lines
15 KiB
Rust
407 lines
15 KiB
Rust
//! §9a federation — the replication surface and its guarantees.
|
|
//!
|
|
//! The design's whole claim is **"replicate content, re-derive judgement"**, and
|
|
//! each of those halves has a failure mode worth pinning:
|
|
//!
|
|
//! - Replicating content wrongly means a peer can substitute what you serve.
|
|
//! Guarded by verifying the fetched body hashes to the `content_id` requested.
|
|
//! - Inheriting judgement means outsourcing your moderation, or your liability.
|
|
//! Guarded by re-validating everything and never adopting a peer's `status`.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode};
|
|
use http_body_util::BodyExt;
|
|
use jray_server::config::Config;
|
|
use jray_server::db::{repo, Db};
|
|
use jray_server::ratelimit::RateLimiter;
|
|
use jray_server::state::AppState;
|
|
use jray_server::tmdb::TmdbClient;
|
|
use jray_server::{app, worker};
|
|
use serde_json::{json, Value};
|
|
use tower::ServiceExt;
|
|
|
|
struct TempDir(std::path::PathBuf);
|
|
|
|
impl TempDir {
|
|
fn new(tag: &str) -> Self {
|
|
let mut p = std::env::temp_dir();
|
|
p.push(format!("jray-fed-{}-{}", tag, unique()));
|
|
std::fs::create_dir_all(&p).expect("creating temp dir");
|
|
Self(p)
|
|
}
|
|
fn db_path(&self) -> String {
|
|
self.0.join("test.db").to_string_lossy().into_owned()
|
|
}
|
|
}
|
|
|
|
impl Drop for TempDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn unique() -> String {
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
static N: AtomicU64 = AtomicU64::new(0);
|
|
let t = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_nanos())
|
|
.unwrap_or(0);
|
|
format!("{t}-{}", N.fetch_add(1, Ordering::Relaxed))
|
|
}
|
|
|
|
struct TestServer {
|
|
router: axum::Router,
|
|
db: Db,
|
|
_dir: TempDir,
|
|
}
|
|
|
|
impl TestServer {
|
|
fn new(tag: &str, publish_directory: bool) -> Self {
|
|
let dir = TempDir::new(tag);
|
|
let db = Db::open(&dir.db_path()).expect("opening database");
|
|
let config = Arc::new(Config {
|
|
bind: "127.0.0.1:0".into(),
|
|
db_path: dir.db_path(),
|
|
tmdb_api_key: None,
|
|
tmdb_base_url: "http://127.0.0.1:1".into(),
|
|
trusted_proxies: Vec::new(),
|
|
server_id: "local.example".into(),
|
|
publish_peer_directory: publish_directory,
|
|
contact: Some("admin@local.example".into()),
|
|
enable_audio_search: true,
|
|
request_timeout: std::time::Duration::from_secs(30),
|
|
job_batch: 8,
|
|
job_poll_interval: std::time::Duration::from_secs(3600),
|
|
});
|
|
let state = AppState {
|
|
db: db.clone(),
|
|
config: config.clone(),
|
|
limiter: Arc::new(RateLimiter::new()),
|
|
tmdb: Arc::new(TmdbClient::new(config.tmdb_base_url.clone(), None)),
|
|
};
|
|
Self { router: app::router(state), db, _dir: dir }
|
|
}
|
|
|
|
async fn get(&self, uri: &str) -> (StatusCode, Value) {
|
|
self.send(Request::builder().uri(uri).body(Body::empty()).unwrap()).await
|
|
}
|
|
|
|
async fn post(&self, uri: &str, body: &Value) -> (StatusCode, Value) {
|
|
self.send(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(uri)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(body.to_string()))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn send(&self, req: Request<Body>) -> (StatusCode, Value) {
|
|
let resp = self.router.clone().oneshot(req).await.expect("router call");
|
|
let status = resp.status();
|
|
let bytes = resp.into_body().collect().await.expect("body").to_bytes();
|
|
let body = if bytes.is_empty() {
|
|
Value::Null
|
|
} else {
|
|
serde_json::from_slice(&bytes).unwrap_or(Value::Null)
|
|
};
|
|
(status, body)
|
|
}
|
|
|
|
/// Seeds a listed manifest and its change-feed entry, as the cast check would.
|
|
async fn seed_listed(&self, tmdb_id: &str, content_id: &str, origin: &str) -> String {
|
|
let tmdb_id = tmdb_id.to_string();
|
|
let content_id = content_id.to_string();
|
|
let origin = origin.to_string();
|
|
self.db
|
|
.write(move |tx| {
|
|
let now = worker::now_iso();
|
|
let title_id = repo::upsert_title(
|
|
tx,
|
|
jray_server::model::IdentityType::Movie,
|
|
Some(&tmdb_id),
|
|
None,
|
|
Some("A Film"),
|
|
None,
|
|
&now,
|
|
)?;
|
|
let id = format!("m-{tmdb_id}");
|
|
repo::insert_manifest(
|
|
tx,
|
|
&repo::NewManifest {
|
|
id: &id,
|
|
title_id: &title_id,
|
|
season: None,
|
|
episode: None,
|
|
runtime_sec: 6420.5,
|
|
audio_signature: None,
|
|
audio_sig_coarse: None,
|
|
sample_fps: Some(5.0),
|
|
extinction_sec: Some(12.0),
|
|
pipeline_version: Some("test 0.1"),
|
|
gallery_scope: Some("global"),
|
|
contributor_id: None,
|
|
status: "listed",
|
|
content_id: Some(&content_id),
|
|
origin: &origin,
|
|
ingested_from: None,
|
|
created_at: &now,
|
|
},
|
|
)?;
|
|
repo::upsert_person(tx, 884, "Steve Buscemi", false, &now)?;
|
|
repo::insert_actor_scenes(
|
|
tx,
|
|
&id,
|
|
884,
|
|
&[jray_server::validate::SceneCs::plain(19160, 20920)],
|
|
)?;
|
|
repo::append_change(tx, &content_id, "add", None, &origin, &now)?;
|
|
Ok(id)
|
|
})
|
|
.await
|
|
.expect("seeding")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The change feed
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn the_change_feed_is_resumable_from_an_opaque_cursor() {
|
|
let s = TestServer::new("feed", false);
|
|
for i in 0..3 {
|
|
s.seed_listed(&format!("100{i}"), &format!("sha256:c{i}"), "local.example").await;
|
|
}
|
|
|
|
let (status, body) = s.get("/api/v1/federation/changes").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
let changes = body["changes"].as_array().expect("changes");
|
|
assert_eq!(changes.len(), 3);
|
|
assert_eq!(body["server_id"], "local.example");
|
|
|
|
// Resuming from the first entry yields exactly the remainder — the property
|
|
// that makes the feed idempotent for a peer that restarts mid-pull.
|
|
let first = changes[0]["seq"].as_i64().expect("seq is a monotonic integer");
|
|
let (_, body) = s.get(&format!("/api/v1/federation/changes?since={first}")).await;
|
|
assert_eq!(body["changes"].as_array().unwrap().len(), 2);
|
|
|
|
// Resuming from the last yields nothing, and the cursor is absent rather
|
|
// than stale — a peer that polls an idle server does no work.
|
|
let last = changes[2]["seq"].as_i64().unwrap();
|
|
let (_, body) = s.get(&format!("/api/v1/federation/changes?since={last}")).await;
|
|
assert!(body["changes"].as_array().unwrap().is_empty());
|
|
assert!(body["cursor"].is_null());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_feed_limit_is_capped() {
|
|
// §5: a peer must not be able to walk the entire catalogue in one request.
|
|
let s = TestServer::new("feed-limit", false);
|
|
let (status, body) = s.get("/api/v1/federation/changes?limit=999999").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert!(body["changes"].as_array().unwrap().len() <= 1000);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fetch by content id
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn a_listed_manifest_is_fetchable_by_content_id() {
|
|
let s = TestServer::new("byhash", false);
|
|
s.seed_listed("504172", "sha256:abc", "local.example").await;
|
|
|
|
let (status, body) = s.get("/api/v1/federation/manifests/sha256:abc").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body["jmanifest_version"], 2);
|
|
assert_eq!(body["identity"]["tmdb_id"], "504172");
|
|
// Names come from the server's TMDB-derived table, never from an upload.
|
|
assert_eq!(body["actors"][0]["name"], "Steve Buscemi");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_unknown_content_id_is_not_found() {
|
|
let s = TestServer::new("byhash-missing", false);
|
|
let (status, _) = s.get("/api/v1/federation/manifests/sha256:nope").await;
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_pending_manifest_is_not_replicated() {
|
|
// Only content that cleared *this* server's checks is offered onward.
|
|
// Replicating a pending manifest would export an unverified claim.
|
|
let s = TestServer::new("pending-not-replicated", false);
|
|
s.db.write(|tx| {
|
|
let now = worker::now_iso();
|
|
let title_id = repo::upsert_title(
|
|
tx,
|
|
jray_server::model::IdentityType::Movie,
|
|
Some("777"),
|
|
None,
|
|
None,
|
|
None,
|
|
&now,
|
|
)?;
|
|
repo::insert_manifest(
|
|
tx,
|
|
&repo::NewManifest {
|
|
id: "pending-one",
|
|
title_id: &title_id,
|
|
season: None,
|
|
episode: None,
|
|
runtime_sec: 100.0,
|
|
audio_signature: None,
|
|
audio_sig_coarse: None,
|
|
sample_fps: None,
|
|
extinction_sec: None,
|
|
pipeline_version: None,
|
|
gallery_scope: None,
|
|
contributor_id: None,
|
|
status: "pending",
|
|
content_id: Some("sha256:pending"),
|
|
origin: "local.example",
|
|
ingested_from: None,
|
|
created_at: &now,
|
|
},
|
|
)?;
|
|
Ok(())
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let (status, _) = s.get("/api/v1/federation/manifests/sha256:pending").await;
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Batch have
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn have_reports_only_what_is_held() {
|
|
let s = TestServer::new("have", false);
|
|
s.seed_listed("504172", "sha256:held", "local.example").await;
|
|
|
|
let (status, body) = s
|
|
.post(
|
|
"/api/v1/federation/have",
|
|
&json!({ "content_ids": ["sha256:held", "sha256:absent"] }),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body["have"], json!(["sha256:held"]));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn have_is_capped_and_strict() {
|
|
let s = TestServer::new("have-cap", false);
|
|
|
|
let too_many: Vec<String> = (0..1001).map(|i| format!("sha256:{i}")).collect();
|
|
let (status, _) = s.post("/api/v1/federation/have", &json!({ "content_ids": too_many })).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
|
|
// §6 stage 2 applies to federation bodies too — a peer is not exempt.
|
|
let (status, _) =
|
|
s.post("/api/v1/federation/have", &json!({ "content_ids": [], "extra": true })).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Peer directory — publishing, not discovering
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn the_peer_directory_lists_only_advertised_peers() {
|
|
let s = TestServer::new("peers", true);
|
|
s.db.write(|tx| {
|
|
let now = worker::now_iso();
|
|
let a = repo::insert_peer(tx, "https://a.example", Some("Peer A"), &now)?;
|
|
repo::insert_peer(tx, "https://b.example", Some("Peer B"), &now)?;
|
|
// Advertising is opt-in per peer: peering with someone must not
|
|
// publish their existence against their wishes.
|
|
tx.execute("UPDATE peers SET advertise = 1 WHERE id = ?1", rusqlite::params![a])?;
|
|
Ok(())
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let (status, body) = s.get("/api/v1/federation/peers").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
let peers = body["peers"].as_array().unwrap();
|
|
assert_eq!(peers.len(), 1, "only the advertised peer appears");
|
|
assert_eq!(peers[0]["url"], "https://a.example");
|
|
assert_eq!(body["contact"], "admin@local.example");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_peer_directory_can_be_withheld_entirely() {
|
|
// §9a: publishing the directory is itself optional. A server that would
|
|
// rather not disclose its topology simply does not.
|
|
let s = TestServer::new("peers-off", false);
|
|
let (status, _) = s.get("/api/v1/federation/peers").await;
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn there_is_no_endpoint_that_creates_a_peering() {
|
|
// **No automatic peering, ever.** Nothing a remote server says, and no data
|
|
// returned from any endpoint, can establish or widen a peering. This asserts
|
|
// the absence of a write surface — the guarantee is structural, so the test
|
|
// is that the obvious shapes simply do not route.
|
|
let s = TestServer::new("no-auto-peer", true);
|
|
|
|
for (method, uri) in [
|
|
("POST", "/api/v1/federation/peers"),
|
|
("PUT", "/api/v1/federation/peers"),
|
|
("POST", "/api/v1/federation/peer"),
|
|
("POST", "/api/v1/federation/subscribe"),
|
|
("POST", "/api/v1/federation/announce"),
|
|
] {
|
|
let req = Request::builder()
|
|
.method(method)
|
|
.uri(uri)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(r#"{"url":"https://hostile.example"}"#))
|
|
.unwrap();
|
|
let (status, _) = s.send(req).await;
|
|
assert!(
|
|
status == StatusCode::NOT_FOUND || status == StatusCode::METHOD_NOT_ALLOWED,
|
|
"{method} {uri} must not exist, got {status}"
|
|
);
|
|
}
|
|
|
|
// And nothing was created.
|
|
let n = s
|
|
.db
|
|
.read(|conn| Ok(conn.query_row("SELECT COUNT(*) FROM peers", [], |r| r.get::<_, i64>(0))?))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(n, 0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Capabilities
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn capabilities_advertise_the_envelope_version() {
|
|
// The gap this closes: without it, a plugin at v2 talking to a v1 server
|
|
// discovers the mismatch as a 400 per manifest across a whole library sweep.
|
|
let s = TestServer::new("caps", false);
|
|
let (status, body) = s.get("/api/v1/federation/capabilities").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body["jmanifest_versions"], json!([2]));
|
|
assert_eq!(body["federation"], true);
|
|
// §3: the audio surfaces are advertised rather than assumed, because search
|
|
// is optional for a server to implement. This build implements both, so a
|
|
// client that reads this and then sends a signature must not get a 404.
|
|
assert_eq!(body["audio_search"], true);
|
|
assert_eq!(body["audio_tier_matching"], true);
|
|
}
|