diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..42e519c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "scripts/vendor/jray-project"] + path = scripts/vendor/jray-project + url = git@gitea.tourolle.paris:dtourolle/jray-project.git diff --git a/README.md b/README.md index bd277ce..ff8f01e 100644 --- a/README.md +++ b/README.md @@ -110,10 +110,23 @@ curl -sX POST -H 'content-type: application/json' -d '{}' \ ## Tests ```sh -cargo test # 178 tests -cargo deny check # advisories, licences, bans, sources +cargo test # 189 tests +cargo deny check # advisories, licences, bans, sources +scripts/traceability-gate.sh # requirement coverage ``` +The traceability gate needs the shared tooling submodule: + +```sh +git submodule update --init --recursive +``` + +It reports coverage against [`docs/requirements.md`](docs/requirements.md), +flags orphan tags (an ID no register defines), and fails on a >100% ratio — the +signal that the computation itself is broken. Currently **23/32 (71.9%)**; the +untraced nine are UR-007 (plugin-side), UR-008 (federation) and UR-015..018 (the +pending SR-003 bump), none of which is implemented yet. + Unit tests per module, plus two integration suites: - `tests/api.rs` — end-to-end through the real router: status codes, headers, and diff --git a/docs/traceability.md b/docs/traceability.md new file mode 100644 index 0000000..1ae5904 --- /dev/null +++ b/docs/traceability.md @@ -0,0 +1,352 @@ +# Requirements traceability matrix + + + + +**Generated:** 2026-07-30T16:25:39+00:00 + +Denominators are read from [`requirements.md`](requirements.md) at run time, never hardcoded. Coverage counts a requirement only when it is tagged in source **and** has a verification tier this CI host can execute — CI is an Intel N100 with no discrete GPU. + +## Summary + +| Metric | Value | +|---|---| +| Source files scanned | 26 | +| TRACES tags found | 35 | +| EXCEPTION tags found | 0 | +| Requirements defined | 32 | +| Requirements covered | 23 | +| **Coverage** | **71.9%** (23/32) | +| Coverage of CI-executable scope | 71.9% (23/32) | +| Tagged but unexecuted in CI (T4/GPU) | 0 | +| Orphan tags | 0 | + +### By type + +| Type | Covered | Tagged but unexecuted | Defined | +|---|---|---|---| +| UR | 13 | 0 | 18 | +| DR | 10 | 0 | 14 | + +- **PR** tags present (separate taxonomy, not counted in coverage): PR-004, PR-005, PR-006 +- **SR** tags present (separate taxonomy, not counted in coverage): SR-001, SR-002, SR-003, SR-004, SR-005 + +## Not executable in CI + +CI runs on an Intel N100 with no discrete GPU. These requirements have no verification tier that can run here, so a tag on them is evidence of *intent*, not of verification. They are never counted as covered. + +_None._ + +## Orphan tags + +A tag naming an ID `requirements.md` does not define. This is what renumbering produces, and what a typo produces. + +_None._ + +## Requirements tracing up to nothing + +A register row whose `Traces to` cell names no parent. Work serving no stated goal is how scope creeps in, and it is invisible unless something looks. + +_None._ + +## Recorded exceptions + +Deliberate, documented departures from an invariant (`EXCEPTION: AR-nnn `). Reported separately and never counted as coverage — an exception is a decision to be reviewed, not evidence a requirement is met. + +_None._ + +## Register + +| ID | Status | Tier | Traces to | Trace state | Tagged in | Requirement | +|---|---|---|---|---|---|---| +| UR-001 | Done | T2 | SR-001 | covered | `src/api/exists.rs`, `src/matching.rs` | Cheap existence probe, separate from the fetch, returning availabilit… | +| UR-002 | Done | T2 | PR-006 | covered | `src/api/upload.rs`, `src/ingest.rs` | Accept a contributed manifest for a media item | +| UR-003 | Done | T1, T2 | SR-004 | covered | `src/api/upload.rs`, `src/castcheck.rs`, `src/model.rs`, `src/validate.rs`, `src/worker.rs` | Content verification: strict schema, size caps, approximate TMDB cast… | +| UR-004 | Done | T1, T2 | SR-004 | covered | `src/auth.rs`, `src/ratelimit.rs` | Rate limiting, per token where present and per source IP otherwise | +| UR-005 | Done | T1, T2 | SR-004 | covered | `src/api/report.rs`, `src/api/upload.rs`, `src/auth.rs`, `src/castcheck.rs`, `src/worker.rs` | Trust without accounts: not usable as a content store, nor for prank … | +| UR-006 | Done | T2 | PR-006 | covered | `src/api/fetch.rs`, `src/api/upload.rs`, `src/validate.rs` | Serve and accept a whole series in one operation | +| UR-007 | In Progress | unset | PR-005 | covered | `src/api/exists.rs` | Plugin queries an ordered, configurable list of servers | +| UR-008 | Planned | unset | PR-006 | untagged | - | Servers replicate manifests between each other | +| UR-009 | In Progress | T1 | SR-003 | covered | `src/validate.rs` | Store an audio spectral-peak signature for content-based identificati… | +| UR-010 | Done | T1, T2 | SR-001 | covered | `src/api/fetch.rs`, `src/castcheck.rs`, `src/db/repo.rs`, `src/model.rs` | Identity crossing the API boundary is TMDB/IMDB ids, never a name alo… | +| UR-011 | Done | T2 | SR-004 | covered | `src/model.rs`, `src/validate.rs` | Reject any field capable of carrying binary or attacker-chosen content | +| UR-012 | Done | T2 | SR-005 | covered | `src/db/repo.rs`, `src/ingest.rs` | Never accept, store, or serve gallery data — reference faces or embed… | +| UR-013 | Done | T1 | SR-002 | covered | `src/api/fetch.rs`, `src/model.rs`, `src/validate.rs` | Windows are scene-scoped claims; never reinterpret their boundaries | +| UR-014 | Done | T1 | SR-003 | covered | `src/model.rs`, `src/validate.rs` | Reject an unknown `jmanifest_version` outright, never guess | +| UR-015 | Planned | unset | SR-003 | untagged | - | Accept `extraction.extinction_sec` in place of `anneal_sec` | +| UR-016 | Planned | unset | SR-003 | untagged | - | Accept and store `extraction.gallery_scope`; rank on it (§7) | +| UR-017 | Planned | unset | SR-003 | untagged | - | Accept per-window belief and identification route; `scenes` becomes o… | +| UR-018 | Planned | unset | SR-003 | untagged | - | Exclude belief from `content_id`, replicating it as an attribute | +| DR-001 | Done | T1 | SR-004 | untagged | - | Strict parse boundary: unknown fields rejected structurally, not by v… | +| DR-002 | Done | unset | SR-004 | covered | `src/api/fetch.rs`, `src/db/repo.rs` | Fully relational storage — no JSON blob on the write path | +| DR-003 | Done | T1 | PR-004 | covered | `src/db/mod.rs` | Single serialized writer connection, with a read pool alongside | +| DR-004 | Done | unset | PR-004 | covered | `src/db/repo.rs` | All database access behind a repository layer, not scattered through … | +| DR-005 | Done | T1 | PR-004 | covered | `src/db/repo.rs` | Background work in-process, with the job queue as a table so it survi… | +| DR-006 | Done | unset | PR-004 | covered | `src/ratelimit.rs` | Rate-limit counters in process memory; no external counter store | +| DR-007 | Done | unset | PR-004 | untagged | - | Ship a single static binary plus one database file; container optional | +| DR-008 | Done | T2 | SR-004 | covered | `src/auth.rs`, `src/config.rs` | `X-Forwarded-For` honoured only from explicitly configured proxies | +| DR-009 | Done | T2 | SR-004 | covered | `src/app.rs` | Body caps enforced while streaming, before parsing, per route | +| DR-010 | Done | T1 | SR-003 | covered | `src/api/json.rs` | Request bodies are UTF-8 only, rejected with a diagnosable error othe… | +| DR-011 | Done | T1 | SR-003 | covered | `src/content_id.rs`, `src/validate.rs` | `content_id` canonical form is byte-stable and cross-implementation t… | +| DR-012 | Done | unset | PR-004 | untagged | - | Dependency audit: advisories, licence policy, source policy | +| DR-013 | Done | T1 | SR-003 | covered | `src/api/json.rs`, `src/app.rs`, `src/error.rs` | API errors use the status codes the spec names, not the framework's d… | +| DR-014 | Done | unset | PR-004 | untagged | - | Portable SQL — no SQLite-specific form where a standard one exists | + +## Detailed mapping + +### DR-002 + +**Locations:** 3 + +- [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `Unknown` +- [`src/db/repo.rs:337`](../src/db/repo.rs#L337) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>` +- [`src/db/repo.rs:405`](../src/db/repo.rs#L405) — `Unknown` + +### DR-003 + +**Locations:** 1 + +- [`src/db/mod.rs:30`](../src/db/mod.rs#L30) — `struct ReadPool` + +### DR-004 + +**Locations:** 1 + +- [`src/db/repo.rs:337`](../src/db/repo.rs#L337) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>` + +### DR-005 + +**Locations:** 1 + +- [`src/db/repo.rs:691`](../src/db/repo.rs#L691) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result, trusted_proxies: &[IpAddr]) -…` +- [`src/config.rs:10`](../src/config.rs#L10) — `Unknown` + +### DR-009 + +**Locations:** 1 + +- [`src/app.rs:26`](../src/app.rs#L26) — `pub fn router(state: AppState) -> Router` + +### DR-010 + +**Locations:** 1 + +- [`src/api/json.rs:100`](../src/api/json.rs#L100) — `fn require_utf8(bytes: &[u8]) -> Result<&str, ApiError>` + +### DR-011 + +**Locations:** 3 + +- [`src/content_id.rs:52`](../src/content_id.rs#L52) — `Unknown` +- [`src/content_id.rs:129`](../src/content_id.rs#L129) — `fn push_opt_num(s: &mut String, v: Option)` +- [`src/validate.rs:78`](../src/validate.rs#L78) — `pub fn to_centiseconds(secs: f64) -> i64` + +### DR-013 + +**Locations:** 3 + +- [`src/api/json.rs:100`](../src/api/json.rs#L100) — `fn require_utf8(bytes: &[u8]) -> Result<&str, ApiError>` +- [`src/app.rs:26`](../src/app.rs#L26) — `pub fn router(state: AppState) -> Router` +- [`src/error.rs:8`](../src/error.rs#L8) — `Unknown` + +### PR-004 + +**Locations:** 3 + +- [`src/config.rs:10`](../src/config.rs#L10) — `Unknown` +- [`src/db/mod.rs:30`](../src/db/mod.rs#L30) — `struct ReadPool` +- [`src/db/repo.rs:691`](../src/db/repo.rs#L691) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result VResult<()>` + +### SR-001 + +**Locations:** 7 + +- [`src/api/exists.rs:61`](../src/api/exists.rs#L61) — `Unknown` +- [`src/api/exists.rs:74`](../src/api/exists.rs#L74) — `Unknown` +- [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `Unknown` +- [`src/castcheck.rs:82`](../src/castcheck.rs#L82) — `pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome` +- [`src/db/repo.rs:405`](../src/db/repo.rs#L405) — `Unknown` +- [`src/matching.rs:52`](../src/matching.rs#L52) — `pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option` +- [`src/model.rs:179`](../src/model.rs#L179) — `Unknown` + +### SR-002 + +**Locations:** 3 + +- [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `Unknown` +- [`src/model.rs:179`](../src/model.rs#L179) — `Unknown` +- [`src/validate.rs:486`](../src/validate.rs#L486) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult>` + +### SR-003 + +**Locations:** 8 + +- [`src/api/json.rs:100`](../src/api/json.rs#L100) — `fn require_utf8(bytes: &[u8]) -> Result<&str, ApiError>` +- [`src/content_id.rs:52`](../src/content_id.rs#L52) — `Unknown` +- [`src/content_id.rs:129`](../src/content_id.rs#L129) — `fn push_opt_num(s: &mut String, v: Option)` +- [`src/error.rs:8`](../src/error.rs#L8) — `Unknown` +- [`src/model.rs:198`](../src/model.rs#L198) — `Unknown` +- [`src/validate.rs:78`](../src/validate.rs#L78) — `pub fn to_centiseconds(secs: f64) -> i64` +- [`src/validate.rs:203`](../src/validate.rs#L203) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult` +- [`src/validate.rs:354`](../src/validate.rs#L354) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>` + +### SR-004 + +**Locations:** 16 + +- [`src/api/report.rs:56`](../src/api/report.rs#L56) — `Unknown` +- [`src/api/upload.rs:29`](../src/api/upload.rs#L29) — `Unknown` +- [`src/api/upload.rs:223`](../src/api/upload.rs#L223) — `Unknown` +- [`src/app.rs:26`](../src/app.rs#L26) — `pub fn router(state: AppState) -> Router` +- [`src/auth.rs:22`](../src/auth.rs#L22) — `pub fn hash_token(token: &str) -> String` +- [`src/auth.rs:76`](../src/auth.rs#L76) — `pub fn client_ip(headers: &HeaderMap, peer: Option, trusted_proxies: &[IpAddr]) -…` +- [`src/castcheck.rs:82`](../src/castcheck.rs#L82) — `pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome` +- [`src/castcheck.rs:215`](../src/castcheck.rs#L215) — `pub fn category_guard_violation(matched: &[MatchedActor], title_is_adult: bool) -> Option…` +- [`src/db/repo.rs:337`](../src/db/repo.rs#L337) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>` +- [`src/model.rs:156`](../src/model.rs#L156) — `Unknown` +- [`src/model.rs:198`](../src/model.rs#L198) — `Unknown` +- [`src/ratelimit.rs:76`](../src/ratelimit.rs#L76) — `struct Window` +- [`src/validate.rs:112`](../src/validate.rs#L112) — `fn is_allowed_text_char(c: char) -> bool` +- [`src/validate.rs:203`](../src/validate.rs#L203) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult` +- [`src/validate.rs:354`](../src/validate.rs#L354) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>` +- [`src/worker.rs:96`](../src/worker.rs#L96) — `async fn run_cast_check(&self, payload: &str) -> Result<(), JobError>` + +### SR-005 + +**Locations:** 2 + +- [`src/db/repo.rs:337`](../src/db/repo.rs#L337) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>` +- [`src/ingest.rs:50`](../src/ingest.rs#L50) — `Unknown` + +### UR-001 + +**Locations:** 3 + +- [`src/api/exists.rs:61`](../src/api/exists.rs#L61) — `Unknown` +- [`src/api/exists.rs:74`](../src/api/exists.rs#L74) — `Unknown` +- [`src/matching.rs:52`](../src/matching.rs#L52) — `pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option` + +### UR-002 + +**Locations:** 2 + +- [`src/api/upload.rs:29`](../src/api/upload.rs#L29) — `Unknown` +- [`src/ingest.rs:50`](../src/ingest.rs#L50) — `Unknown` + +### UR-003 + +**Locations:** 6 + +- [`src/api/upload.rs:29`](../src/api/upload.rs#L29) — `Unknown` +- [`src/castcheck.rs:82`](../src/castcheck.rs#L82) — `pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome` +- [`src/model.rs:156`](../src/model.rs#L156) — `Unknown` +- [`src/model.rs:198`](../src/model.rs#L198) — `Unknown` +- [`src/validate.rs:203`](../src/validate.rs#L203) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult` +- [`src/worker.rs:96`](../src/worker.rs#L96) — `async fn run_cast_check(&self, payload: &str) -> Result<(), JobError>` + +### UR-004 + +**Locations:** 2 + +- [`src/auth.rs:76`](../src/auth.rs#L76) — `pub fn client_ip(headers: &HeaderMap, peer: Option, trusted_proxies: &[IpAddr]) -…` +- [`src/ratelimit.rs:76`](../src/ratelimit.rs#L76) — `struct Window` + +### UR-005 + +**Locations:** 6 + +- [`src/api/report.rs:56`](../src/api/report.rs#L56) — `Unknown` +- [`src/api/upload.rs:223`](../src/api/upload.rs#L223) — `Unknown` +- [`src/auth.rs:22`](../src/auth.rs#L22) — `pub fn hash_token(token: &str) -> String` +- [`src/castcheck.rs:82`](../src/castcheck.rs#L82) — `pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome` +- [`src/castcheck.rs:215`](../src/castcheck.rs#L215) — `pub fn category_guard_violation(matched: &[MatchedActor], title_is_adult: bool) -> Option…` +- [`src/worker.rs:96`](../src/worker.rs#L96) — `async fn run_cast_check(&self, payload: &str) -> Result<(), JobError>` + +### UR-006 + +**Locations:** 3 + +- [`src/api/fetch.rs:128`](../src/api/fetch.rs#L128) — `Unknown` +- [`src/api/upload.rs:101`](../src/api/upload.rs#L101) — `Unknown` +- [`src/validate.rs:540`](../src/validate.rs#L540) — `pub fn validate_bundle_envelope(b: &SeriesBundle) -> VResult<()>` + +### UR-007 + +**Locations:** 1 + +- [`src/api/exists.rs:74`](../src/api/exists.rs#L74) — `Unknown` + +### UR-009 + +**Locations:** 1 + +- [`src/validate.rs:354`](../src/validate.rs#L354) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>` + +### UR-010 + +**Locations:** 4 + +- [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `Unknown` +- [`src/castcheck.rs:82`](../src/castcheck.rs#L82) — `pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome` +- [`src/db/repo.rs:405`](../src/db/repo.rs#L405) — `Unknown` +- [`src/model.rs:179`](../src/model.rs#L179) — `Unknown` + +### UR-011 + +**Locations:** 4 + +- [`src/model.rs:156`](../src/model.rs#L156) — `Unknown` +- [`src/model.rs:198`](../src/model.rs#L198) — `Unknown` +- [`src/validate.rs:112`](../src/validate.rs#L112) — `fn is_allowed_text_char(c: char) -> bool` +- [`src/validate.rs:354`](../src/validate.rs#L354) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>` + +### UR-012 + +**Locations:** 2 + +- [`src/db/repo.rs:337`](../src/db/repo.rs#L337) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>` +- [`src/ingest.rs:50`](../src/ingest.rs#L50) — `Unknown` + +### UR-013 + +**Locations:** 3 + +- [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `Unknown` +- [`src/model.rs:179`](../src/model.rs#L179) — `Unknown` +- [`src/validate.rs:486`](../src/validate.rs#L486) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult>` + +### UR-014 + +**Locations:** 2 + +- [`src/model.rs:198`](../src/model.rs#L198) — `Unknown` +- [`src/validate.rs:203`](../src/validate.rs#L203) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult` + diff --git a/scripts/traceability-gate.sh b/scripts/traceability-gate.sh new file mode 100755 index 0000000..6961ecd --- /dev/null +++ b/scripts/traceability-gate.sh @@ -0,0 +1,58 @@ +#!/bin/sh +# +# Requirement traceability gate for JRay-public-server. +# +# A thin wrapper over the shared implementation in the `jray-project` submodule. +# Everything repo-specific lives here; the tooling itself is identical across all +# three components, so a fix to the gate benefits every repo rather than one. +# +# scripts/traceability-gate.sh +# +# The submodule must be checked out. If `scripts/vendor/jray-project` is empty: +# +# git submodule update --init --recursive +# +# Environment (passed through to the shared gate): +# MIN_COVERAGE minimum overall coverage percent +# ALLOW_ORPHANS set to 1 to report orphan tags without failing + +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +VENDOR="$SCRIPT_DIR/vendor/jray-project" + +if [ ! -f "$VENDOR/scripts/traceability/traceability-gate.sh" ]; then + echo "FAILED: the jray-project submodule is not checked out." >&2 + echo " Run: git submodule update --init --recursive" >&2 + exit 2 +fi + +# Why each override is needed, since omitting any of them fails silently in a +# way that looks like "no work done" rather than "misconfigured": +# +# REPO_ROOT the shared gate defaults to two levels above itself, which is +# inside the submodule once vendored. +# TYPES this repo's register uses UR/DR. The default is the extraction +# pipeline's AR/DP/IR/GR/VR, under which the register parses to +# ZERO requirements. +# SUFFIXES this repo is Rust. The default is C++/Python, under which the +# source tree scans to ZERO files. +# SYSTEM_SPEC the PR/SR requirements live in the project home, which is the +# submodule itself — so it ships with the tool that reads it. +# +# MIN_COVERAGE stays 0 until the TRACES annotation pass lands. That does not +# make the gate toothless: orphan tags, a >100% ratio, a register parsing to +# nothing and an empty source scan are all hard failures from day one. Raise it +# as tags land, and treat every raise as a ratchet. +REPO_ROOT="$REPO_ROOT" \ +REQUIREMENTS="$REPO_ROOT/docs/requirements.md" \ +SYSTEM_SPEC="$VENDOR/SPEC.md" \ +TYPES="UR,DR" \ +SUFFIXES=".rs" \ +SCAN_ROOTS="src,tests" \ +MIN_COVERAGE="${MIN_COVERAGE:-0}" \ +ALLOW_ORPHANS="${ALLOW_ORPHANS:-0}" \ +TRACES_JSON="${TRACES_JSON:-$REPO_ROOT/traces-report.json}" \ +TRACES_MD="${TRACES_MD:-$REPO_ROOT/docs/traceability.md}" \ +exec sh "$VENDOR/scripts/traceability/traceability-gate.sh" diff --git a/scripts/vendor/jray-project b/scripts/vendor/jray-project new file mode 160000 index 0000000..041961c --- /dev/null +++ b/scripts/vendor/jray-project @@ -0,0 +1 @@ +Subproject commit 041961c8c63bf37fa220dfa1f39858ca9830a84e diff --git a/src/api/exists.rs b/src/api/exists.rs index 22fb70c..12171f2 100644 --- a/src/api/exists.rs +++ b/src/api/exists.rs @@ -58,6 +58,7 @@ pub struct BatchResponse { pub results: Vec, } +/// TRACES: UR-001 | SR-001 pub async fn exists( State(state): State, 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, peer: crate::state::PeerIp, diff --git a/src/api/fetch.rs b/src/api/fetch.rs index 48b8ea3..0aeb2fb 100644 --- a/src/api/fetch.rs +++ b/src/api/fetch.rs @@ -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, peer: crate::state::PeerIp, @@ -263,6 +264,7 @@ fn title_of(conn: &rusqlite::Connection, title_id: &str) -> anyhow::Result 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 diff --git a/src/api/report.rs b/src/api/report.rs index a50303a..435c360 100644 --- a/src/api/report.rs +++ b/src/api/report.rs @@ -53,6 +53,7 @@ pub struct ReportAccepted { pub report_id: String, } +/// TRACES: UR-005 | SR-004 pub async fn post_report( State(state): State, peer: crate::state::PeerIp, diff --git a/src/api/upload.rs b/src/api/upload.rs index 8fb4199..fc63af7 100644 --- a/src/api/upload.rs +++ b/src/api/upload.rs @@ -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, 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, 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, peer: crate::state::PeerIp, diff --git a/src/app.rs b/src/app.rs index f3e7f47..64ee194 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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; diff --git a/src/auth.rs b/src/auth.rs index f296a26..db9d0be 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -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 { /// 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, trusted_proxies: &[IpAddr]) -> String { let peer_is_trusted = peer.is_some_and(|p| trusted_proxies.contains(&p)); diff --git a/src/castcheck.rs b/src/castcheck.rs index 581f8b7..60ce123 100644 --- a/src/castcheck.rs +++ b/src/castcheck.rs @@ -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 { if title_is_adult { return None; diff --git a/src/config.rs b/src/config.rs index e6a1042..596f95c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, diff --git a/src/content_id.rs b/src/content_id.rs index 44ce29d..8695da5 100644 --- a/src/content_id.rs +++ b/src/content_id.rs @@ -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) { } /// `sha256:` over the canonical form (§9a). +/// TRACES: DR-011 | SR-003 pub fn content_id( identity: &CanonicalIdentity, cut: &CanonicalCut, diff --git a/src/db/mod.rs b/src/db/mod.rs index 8806e97..b7fe649 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -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>, diff --git a/src/db/repo.rs b/src/db/repo.rs index 9621928..560702b 100644 --- a/src/db/repo.rs +++ b/src/db/repo.rs @@ -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> { let jobs: Vec = { let mut stmt = tx.prepare( diff --git a/src/error.rs b/src/error.rs index f5043d5..a104526 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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 diff --git a/src/ingest.rs b/src/ingest.rs index 1fede64..2b60269 100644 --- a/src/ingest.rs +++ b/src/ingest.rs @@ -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, diff --git a/src/matching.rs b/src/matching.rs index b647d5d..47e2e21 100644 --- a/src/matching.rs +++ b/src/matching.rs @@ -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 { // Tier 1 — same file. Checked first and unconditionally: an equal hash is // decisive regardless of what the runtimes say. diff --git a/src/model.rs b/src/model.rs index 5241824..7c92799 100644 --- a/src/model.rs +++ b/src/model.rs @@ -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 { diff --git a/src/ratelimit.rs b/src/ratelimit.rs index 66a4eba..a2b686b 100644 --- a/src/ratelimit.rs +++ b/src/ratelimit.rs @@ -73,6 +73,7 @@ struct Window { count: u32, } +/// TRACES: UR-004 | DR-006 | SR-004 pub struct RateLimiter { windows: Mutex>, } diff --git a/src/validate.rs b/src/validate.rs index f1993b3..15cd217 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -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 { 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> { Ok(out) } +/// TRACES: UR-013 | SR-002 fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult> { 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 VResult<()> { if b.jmanifest_version != JMANIFEST_VERSION { return Err(err( diff --git a/src/worker.rs b/src/worker.rs index 1538f23..9e7b282 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -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()))?;