Initial implementation: core vertical slice
CI / fmt, clippy, test (push) Failing after 2m46s
CI / static musl binary (push) Has been skipped
CI / advisories and licences (push) Successful in 4m22s

Implements the core of SPEC.md — the manifest exchange, less audio-tier
matching (§3) and federation (§9a), both of which the spec sequences as
later work.

- §2 Jmanifest format and series bundles
- §3 cut matching: exact / runtime / loose tiers
- §4 API, less POST /manifests/search
- §5 rate limiting; §5a trust model, anonymous bearer tokens
- §6 upload validation, all four stages
- §7 relational storage, no JSON blob on the write path
- §8 Rust + Axum + SQLite, single serialized writer, in-process job queue
- §9a content addressing, computed on upload

Reconciled against the system spec:

- anneal_sec removed, withdrawn upstream by AR-012/AR-013. Presence follows
  track extent, so a track survives its own gaps and there is nothing to
  anneal. Its successor extinction_sec and the new gallery_scope are accepted
  and stored; scope enters the §7 ranking. A manifest still carrying
  anneal_sec is a hard 400, not silently ignored — it came from a pipeline
  whose window semantics differ from what this server assumes.
- Audio signature: media under 120 s now emits no signature at all, matching
  scene-actor-extraction IR-007. The earlier §3 draft allowed a shortened
  window under 150 s, which was the weaker rule — a caller-varying length is
  the property SR-004 forbids.
- UR IDs regularised to UR-nnn; docs/requirements.md registers 32
  requirements, each tracing to an SR-nnn or PR-nnn.

189 tests: unit, end-to-end through the real router, and an injection suite
covering SQL, JSON, header and Unicode payloads. Writing that suite found two
real gaps, both fixed here: compatibility homoglyphs passed the §5a character
class, and a one-frame audio signature was accepted on a feature-length item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 18:14:02 +02:00
co-authored by Claude Opus 5
commit a848750a65
38 changed files with 13014 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
//! §5a tokens and client-IP attribution.
//!
//! A token is **not an account** — it is an anonymous bearer capability. No
//! email, no verification, no personal data. It is stored only as a hash, so the
//! server cannot enumerate who holds tokens, and its sole purposes are
//! rate-limiting attribution (§5) and revocation.
//!
//! Discarding a token and requesting another is trivially easy, and that is
//! fine: the token is not the defence, the content checks are. Sybil resistance
//! is not required because identity is not load-bearing.
use std::net::IpAddr;
use axum::http::HeaderMap;
use sha2::{Digest, Sha256};
/// Hashes a bearer token for storage and lookup.
///
/// Plain SHA-256 rather than a password KDF is deliberate and sufficient here:
/// tokens are 256 bits of server-generated randomness, not user-chosen secrets,
/// so there is no dictionary to attack.
pub fn hash_token(token: &str) -> String {
let mut h = Sha256::new();
h.update(token.as_bytes());
hex(&h.finalize())
}
/// Hashes a client IP for report attribution (§7 `reports.source_ip_hash`).
///
/// Salted with the server id so hashes are not comparable across instances.
pub fn hash_ip(ip: &str, server_id: &str) -> String {
let mut h = Sha256::new();
h.update(server_id.as_bytes());
h.update(b"\0");
h.update(ip.as_bytes());
hex(&h.finalize())
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
/// Generates a new token. Returned once to the caller; only its hash is stored.
pub fn generate_token() -> String {
use rand::RngCore;
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
format!("jray_{}", hex(&bytes))
}
/// Extracts a bearer token from an `Authorization` header.
pub fn bearer_token(headers: &HeaderMap) -> Option<String> {
let raw = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?;
let (scheme, value) = raw.split_once(' ')?;
if !scheme.eq_ignore_ascii_case("bearer") {
return None;
}
let value = value.trim();
if value.is_empty() {
return None;
}
Some(value.to_string())
}
/// Resolves the client IP for rate-limiting and report attribution.
///
/// §8: the app must trust `X-Forwarded-For` **only** from the operator's proxy.
/// Rate limiting and report attribution key on client IP, so a spoofable header
/// defeats both — hence `trusted_proxies` is explicit configuration and an
/// untrusted peer's header is ignored outright.
pub fn client_ip(headers: &HeaderMap, peer: Option<IpAddr>, trusted_proxies: &[IpAddr]) -> String {
let peer_is_trusted = peer.is_some_and(|p| trusted_proxies.contains(&p));
if peer_is_trusted {
if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
// Right-most entry is the one our trusted proxy appended; entries to
// its left are client-supplied and forgeable. Walk from the right
// past any further trusted hops.
for candidate in xff.split(',').rev().map(str::trim).filter(|s| !s.is_empty()) {
match candidate.parse::<IpAddr>() {
Ok(ip) if trusted_proxies.contains(&ip) => continue,
Ok(ip) => return ip.to_string(),
Err(_) => break,
}
}
}
}
peer.map(|p| p.to_string()).unwrap_or_else(|| "unknown".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
fn headers(pairs: &[(&'static str, &str)]) -> HeaderMap {
let mut h = HeaderMap::new();
for (k, v) in pairs {
h.insert(*k, HeaderValue::from_str(v).unwrap());
}
h
}
#[test]
fn token_hash_is_stable_and_distinguishing() {
assert_eq!(hash_token("abc"), hash_token("abc"));
assert_ne!(hash_token("abc"), hash_token("abd"));
assert_eq!(hash_token("abc").len(), 64);
}
#[test]
fn generated_tokens_are_unique_and_prefixed() {
let a = generate_token();
let b = generate_token();
assert_ne!(a, b);
assert!(a.starts_with("jray_"));
assert_eq!(a.len(), 5 + 64);
}
#[test]
fn ip_hash_is_salted_per_server() {
// Hashes must not be comparable across instances.
assert_ne!(hash_ip("1.2.3.4", "a.example"), hash_ip("1.2.3.4", "b.example"));
assert_eq!(hash_ip("1.2.3.4", "a.example"), hash_ip("1.2.3.4", "a.example"));
}
#[test]
fn parses_bearer_tokens_case_insensitively() {
assert_eq!(
bearer_token(&headers(&[("authorization", "Bearer xyz")])).as_deref(),
Some("xyz")
);
assert_eq!(
bearer_token(&headers(&[("authorization", "bearer xyz")])).as_deref(),
Some("xyz")
);
assert!(bearer_token(&headers(&[("authorization", "Basic xyz")])).is_none());
assert!(bearer_token(&headers(&[("authorization", "Bearer ")])).is_none());
assert!(bearer_token(&HeaderMap::new()).is_none());
}
#[test]
fn forwarded_header_from_an_untrusted_peer_is_ignored() {
// The whole point of §8's explicit trusted-proxy configuration: an
// arbitrary client must not be able to choose its own rate-limit key.
let h = headers(&[("x-forwarded-for", "9.9.9.9")]);
let peer: IpAddr = "203.0.113.7".parse().unwrap();
assert_eq!(client_ip(&h, Some(peer), &[]), "203.0.113.7");
}
#[test]
fn forwarded_header_from_a_trusted_proxy_is_honoured() {
let h = headers(&[("x-forwarded-for", "9.9.9.9")]);
let proxy: IpAddr = "127.0.0.1".parse().unwrap();
assert_eq!(client_ip(&h, Some(proxy), &[proxy]), "9.9.9.9");
}
#[test]
fn client_supplied_entries_left_of_the_proxy_cannot_spoof() {
// A client that sends its own XFF gets its value appended to, not
// replaced, so only the right-most entry is trustworthy.
let h = headers(&[("x-forwarded-for", "9.9.9.9, 203.0.113.7")]);
let proxy: IpAddr = "127.0.0.1".parse().unwrap();
assert_eq!(client_ip(&h, Some(proxy), &[proxy]), "203.0.113.7");
}
#[test]
fn walks_past_additional_trusted_hops() {
let inner: IpAddr = "10.0.0.2".parse().unwrap();
let proxy: IpAddr = "127.0.0.1".parse().unwrap();
let h = headers(&[("x-forwarded-for", "203.0.113.7, 10.0.0.2")]);
assert_eq!(client_ip(&h, Some(proxy), &[proxy, inner]), "203.0.113.7");
}
#[test]
fn malformed_forwarded_value_falls_back_to_the_peer() {
let h = headers(&[("x-forwarded-for", "not-an-ip")]);
let proxy: IpAddr = "127.0.0.1".parse().unwrap();
assert_eq!(client_ip(&h, Some(proxy), &[proxy]), "127.0.0.1");
}
}