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>
This commit is contained in:
@@ -58,6 +58,7 @@ pub struct BatchResponse {
|
||||
pub results: Vec<ExistsResponse>,
|
||||
}
|
||||
|
||||
/// TRACES: UR-001 | SR-001
|
||||
pub async fn exists(
|
||||
State(state): State<AppState>,
|
||||
peer: crate::state::PeerIp,
|
||||
@@ -70,6 +71,7 @@ pub async fn exists(
|
||||
Ok(with_quota_headers(Json(body).into_response(), quota))
|
||||
}
|
||||
|
||||
/// TRACES: UR-001, UR-007 | SR-001 | PR-005
|
||||
pub async fn exists_batch(
|
||||
State(state): State<AppState>,
|
||||
peer: crate::state::PeerIp,
|
||||
|
||||
@@ -125,6 +125,7 @@ async fn fetch_best(
|
||||
/// bundle with 9 of 13 episodes is a valid, useful response, not an error (§2).
|
||||
/// Episode-level cut matching is done client-side against the returned bundle,
|
||||
/// since a client pulling a whole series already knows its own runtimes.
|
||||
/// TRACES: UR-006 | PR-006
|
||||
pub async fn get_series(
|
||||
State(state): State<AppState>,
|
||||
peer: crate::state::PeerIp,
|
||||
@@ -263,6 +264,7 @@ fn title_of(conn: &rusqlite::Connection, title_id: &str) -> anyhow::Result<repo:
|
||||
/// Names come from `people` — populated from TMDB by the server — so `name` is
|
||||
/// server-authoritative on download and a name a contributor invented does not
|
||||
/// round-trip (§2, §5a).
|
||||
/// TRACES: UR-010, UR-013 | DR-002 | SR-001, SR-002
|
||||
pub fn reconstruct(
|
||||
conn: &rusqlite::Connection,
|
||||
row: &ManifestRow,
|
||||
|
||||
@@ -97,6 +97,7 @@ where
|
||||
/// serde_json would reject non-UTF-8 anyway; the value added here is a diagnosable
|
||||
/// error rather than a misleading one. A UTF-16 body otherwise fails with "key
|
||||
/// must be a string", which points an operator at the wrong problem entirely.
|
||||
/// TRACES: DR-010, DR-013 | SR-003
|
||||
fn require_utf8(bytes: &[u8]) -> Result<&str, ApiError> {
|
||||
// A BOM is not valid JSON (RFC 8259 §8.1: "implementations MUST NOT add a
|
||||
// byte order mark"), and it is the clearest signal of an encoding mistake, so
|
||||
|
||||
@@ -53,6 +53,7 @@ pub struct ReportAccepted {
|
||||
pub report_id: String,
|
||||
}
|
||||
|
||||
/// TRACES: UR-005 | SR-004
|
||||
pub async fn post_report(
|
||||
State(state): State<AppState>,
|
||||
peer: crate::state::PeerIp,
|
||||
|
||||
@@ -26,6 +26,7 @@ pub struct UploadAccepted {
|
||||
}
|
||||
|
||||
/// `POST /manifests` — UR-2.
|
||||
/// TRACES: UR-002, UR-003 | SR-004 | PR-006
|
||||
pub async fn post_manifest(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -97,6 +98,7 @@ pub struct BundleAccepted {
|
||||
///
|
||||
/// **One rate-limit unit**, so contributing a season is not punished relative to
|
||||
/// contributing a film (§2, §5).
|
||||
/// TRACES: UR-006 | PR-006
|
||||
pub async fn post_bundle(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -218,6 +220,7 @@ pub struct TokenIssued {
|
||||
/// only as a hash, so the server cannot enumerate who holds tokens. Discarding a
|
||||
/// token and requesting another is trivially easy — and that is fine, because the
|
||||
/// token is not the defence; the content checks are.
|
||||
/// TRACES: UR-005 | SR-004
|
||||
pub async fn post_token(
|
||||
State(state): State<AppState>,
|
||||
peer: crate::state::PeerIp,
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::validate::limits;
|
||||
/// 100 items.
|
||||
const SMALL_BODY_LIMIT: usize = 256 * 1024;
|
||||
|
||||
/// TRACES: DR-009, DR-013 | SR-004
|
||||
pub fn router(state: AppState) -> Router {
|
||||
let timeout = state.config.request_timeout;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ use sha2::{Digest, Sha256};
|
||||
/// 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.
|
||||
/// TRACES: UR-005 | SR-004
|
||||
pub fn hash_token(token: &str) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(token.as_bytes());
|
||||
@@ -72,6 +73,7 @@ pub fn bearer_token(headers: &HeaderMap) -> Option<String> {
|
||||
/// 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.
|
||||
/// TRACES: UR-004 | DR-008 | SR-004
|
||||
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));
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ fn name_key(s: &str) -> String {
|
||||
///
|
||||
/// `credits` is the reference set *C*: for a movie, its credits; for an episode,
|
||||
/// the union of per-episode credits and the series' aggregate credits.
|
||||
/// TRACES: UR-003, UR-005, UR-010 | SR-001, SR-004
|
||||
pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome {
|
||||
let m = submitted.len();
|
||||
|
||||
@@ -211,6 +212,7 @@ fn classify(m: usize, matches: usize, ratio: f64) -> Verdict {
|
||||
///
|
||||
/// Rejects when a matched person is flagged adult by TMDB and the target title
|
||||
/// is not, which targets the stated prank without needing a blocklist of names.
|
||||
/// TRACES: UR-005 | SR-004
|
||||
pub fn category_guard_violation(matched: &[MatchedActor], title_is_adult: bool) -> Option<u64> {
|
||||
if title_is_adult {
|
||||
return None;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
/// TRACES: DR-008 | PR-004
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub bind: String,
|
||||
|
||||
@@ -49,6 +49,7 @@ pub struct CanonicalCut {
|
||||
///
|
||||
/// `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,
|
||||
@@ -125,6 +126,7 @@ fn push_opt_num(s: &mut String, v: Option<i64>) {
|
||||
}
|
||||
|
||||
/// `sha256:` over the canonical form (§9a).
|
||||
/// TRACES: DR-011 | SR-003
|
||||
pub fn content_id(
|
||||
identity: &CanonicalIdentity,
|
||||
cut: &CanonicalCut,
|
||||
|
||||
@@ -27,6 +27,7 @@ const SCHEMA: &str = include_str!("schema.sql");
|
||||
/// Handle to the database: one serialized writer, plus read connections.
|
||||
///
|
||||
/// Cloning is cheap and shares the same underlying connections.
|
||||
/// TRACES: DR-003 | PR-004
|
||||
#[derive(Clone)]
|
||||
pub struct Db {
|
||||
writer: Arc<Mutex<Connection>>,
|
||||
|
||||
@@ -334,6 +334,7 @@ pub struct NewManifest<'a> {
|
||||
pub created_at: &'a str,
|
||||
}
|
||||
|
||||
/// TRACES: UR-012 | DR-002, DR-004 | SR-004, SR-005
|
||||
pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()> {
|
||||
tx.execute(
|
||||
"INSERT INTO manifests
|
||||
@@ -401,6 +402,7 @@ pub struct StoredActor {
|
||||
pub scenes_cs: Vec<(i64, i64)>,
|
||||
}
|
||||
|
||||
/// TRACES: UR-010 | DR-002 | SR-001
|
||||
pub fn actors_for_manifest(
|
||||
conn: &Connection,
|
||||
manifest_id: &str,
|
||||
@@ -686,6 +688,7 @@ pub fn enqueue_job(
|
||||
|
||||
/// Claims up to `limit` due jobs, marking them leased so a second worker tick
|
||||
/// cannot pick up the same work.
|
||||
/// TRACES: DR-005 | PR-004
|
||||
pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result<Vec<Job>> {
|
||||
let jobs: Vec<Job> = {
|
||||
let mut stmt = tx.prepare(
|
||||
|
||||
@@ -5,6 +5,7 @@ use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
/// TRACES: DR-013 | SR-003
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ApiError {
|
||||
/// §6 stage 2 — malformed, unrecognised or forbidden field. The message
|
||||
|
||||
@@ -47,6 +47,7 @@ pub const JOB_CAST_CHECK: &str = "cast_check";
|
||||
/// Persists a validated manifest and enqueues its cast check, all in one
|
||||
/// transaction — so a manifest is never left listed-but-unchecked, and its scene
|
||||
/// rows go in as a single transaction rather than one per row (§8).
|
||||
/// TRACES: UR-002, UR-012 | SR-005 | PR-006
|
||||
pub fn persist(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
valid: &ValidManifest,
|
||||
|
||||
@@ -49,6 +49,7 @@ pub struct CutMatch {
|
||||
|
||||
/// Compares a client's cut against a stored one, returning the best tier that
|
||||
/// fires, or `None` for "beyond that: no match; do not serve" (§3).
|
||||
/// TRACES: UR-001 | SR-001
|
||||
pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option<CutMatch> {
|
||||
// Tier 1 — same file. Checked first and unconditionally: an equal hash is
|
||||
// decisive regardless of what the runtimes say.
|
||||
|
||||
@@ -153,6 +153,7 @@ impl GalleryScope {
|
||||
/// than something silently ignored, which is deliberate: a manifest still
|
||||
/// carrying it was produced by a pipeline whose window semantics differ from
|
||||
/// what this server now assumes.
|
||||
/// TRACES: UR-003, UR-011 | SR-004
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Extraction {
|
||||
@@ -175,6 +176,7 @@ pub struct Extraction {
|
||||
/// Note there is no `jellyfin_id` field: `deny_unknown_fields` means its
|
||||
/// presence is a parse error, which is exactly the §2/§6 requirement that it be
|
||||
/// *rejected on upload* rather than merely ignored on download.
|
||||
/// TRACES: UR-010, UR-013 | SR-001, SR-002
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Actor {
|
||||
@@ -193,6 +195,7 @@ pub struct Actor {
|
||||
}
|
||||
|
||||
/// One shareable actor timeline for one cut of one title (§2).
|
||||
/// TRACES: UR-003, UR-011, UR-014 | SR-003, SR-004
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Jmanifest {
|
||||
|
||||
@@ -73,6 +73,7 @@ struct Window {
|
||||
count: u32,
|
||||
}
|
||||
|
||||
/// TRACES: UR-004 | DR-006 | SR-004
|
||||
pub struct RateLimiter {
|
||||
windows: Mutex<HashMap<(String, Surface), Window>>,
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ pub struct ActorScenes {
|
||||
/// than dodging it: pipeline timings are *derived* by accumulating `1/fps`, so
|
||||
/// they carry accumulated error, and any value near a rounding boundary would
|
||||
/// otherwise hash differently on two servers.
|
||||
/// TRACES: DR-011 | SR-003
|
||||
pub fn to_centiseconds(secs: f64) -> i64 {
|
||||
(secs * 100.0).round() as i64
|
||||
}
|
||||
@@ -108,6 +109,7 @@ fn is_tmdb_id(s: &str) -> bool {
|
||||
///
|
||||
/// No digits and no `/ + =`, which is what **defeats base64/hex smuggling**. No
|
||||
/// control characters, and no zero-width or bidi-control codepoints.
|
||||
/// TRACES: UR-011 | SR-004
|
||||
fn is_allowed_text_char(c: char) -> bool {
|
||||
if matches!(c, '.' | '\'' | '-' | ',' | ' ') {
|
||||
return true;
|
||||
@@ -198,6 +200,7 @@ fn check_not_path_shaped(field: &str, value: &str) -> VResult<()> {
|
||||
// Manifest validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// TRACES: UR-003, UR-014 | SR-003, SR-004
|
||||
pub fn validate_manifest(mut m: Jmanifest) -> VResult<ValidManifest> {
|
||||
if m.jmanifest_version != JMANIFEST_VERSION {
|
||||
return Err(err(
|
||||
@@ -348,6 +351,7 @@ fn validate_video_hash(h: &str) -> VResult<()> {
|
||||
///
|
||||
/// `runtime_sec` is needed because §3 shortens the window for very short items;
|
||||
/// see [`expected_min_frames`].
|
||||
/// TRACES: UR-009, UR-011 | SR-003, SR-004
|
||||
pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()> {
|
||||
// IR-007: media shorter than the window emits **no signature**, and no sync
|
||||
// offset is applied to it. A signature present on such an item did not come
|
||||
@@ -479,6 +483,7 @@ fn validate_actors(m: &Jmanifest) -> VResult<Vec<ActorScenes>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// TRACES: UR-013 | SR-002
|
||||
fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<(i64, i64)>> {
|
||||
if a.scenes.len() > limits::MAX_SCENES_PER_ACTOR {
|
||||
return Err(err(
|
||||
@@ -532,6 +537,7 @@ fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<(i64,
|
||||
/// episodes are reported in the per-episode results list — the bundle is not
|
||||
/// atomic, because all-or-nothing would let one bad episode discard an entire
|
||||
/// season's compute (§2).
|
||||
/// TRACES: UR-006 | PR-006
|
||||
pub fn validate_bundle_envelope(b: &SeriesBundle) -> VResult<()> {
|
||||
if b.jmanifest_version != JMANIFEST_VERSION {
|
||||
return Err(err(
|
||||
|
||||
@@ -93,6 +93,7 @@ impl Worker {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// TRACES: UR-003, UR-005 | SR-004
|
||||
async fn run_cast_check(&self, payload: &str) -> Result<(), JobError> {
|
||||
let job: CastCheckJob =
|
||||
serde_json::from_str(payload).map_err(|e| JobError::Fatal(e.into()))?;
|
||||
|
||||
Reference in New Issue
Block a user