Ship the SR-003 schema bump: jmanifest_version 2
CI / fmt, clippy, test (push) Failing after 1m22s
CI / static musl binary (push) Has been skipped
CI / advisories and licences (push) Successful in 26s

Moves the exchange envelope to version 2 in lockstep with the truth file's
schema_version, per SR-003's requirement that breaking changes be batched and
ship together rather than piecemeal. The plugin had already moved to
schema_version 2; the server declaring 1 while accepting the new fields
defeated the point of having a version at all.

Flag day, not dual-accept (JR-003): version 1 is now rejected outright. All
three components are pre-release, and a v1 read path would be the one nobody
exercises, so it is the one that would rot while being dragged through every
later change to the reader. A pipeline still emitting v1 is incompatible until
updated — stated plainly rather than papered over with a shim nobody tests.

scenes become objects carrying belief and route (extraction AR-017) instead of
float pairs. Belief is bounded to [0, 1] rather than merely stored: §5a's
Threat 1 argument rests on every accepted value being bounded, and an unbounded
float is a 64-bit channel however harmless it looks. route is a closed enum, so
an invented value cannot be stored.

UR-018 is the requirement with the trap in it, and the reason content_id.rs is
untouched by this commit: belief is a producer-side estimate that may
legitimately differ between pipeline versions for identical timings, so
including it in the canonical form would give two servers different ids for the
same content — the exact failure mode §9a quantises centiseconds to avoid,
reintroduced one field along. It replicates as an attribute, exactly as
audio_signature does. The golden vector still passes unchanged, which is the
evidence rather than the claim.

191 tests. UR-015..018 move from Planned to Done; coverage 24/32 (75%).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: UR-014, UR-015, UR-016, UR-017, UR-018 | SR-003
This commit is contained in:
2026-07-31 09:13:14 +02:00
co-authored by Claude Opus 5
parent c73f417d45
commit 88c7264094
12 changed files with 494 additions and 179 deletions
+18 -4
View File
@@ -39,6 +39,12 @@ Implemented:
- §8 Rust + Axum + SQLite, single serialized writer, in-process job queue - §8 Rust + Axum + SQLite, single serialized writer, in-process job queue
- §9a content addressing (`content_id`), computed on upload - §9a content addressing (`content_id`), computed on upload
**Schema version 2** (SR-003). `jmanifest_version` moved to 2 in lockstep with
the truth file's `schema_version` — breaking changes are batched and ship
together across all three repos. It is a **flag day**: version 1 is rejected
outright rather than carried alongside, because a v1 read path would be the one
nobody exercises and so the one that rots.
Reconciled with the system spec (see `docs/requirements.md` for the detail): Reconciled with the system spec (see `docs/requirements.md` for the detail):
- **`anneal_sec` removed.** Withdrawn upstream by AR-012/AR-013 — presence now - **`anneal_sec` removed.** Withdrawn upstream by AR-012/AR-013 — presence now
@@ -48,6 +54,14 @@ Reconciled with the system spec (see `docs/requirements.md` for the detail):
carrying `anneal_sec` is now a hard `400`, not silently ignored: it was carrying `anneal_sec` is now a hard `400`, not silently ignored: it was
produced by a pipeline whose window semantics differ from what this server produced by a pipeline whose window semantics differ from what this server
assumes. assumes.
- **Windows carry belief and route.** `scenes` are objects rather than float
pairs: `{ "start", "end", "belief", "route" }`, where belief is the posterior
that justified the claim and route is `live`/`deferred`/`pooled`. Both are
**excluded from `content_id`** — belief is a producer-side estimate that may
differ between pipeline versions for identical timings, so hashing it would
give two servers different ids for the same content. `src/content_id.rs` is
unchanged by the bump and its golden vector still passes, which is the
evidence rather than the claim.
- **Audio signature: the 120 s rule now matches both producers.** An earlier - **Audio signature: the 120 s rule now matches both producers.** An earlier
draft of §3 allowed a shortened window for items under 150 s; that conflicted draft of §3 allowed a shortened window for items under 150 s; that conflicted
with `scene-actor-extraction` IR-007 and was the weaker rule, since a with `scene-actor-extraction` IR-007 and was the weaker rule, since a
@@ -110,7 +124,7 @@ curl -sX POST -H 'content-type: application/json' -d '{}' \
## Tests ## Tests
```sh ```sh
cargo test # 189 tests cargo test # 191 tests
cargo deny check # advisories, licences, bans, sources cargo deny check # advisories, licences, bans, sources
scripts/traceability-gate.sh # requirement coverage scripts/traceability-gate.sh # requirement coverage
``` ```
@@ -123,9 +137,9 @@ git submodule update --init --recursive
It reports coverage against [`docs/requirements.md`](docs/requirements.md), 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 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 signal that the computation itself is broken. Currently **24/32 (75%)**; the
untraced nine are UR-007 (plugin-side), UR-008 (federation) and UR-015..018 (the untraced remainder is UR-007 (plugin-side) and UR-008 (federation), neither of
pending SR-003 bump), none of which is implemented yet. which is implemented here yet.
Unit tests per module, plus two integration suites: Unit tests per module, plus two integration suites:
+25 -11
View File
@@ -107,7 +107,7 @@ and a cut fingerprint; the actor timeline payload is unchanged.
```json ```json
{ {
"jmanifest_version": 1, "jmanifest_version": 2,
"identity": { "identity": {
"type": "movie", "type": "movie",
"tmdb_id": "504172", "tmdb_id": "504172",
@@ -133,7 +133,10 @@ and a cut fingerprint; the actor timeline payload is unchanged.
"name": "Steve Buscemi", "name": "Steve Buscemi",
"imdb_id": "nm0000114", "imdb_id": "nm0000114",
"tmdb_id": "884", "tmdb_id": "884",
"scenes": [[191.6, 209.2], [438.2, 465.6]] "scenes": [
{ "start": 191.6, "end": 209.2, "belief": 0.98, "route": "live" },
{ "start": 438.2, "end": 465.6, "belief": 0.81, "route": "deferred" }
]
} }
] ]
} }
@@ -154,8 +157,10 @@ For an episode, `identity` is:
Field notes: Field notes:
- `jmanifest_version` — separate from the plugin's `schema_version`; this - `jmanifest_version` — **currently 2.** Separate from the plugin's
versions the *exchange* envelope. `schema_version`; this versions the *exchange* envelope, and the two remain
independent by design. They coincide at 2 only because the SR-003 bump touched
both. An unknown version is rejected outright (UR-014), never guessed at.
- `identity.tmdb_id` / `imdb_id` — at least one required. These are the - `identity.tmdb_id` / `imdb_id` — at least one required. These are the
lookup keys. lookup keys.
- `cut.runtime_sec` — **required**, the decoded duration of the media the - `cut.runtime_sec` — **required**, the decoded duration of the media the
@@ -173,7 +178,10 @@ Field notes:
- `actors[].jellyfin_id` — **must not appear.** The server rejects uploads - `actors[].jellyfin_id` — **must not appear.** The server rejects uploads
containing it (see §6). containing it (see §6).
- `movie` (absolute path) — **must not appear.** Rejected likewise. - `movie` (absolute path) — **must not appear.** Rejected likewise.
- `actors[].scenes` — `[start_sec, end_sec]` inclusive, sorted. **A window is a - `actors[].scenes` — objects, not float pairs. `start`/`end` in seconds,
inclusive, sorted. `belief` is the accumulated posterior that justified the
claim, in `[0, 1]`; `route` is `live`, `deferred` or `pooled` (extraction
AR-017). Both are optional and both are **excluded from `content_id`** (§9a). **A window is a
claim about scene membership, not a recognition event** (UR-013, system spec claim about scene membership, not a recognition event** (UR-013, system spec
SR-002): an actor who turns away or is off-camera during a reverse shot is SR-002): an actor who turns away or is off-camera during a reverse shot is
still present. The server therefore never reinterprets, merges, splits or still present. The server therefore never reinterprets, merges, splits or
@@ -188,12 +196,13 @@ Field notes:
server-authoritative. Contributors should not expect a name they invented to server-authoritative. Contributors should not expect a name they invented to
round-trip. round-trip.
### Pending schema bump — SR-003 ### Schema bump — SR-003, shipped at version 2
The truth file and the Jmanifest are consumed by components that ship The truth file and the Jmanifest are consumed by components that ship
independently, so breaking changes are **batched into one `schema_version` independently, so breaking changes are **batched into one `schema_version`
bump** coordinated across all three repos (system spec SR-003). One bump is bump** coordinated across all three repos (system spec SR-003). One bump has
currently pending, and this server must accept the new shape when it lands: shipped, moving `jmanifest_version` to **2** in lockstep with the truth file's
`schema_version`:
| Change | Effect here | | Change | Effect here |
|---|---| |---|---|
@@ -219,8 +228,13 @@ bump rather than after it:
as an attribute, exactly as `audio_signature` is (§9a). as an attribute, exactly as `audio_signature` is (§9a).
- Quantisation is unchanged: integer centiseconds, for the reasons in §9a. - Quantisation is unchanged: integer centiseconds, for the reasons in §9a.
Until the bump ships, this server accepts `jmanifest_version: 1` and rejects **Flag day, not dual-accept.** This server accepts `jmanifest_version: 2` and
anything else outright (UR-014) rather than guessing at an unknown shape. rejects everything else outright (UR-014), including version 1. All three
components are pre-release, and a v1 read path would be the one nobody
exercises — so it is the one that would rot while being carried through every
later change to the reader. The consequence is that a pipeline still emitting v1
is incompatible until it is updated, which is stated plainly rather than papered
over with a compatibility shim nobody tests.
### Series bundles ### Series bundles
@@ -232,7 +246,7 @@ A bundle is a thin wrapper, not a new format:
```json ```json
{ {
"jmanifest_version": 1, "jmanifest_version": 2,
"series": { "series": {
"series_tmdb_id": "1396", "series_tmdb_id": "1396",
"series_imdb_id": "tt0903747", "series_imdb_id": "tt0903747",
+23 -16
View File
@@ -41,6 +41,10 @@ requirement and no fixture-generation step, unlike `scene-actor-extraction`.
| UR-012 | Never accept, store, or serve gallery data — reference faces or embeddings | SR-005 | High | Done | | UR-012 | Never accept, store, or serve gallery data — reference faces or embeddings | SR-005 | High | Done |
| UR-013 | Windows are scene-scoped claims; never reinterpret their boundaries | SR-002 | High | Done | | UR-013 | Windows are scene-scoped claims; never reinterpret their boundaries | SR-002 | High | Done |
| UR-014 | Reject an unknown `jmanifest_version` outright, never guess | SR-003 | High | Done | | UR-014 | Reject an unknown `jmanifest_version` outright, never guess | SR-003 | High | Done |
| UR-015 | Accept `extraction.extinction_sec` in place of `anneal_sec` | SR-003 | High | Done |
| UR-016 | Accept and store `extraction.gallery_scope`; rank on it (§7) | SR-003 | Medium | Done |
| UR-017 | Accept per-window belief and identification route; `scenes` are objects | SR-003 | High | Done |
| UR-018 | Exclude belief and route from `content_id`, replicating them as attributes | SR-003 | High | Done |
### Notes on status ### Notes on status
@@ -128,6 +132,10 @@ topology is the point, so this is a deliberate choice rather than an oversight.
| DR-009 | T2 | Oversized body rejected as `413` | **A lying `Content-Length` does not bypass the cap**; per-route limits differ | | DR-009 | T2 | Oversized body rejected as `413` | **A lying `Content-Length` does not bypass the cap**; per-route limits differ |
| DR-010 | T1 | Non-UTF-8 rejected by name | UTF-16 with and without BOM; UTF-8 BOM; declared `charset=utf-16` | | DR-010 | T1 | Non-UTF-8 rejected by name | UTF-16 with and without BOM; UTF-8 BOM; declared `charset=utf-16` |
| DR-011 | T1 | Canonical form is stable and order-independent | Accumulated float error hashes identically; `audio_signature` and `extraction` excluded; **golden vector verified against an independent Python implementation** | | DR-011 | T1 | Canonical form is stable and order-independent | Accumulated float error hashes identically; `audio_signature` and `extraction` excluded; **golden vector verified against an independent Python implementation** |
| UR-015 | T2 | `extinction_sec` accepted and range-checked | A manifest still carrying `anneal_sec` is a hard `400` naming the field |
| UR-016 | T2 | `gallery_scope` stored and served | An unrecognised scope is a closed-vocabulary `400`, not a free string |
| UR-017 | T1 + T2 | Windows carry belief and route through storage | Belief outside `[0, 1]` rejected; an invented `route` rejected |
| UR-018 | **T1** | Belief and route absent from `content_id` | Same windows at different belief hash identically; **a real timing change still does not**, so the test cannot pass vacuously |
| DR-013 | T1 | Schema mismatch is `400`, not the framework's `422` | §4 names `400` for a forbidden field, and a client checking for it would mishandle `422` | | DR-013 | T1 | Schema mismatch is `400`, not the framework's `422` | §4 names `400` for a forbidden field, and a client checking for it would mishandle `422` |
Three are worth singling out, because each verifies a claim that would otherwise Three are worth singling out, because each verifies a claim that would otherwise
@@ -162,27 +170,26 @@ requirement — so nothing is orphaned by its removal.
--- ---
## Pending — the SR-003 schema bump ## The SR-003 schema bump — shipped at version 2
These are `Planned` rather than absent, because the bump is coordinated across `jmanifest_version` moved to **2** in lockstep with the truth file's
three repos and this register should show the work rather than imply the server `schema_version`, per SR-003's requirement that breaking changes be batched and
is finished. ship together. The two fields stay independent by design; they coincide at 2
only because this bump touched both.
| ID | Requirement | Traces to | Priority | Status | **Flag day, not dual-accept** (`jRay` JR-003): version 1 is rejected outright.
|---|---|---|---|---| All three components are pre-release, and a v1 read path would be the one nobody
| UR-015 | Accept `extraction.extinction_sec` in place of `anneal_sec` | SR-003 | High | Planned | exercises, so it is the one that would rot while being dragged through every
| UR-016 | Accept and store `extraction.gallery_scope`; rank on it (§7) | SR-003 | Medium | Planned | later change to the reader.
| UR-017 | Accept per-window belief and identification route; `scenes` becomes objects | SR-003 | High | Planned |
| UR-018 | Exclude belief from `content_id`, replicating it as an attribute | SR-003 | High | Planned |
**UR-018 is the one with a trap in it.** Belief is a producer-side estimate that **UR-018 was the one with a trap in it.** Belief is a producer-side estimate
may legitimately differ between pipeline versions for identical timings, so that may legitimately differ between pipeline versions for identical timings, so
including it in the canonical form would give two servers different `content_id`s including it in the canonical form would give two servers different `content_id`s
for the same content — the exact failure mode §9a quantises centiseconds to for the same content — the exact failure mode §9a quantises centiseconds to
avoid. It follows `audio_signature`'s precedent: replicated as an attribute, not avoid, reintroduced one field along. It follows `audio_signature`'s precedent:
part of identity. replicated as an attribute, never as identity. `src/content_id.rs` is unchanged
by the bump, and its golden vector still passes — which is the evidence, not the
--- claim.
## Notes on coverage ## Notes on coverage
+50 -39
View File
@@ -3,7 +3,7 @@
<!-- GENERATED FILE - do not edit by hand. --> <!-- GENERATED FILE - do not edit by hand. -->
<!-- Regenerate: scripts/traceability/traceability-gate.sh --> <!-- Regenerate: scripts/traceability/traceability-gate.sh -->
**Generated:** 2026-07-30T16:55:59+00:00 **Generated:** 2026-07-31T07:12:27+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 repo's CI host can execute (`T1, T2, static`). 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 repo's CI host can execute (`T1, T2, static`).
@@ -12,12 +12,12 @@ Denominators are read from [`requirements.md`](requirements.md) at run time, nev
| Metric | Value | | Metric | Value |
|---|---| |---|---|
| Source files scanned | 26 | | Source files scanned | 26 |
| TRACES tags found | 35 | | TRACES tags found | 37 |
| EXCEPTION tags found | 0 | | EXCEPTION tags found | 0 |
| Requirements defined | 32 | | Requirements defined | 32 |
| Requirements covered | 23 | | Requirements covered | 24 |
| **Coverage** | **71.9%** (23/32) | | **Coverage** | **75.0%** (24/32) |
| Coverage of CI-executable scope | 71.9% (23/32) | | Coverage of CI-executable scope | 75.0% (24/32) |
| Tagged but unexecuted in CI | 0 | | Tagged but unexecuted in CI | 0 |
| Orphan tags | 0 | | Orphan tags | 0 |
@@ -25,7 +25,7 @@ Denominators are read from [`requirements.md`](requirements.md) at run time, nev
| Type | Covered | Tagged but unexecuted | Defined | | Type | Covered | Tagged but unexecuted | Defined |
|---|---|---|---| |---|---|---|---|
| UR | 13 | 0 | 18 | | UR | 14 | 0 | 18 |
| DR | 10 | 0 | 14 | | DR | 10 | 0 | 14 |
- **PR** tags present (separate taxonomy, not counted in coverage): PR-004, PR-005, PR-006 - **PR** tags present (separate taxonomy, not counted in coverage): PR-004, PR-005, PR-006
@@ -73,10 +73,10 @@ _None._
| 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-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-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-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-015 | Done | T2 | 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-016 | Done | T2 | 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-017 | Done | T1, T2 | SR-003 | covered | `src/model.rs` | Accept per-window belief and identification route; `scenes` are objec… |
| UR-018 | Planned | unset | SR-003 | untagged | - | Exclude belief from `content_id`, replicating it as an attribute | | UR-018 | Done | T1 | SR-003 | untagged | - | Exclude belief and route from `content_id`, replicating them as attri… |
| DR-001 | Done | T1 | SR-004 | untagged | - | Strict parse boundary: unknown fields rejected structurally, not by v… | | 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-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-003 | Done | T1 | PR-004 | covered | `src/db/mod.rs` | Single serialized writer connection, with a read pool alongside |
@@ -100,7 +100,7 @@ _None._
- [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `pub fn reconstruct(` - [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `pub fn reconstruct(`
- [`src/db/repo.rs:337`](../src/db/repo.rs#L337) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>` - [`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) — `pub fn actors_for_manifest(` - [`src/db/repo.rs:412`](../src/db/repo.rs#L412) — `pub fn actors_for_manifest(`
### DR-003 ### DR-003
@@ -118,7 +118,7 @@ _None._
**Locations:** 1 **Locations:** 1
- [`src/db/repo.rs:691`](../src/db/repo.rs#L691) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result<Vec<Jo…` - [`src/db/repo.rs:709`](../src/db/repo.rs#L709) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result<Vec<Jo…`
### DR-006 ### DR-006
@@ -151,7 +151,7 @@ _None._
- [`src/content_id.rs:52`](../src/content_id.rs#L52) — `pub fn canonical_json(` - [`src/content_id.rs:52`](../src/content_id.rs#L52) — `pub fn canonical_json(`
- [`src/content_id.rs:129`](../src/content_id.rs#L129) — `pub fn content_id(` - [`src/content_id.rs:129`](../src/content_id.rs#L129) — `pub fn content_id(`
- [`src/validate.rs:78`](../src/validate.rs#L78) — `pub fn to_centiseconds(secs: f64) -> i64` - [`src/validate.rs:102`](../src/validate.rs#L102) — `pub fn to_centiseconds(secs: f64) -> i64`
### DR-013 ### DR-013
@@ -167,7 +167,7 @@ _None._
- [`src/config.rs:10`](../src/config.rs#L10) — `Unknown` - [`src/config.rs:10`](../src/config.rs#L10) — `Unknown`
- [`src/db/mod.rs:30`](../src/db/mod.rs#L30) — `struct ReadPool` - [`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<Vec<Jo…` - [`src/db/repo.rs:709`](../src/db/repo.rs#L709) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result<Vec<Jo…`
### PR-005 ### PR-005
@@ -183,7 +183,7 @@ _None._
- [`src/api/upload.rs:29`](../src/api/upload.rs#L29) — `pub async fn post_manifest(` - [`src/api/upload.rs:29`](../src/api/upload.rs#L29) — `pub async fn post_manifest(`
- [`src/api/upload.rs:101`](../src/api/upload.rs#L101) — `pub async fn post_bundle(` - [`src/api/upload.rs:101`](../src/api/upload.rs#L101) — `pub async fn post_bundle(`
- [`src/ingest.rs:50`](../src/ingest.rs#L50) — `pub fn persist(` - [`src/ingest.rs:50`](../src/ingest.rs#L50) — `pub fn persist(`
- [`src/validate.rs:540`](../src/validate.rs#L540) — `pub fn validate_bundle_envelope(b: &SeriesBundle) -> VResult<()>` - [`src/validate.rs:586`](../src/validate.rs#L586) — `pub fn validate_bundle_envelope(b: &SeriesBundle) -> VResult<()>`
### SR-001 ### SR-001
@@ -193,30 +193,33 @@ _None._
- [`src/api/exists.rs:74`](../src/api/exists.rs#L74) — `pub async fn exists_batch(` - [`src/api/exists.rs:74`](../src/api/exists.rs#L74) — `pub async fn exists_batch(`
- [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `pub fn reconstruct(` - [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `pub fn reconstruct(`
- [`src/castcheck.rs:82`](../src/castcheck.rs#L82) — `pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome` - [`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) — `pub fn actors_for_manifest(` - [`src/db/repo.rs:412`](../src/db/repo.rs#L412) — `pub fn actors_for_manifest(`
- [`src/matching.rs:52`](../src/matching.rs#L52) — `pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option<CutMatch>` - [`src/matching.rs:52`](../src/matching.rs#L52) — `pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option<CutMatch>`
- [`src/model.rs:179`](../src/model.rs#L179) — `Unknown` - [`src/model.rs:179`](../src/model.rs#L179) — `Unknown`
### SR-002 ### SR-002
**Locations:** 3 **Locations:** 4
- [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `pub fn reconstruct(` - [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `pub fn reconstruct(`
- [`src/model.rs:179`](../src/model.rs#L179) — `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<Vec<(i64, i64)>>` - [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_str(s: &str) -> Option<Self>`
- [`src/validate.rs:510`](../src/validate.rs#L510) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<SceneCs>>`
### SR-003 ### SR-003
**Locations:** 8 **Locations:** 10
- [`src/api/json.rs:100`](../src/api/json.rs#L100) — `fn require_utf8(bytes: &[u8]) -> Result<&str, ApiError>` - [`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) — `pub fn canonical_json(` - [`src/content_id.rs:52`](../src/content_id.rs#L52) — `pub fn canonical_json(`
- [`src/content_id.rs:129`](../src/content_id.rs#L129) — `pub fn content_id(` - [`src/content_id.rs:129`](../src/content_id.rs#L129) — `pub fn content_id(`
- [`src/error.rs:8`](../src/error.rs#L8) — `Unknown` - [`src/error.rs:8`](../src/error.rs#L8) — `Unknown`
- [`src/model.rs:198`](../src/model.rs#L198) — `Unknown` - [`src/model.rs:197`](../src/model.rs#L197) — `Unknown`
- [`src/validate.rs:78`](../src/validate.rs#L78) — `pub fn to_centiseconds(secs: f64) -> i64` - [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_str(s: &str) -> Option<Self>`
- [`src/validate.rs:203`](../src/validate.rs#L203) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult<ValidManifest>` - [`src/model.rs:259`](../src/model.rs#L259) — `Unknown`
- [`src/validate.rs:354`](../src/validate.rs#L354) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>` - [`src/validate.rs:102`](../src/validate.rs#L102) — `pub fn to_centiseconds(secs: f64) -> i64`
- [`src/validate.rs:227`](../src/validate.rs#L227) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult<ValidManifest>`
- [`src/validate.rs:378`](../src/validate.rs#L378) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>`
### SR-004 ### SR-004
@@ -232,11 +235,11 @@ _None._
- [`src/castcheck.rs:215`](../src/castcheck.rs#L215) — `pub fn category_guard_violation(matched: &[MatchedActor], title_is_adult: bool) -> Option…` - [`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/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:156`](../src/model.rs#L156) — `Unknown`
- [`src/model.rs:198`](../src/model.rs#L198) — `Unknown` - [`src/model.rs:259`](../src/model.rs#L259) — `Unknown`
- [`src/ratelimit.rs:76`](../src/ratelimit.rs#L76) — `impl Default for RateLimiter` - [`src/ratelimit.rs:76`](../src/ratelimit.rs#L76) — `impl Default for RateLimiter`
- [`src/validate.rs:112`](../src/validate.rs#L112) — `fn is_allowed_text_char(c: char) -> bool` - [`src/validate.rs:136`](../src/validate.rs#L136) — `fn is_allowed_text_char(c: char) -> bool`
- [`src/validate.rs:203`](../src/validate.rs#L203) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult<ValidManifest>` - [`src/validate.rs:227`](../src/validate.rs#L227) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult<ValidManifest>`
- [`src/validate.rs:354`](../src/validate.rs#L354) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>` - [`src/validate.rs:378`](../src/validate.rs#L378) — `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>` - [`src/worker.rs:96`](../src/worker.rs#L96) — `async fn run_cast_check(&self, payload: &str) -> Result<(), JobError>`
### SR-005 ### SR-005
@@ -268,8 +271,8 @@ _None._
- [`src/api/upload.rs:29`](../src/api/upload.rs#L29) — `pub async fn post_manifest(` - [`src/api/upload.rs:29`](../src/api/upload.rs#L29) — `pub async fn post_manifest(`
- [`src/castcheck.rs:82`](../src/castcheck.rs#L82) — `pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome` - [`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:156`](../src/model.rs#L156) — `Unknown`
- [`src/model.rs:198`](../src/model.rs#L198) — `Unknown` - [`src/model.rs:259`](../src/model.rs#L259) — `Unknown`
- [`src/validate.rs:203`](../src/validate.rs#L203) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult<ValidManifest>` - [`src/validate.rs:227`](../src/validate.rs#L227) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult<ValidManifest>`
- [`src/worker.rs:96`](../src/worker.rs#L96) — `async fn run_cast_check(&self, payload: &str) -> Result<(), JobError>` - [`src/worker.rs:96`](../src/worker.rs#L96) — `async fn run_cast_check(&self, payload: &str) -> Result<(), JobError>`
### UR-004 ### UR-004
@@ -296,7 +299,7 @@ _None._
- [`src/api/fetch.rs:128`](../src/api/fetch.rs#L128) — `pub async fn get_series(` - [`src/api/fetch.rs:128`](../src/api/fetch.rs#L128) — `pub async fn get_series(`
- [`src/api/upload.rs:101`](../src/api/upload.rs#L101) — `pub async fn post_bundle(` - [`src/api/upload.rs:101`](../src/api/upload.rs#L101) — `pub async fn post_bundle(`
- [`src/validate.rs:540`](../src/validate.rs#L540) — `pub fn validate_bundle_envelope(b: &SeriesBundle) -> VResult<()>` - [`src/validate.rs:586`](../src/validate.rs#L586) — `pub fn validate_bundle_envelope(b: &SeriesBundle) -> VResult<()>`
### UR-007 ### UR-007
@@ -308,7 +311,7 @@ _None._
**Locations:** 1 **Locations:** 1
- [`src/validate.rs:354`](../src/validate.rs#L354) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>` - [`src/validate.rs:378`](../src/validate.rs#L378) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>`
### UR-010 ### UR-010
@@ -316,7 +319,7 @@ _None._
- [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `pub fn reconstruct(` - [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `pub fn reconstruct(`
- [`src/castcheck.rs:82`](../src/castcheck.rs#L82) — `pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome` - [`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) — `pub fn actors_for_manifest(` - [`src/db/repo.rs:412`](../src/db/repo.rs#L412) — `pub fn actors_for_manifest(`
- [`src/model.rs:179`](../src/model.rs#L179) — `Unknown` - [`src/model.rs:179`](../src/model.rs#L179) — `Unknown`
### UR-011 ### UR-011
@@ -324,9 +327,9 @@ _None._
**Locations:** 4 **Locations:** 4
- [`src/model.rs:156`](../src/model.rs#L156) — `Unknown` - [`src/model.rs:156`](../src/model.rs#L156) — `Unknown`
- [`src/model.rs:198`](../src/model.rs#L198) — `Unknown` - [`src/model.rs:259`](../src/model.rs#L259) — `Unknown`
- [`src/validate.rs:112`](../src/validate.rs#L112) — `fn is_allowed_text_char(c: char) -> bool` - [`src/validate.rs:136`](../src/validate.rs#L136) — `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<()>` - [`src/validate.rs:378`](../src/validate.rs#L378) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>`
### UR-012 ### UR-012
@@ -337,16 +340,24 @@ _None._
### UR-013 ### UR-013
**Locations:** 3 **Locations:** 4
- [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `pub fn reconstruct(` - [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `pub fn reconstruct(`
- [`src/model.rs:179`](../src/model.rs#L179) — `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<Vec<(i64, i64)>>` - [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_str(s: &str) -> Option<Self>`
- [`src/validate.rs:510`](../src/validate.rs#L510) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<SceneCs>>`
### UR-014 ### UR-014
**Locations:** 2 **Locations:** 2
- [`src/model.rs:198`](../src/model.rs#L198) — `Unknown` - [`src/model.rs:259`](../src/model.rs#L259) — `Unknown`
- [`src/validate.rs:203`](../src/validate.rs#L203) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult<ValidManifest>` - [`src/validate.rs:227`](../src/validate.rs#L227) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult<ValidManifest>`
### UR-017
**Locations:** 2
- [`src/model.rs:197`](../src/model.rs#L197) — `Unknown`
- [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_str(s: &str) -> Option<Self>`
+7 -2
View File
@@ -17,7 +17,7 @@ use crate::error::{ApiError, ApiResult};
use crate::matching::{self, StoredCut}; use crate::matching::{self, StoredCut};
use crate::model::{ use crate::model::{
Actor, Coverage, Cut, Extraction, GalleryScope, Identity, IdentityType, Jmanifest, MatchTier, Actor, Coverage, Cut, Extraction, GalleryScope, Identity, IdentityType, Jmanifest, MatchTier,
SeriesBundle, SeriesRef, JMANIFEST_VERSION, Scene, SeriesBundle, SeriesRef, JMANIFEST_VERSION,
}; };
use crate::ratelimit::Surface; use crate::ratelimit::Surface;
use crate::state::{with_quota_headers, AppState}; use crate::state::{with_quota_headers, AppState};
@@ -282,7 +282,12 @@ pub fn reconstruct(
scenes: a scenes: a
.scenes_cs .scenes_cs
.into_iter() .into_iter()
.map(|(s, e)| [s as f64 / 100.0, e as f64 / 100.0]) .map(|s| Scene {
start: s.start_cs as f64 / 100.0,
end: s.end_cs as f64 / 100.0,
belief: s.belief,
route: s.route,
})
.collect(), .collect(),
}) })
.collect(); .collect();
+38 -11
View File
@@ -374,7 +374,7 @@ pub fn insert_actor_scenes(
tx: &Transaction<'_>, tx: &Transaction<'_>,
manifest_id: &str, manifest_id: &str,
tmdb_person_id: u64, tmdb_person_id: u64,
scenes_cs: &[(i64, i64)], scenes_cs: &[crate::validate::SceneCs],
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
tx.execute( tx.execute(
"INSERT INTO manifest_actors (manifest_id, tmdb_person_id) VALUES (?1, ?2) "INSERT INTO manifest_actors (manifest_id, tmdb_person_id) VALUES (?1, ?2)
@@ -382,11 +382,18 @@ pub fn insert_actor_scenes(
params![manifest_id, tmdb_person_id as i64], params![manifest_id, tmdb_person_id as i64],
)?; )?;
let mut stmt = tx.prepare_cached( let mut stmt = tx.prepare_cached(
"INSERT INTO scenes (manifest_id, tmdb_person_id, start_cs, end_cs) "INSERT INTO scenes (manifest_id, tmdb_person_id, start_cs, end_cs, belief, route)
VALUES (?1, ?2, ?3, ?4)", VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
)?; )?;
for (start, end) in scenes_cs { for scene in scenes_cs {
stmt.execute(params![manifest_id, tmdb_person_id as i64, start, end])?; stmt.execute(params![
manifest_id,
tmdb_person_id as i64,
scene.start_cs,
scene.end_cs,
scene.belief,
scene.route.map(|r| r.as_str()),
])?;
} }
Ok(()) Ok(())
} }
@@ -399,7 +406,7 @@ pub fn insert_actor_scenes(
pub struct StoredActor { pub struct StoredActor {
pub tmdb_person_id: u64, pub tmdb_person_id: u64,
pub name: Option<String>, pub name: Option<String>,
pub scenes_cs: Vec<(i64, i64)>, pub scenes_cs: Vec<crate::validate::SceneCs>,
} }
/// TRACES: UR-010 | DR-002 | SR-001 /// TRACES: UR-010 | DR-002 | SR-001
@@ -421,7 +428,7 @@ pub fn actors_for_manifest(
.collect::<rusqlite::Result<Vec<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
let mut scene_stmt = conn.prepare_cached( let mut scene_stmt = conn.prepare_cached(
"SELECT start_cs, end_cs FROM scenes "SELECT start_cs, end_cs, belief, route FROM scenes
WHERE manifest_id = ?1 AND tmdb_person_id = ?2 WHERE manifest_id = ?1 AND tmdb_person_id = ?2
ORDER BY start_cs ASC", ORDER BY start_cs ASC",
)?; )?;
@@ -429,7 +436,18 @@ pub fn actors_for_manifest(
let mut out = Vec::with_capacity(people.len()); let mut out = Vec::with_capacity(people.len());
for (id, name) in people { for (id, name) in people {
let scenes_cs = scene_stmt let scenes_cs = scene_stmt
.query_map(params![manifest_id, id as i64], |r| Ok((r.get(0)?, r.get(1)?)))? .query_map(params![manifest_id, id as i64], |r| {
Ok(crate::validate::SceneCs {
start_cs: r.get(0)?,
end_cs: r.get(1)?,
belief: r.get(2)?,
// An unrecognised stored route means a row from a schema
// this build does not know; report absent rather than guess.
route: r
.get::<_, Option<String>>(3)?
.and_then(|v| crate::model::Route::from_stored(&v)),
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
out.push(StoredActor { tmdb_person_id: id, name, scenes_cs }); out.push(StoredActor { tmdb_person_id: id, name, scenes_cs });
} }
@@ -742,6 +760,7 @@ pub fn release_all_leases(tx: &Transaction<'_>) -> anyhow::Result<usize> {
mod tests { mod tests {
use super::*; use super::*;
use crate::db::Db; use crate::db::Db;
use crate::validate::SceneCs;
async fn db() -> Db { async fn db() -> Db {
Db::open(":memory:").unwrap() Db::open(":memory:").unwrap()
@@ -837,7 +856,12 @@ mod tests {
}, },
)?; )?;
upsert_person(tx, 884, "Steve Buscemi", false, NOW)?; upsert_person(tx, 884, "Steve Buscemi", false, NOW)?;
insert_actor_scenes(tx, &id, 884, &[(19160, 20920), (43820, 46560)])?; insert_actor_scenes(
tx,
&id,
884,
&[SceneCs::plain(19160, 20920), SceneCs::plain(43820, 46560)],
)?;
Ok(id) Ok(id)
}) })
.await .await
@@ -857,7 +881,10 @@ mod tests {
assert_eq!(actors[0].tmdb_person_id, 884); assert_eq!(actors[0].tmdb_person_id, 884);
// §7: names come from `people`, populated from TMDB, never from upload. // §7: names come from `people`, populated from TMDB, never from upload.
assert_eq!(actors[0].name.as_deref(), Some("Steve Buscemi")); assert_eq!(actors[0].name.as_deref(), Some("Steve Buscemi"));
assert_eq!(actors[0].scenes_cs, vec![(19160, 20920), (43820, 46560)]); assert_eq!(
actors[0].scenes_cs,
vec![SceneCs::plain(19160, 20920), SceneCs::plain(43820, 46560)]
);
} }
#[tokio::test] #[tokio::test]
@@ -1097,7 +1124,7 @@ mod tests {
}, },
)?; )?;
upsert_person(tx, 1, "A", false, NOW)?; upsert_person(tx, 1, "A", false, NOW)?;
insert_actor_scenes(tx, "m", 1, &[(0, 100)])?; insert_actor_scenes(tx, "m", 1, &[SceneCs::plain(0, 100)])?;
delete_manifest(tx, "m")?; delete_manifest(tx, "m")?;
let scenes: i64 = tx.query_row("SELECT COUNT(*) FROM scenes", [], |r| r.get(0))?; let scenes: i64 = tx.query_row("SELECT COUNT(*) FROM scenes", [], |r| r.get(0))?;
let actors: i64 = let actors: i64 =
+7 -1
View File
@@ -70,7 +70,13 @@ CREATE TABLE IF NOT EXISTS scenes (
manifest_id TEXT NOT NULL REFERENCES manifests(id) ON DELETE CASCADE, manifest_id TEXT NOT NULL REFERENCES manifests(id) ON DELETE CASCADE,
tmdb_person_id INTEGER NOT NULL, tmdb_person_id INTEGER NOT NULL,
start_cs INTEGER NOT NULL, start_cs INTEGER NOT NULL,
end_cs INTEGER NOT NULL end_cs INTEGER NOT NULL,
-- Per-window provenance (SR-003, extraction AR-017). Deliberately NOT part
-- of `content_id`: belief is a producer-side estimate that may differ
-- between pipeline versions for identical timings, so hashing it would give
-- two servers different ids for the same content (§9a).
belief REAL,
route TEXT -- live | deferred | pooled
); );
CREATE TABLE IF NOT EXISTS reports ( CREATE TABLE IF NOT EXISTS reports (
+72 -18
View File
@@ -164,8 +164,16 @@ pub fn compute_content_id(valid: &ValidManifest) -> String {
.actor_scenes_cs .actor_scenes_cs
.iter() .iter()
.filter_map(|a| { .filter_map(|a| {
a.tmdb_id a.tmdb_id.map(|id| CanonicalActor {
.map(|id| CanonicalActor { tmdb_person_id: id, scenes_cs: a.scenes_cs.clone() }) tmdb_person_id: id,
// Timings only. §9a excludes belief and route from identity:
// they are producer-side estimates that may differ between
// pipeline versions for identical content, so hashing them
// would give two servers different ids for the same
// manifest — the failure mode centisecond quantisation
// exists to remove. They replicate as attributes instead.
scenes_cs: a.scenes_cs.iter().map(|s| (s.start_cs, s.end_cs)).collect(),
})
}) })
.collect(); .collect();
@@ -188,12 +196,12 @@ mod tests {
fn movie_json(tmdb: &str, runtime: f64) -> String { fn movie_json(tmdb: &str, runtime: f64) -> String {
format!( format!(
r#"{{"jmanifest_version":1, r#"{{"jmanifest_version":2,
"identity":{{"type":"movie","tmdb_id":"{tmdb}","title":"A Film"}}, "identity":{{"type":"movie","tmdb_id":"{tmdb}","title":"A Film"}},
"cut":{{"runtime_sec":{runtime}}}, "cut":{{"runtime_sec":{runtime}}},
"extraction":{{"sample_fps":5,"pipeline_version":"test 0.1"}}, "extraction":{{"sample_fps":5,"pipeline_version":"test 0.1"}},
"actors":[{{"name":"Steve Buscemi","tmdb_id":"884","scenes":[[10.0,20.0]]}}, "actors":[{{"name":"Steve Buscemi","tmdb_id":"884","scenes":[{{"start":10.0,"end":20.0}}]}},
{{"name":"Michael Palin","tmdb_id":"11007","scenes":[[30.0,40.0]]}}]}}"# {{"name":"Michael Palin","tmdb_id":"11007","scenes":[{{"start":30.0,"end":40.0}}]}}]}}"#
) )
} }
@@ -270,10 +278,10 @@ mod tests {
// so this exercises the per-contributor 409 path specifically. // so this exercises the per-contributor 409 path specifically.
let a = valid_from(&movie_json("504172", 6420.5)); let a = valid_from(&movie_json("504172", 6420.5));
let b = valid_from( let b = valid_from(
r#"{"jmanifest_version":1, r#"{"jmanifest_version":2,
"identity":{"type":"movie","tmdb_id":"504172","title":"A Film"}, "identity":{"type":"movie","tmdb_id":"504172","title":"A Film"},
"cut":{"runtime_sec":6420.5}, "cut":{"runtime_sec":6420.5},
"actors":[{"name":"Steve Buscemi","tmdb_id":"884","scenes":[[11.0,21.0]]}]}"#, "actors":[{"name":"Steve Buscemi","tmdb_id":"884","scenes":[{"start":11.0,"end":21.0}]}]}"#,
); );
let second = db let second = db
@@ -315,11 +323,11 @@ mod tests {
async fn episode_manifests_carry_their_coordinates() { async fn episode_manifests_carry_their_coordinates() {
let db = Db::open(":memory:").unwrap(); let db = Db::open(":memory:").unwrap();
let valid = valid_from( let valid = valid_from(
r#"{"jmanifest_version":1, r#"{"jmanifest_version":2,
"identity":{"type":"episode","series_tmdb_id":"1396","title":"Breaking Bad", "identity":{"type":"episode","series_tmdb_id":"1396","title":"Breaking Bad",
"season":2,"episode":5}, "season":2,"episode":5},
"cut":{"runtime_sec":2820.0}, "cut":{"runtime_sec":2820.0},
"actors":[{"name":"Bryan Cranston","tmdb_id":"17419","scenes":[[10.0,20.0]]}]}"#, "actors":[{"name":"Bryan Cranston","tmdb_id":"17419","scenes":[{"start":10.0,"end":20.0}]}]}"#,
); );
let row = db let row = db
.write(move |tx| { .write(move |tx| {
@@ -357,13 +365,13 @@ mod tests {
// servers validating the same upload agree. // servers validating the same upload agree.
let a = valid_from(&movie_json("504172", 6420.5)); let a = valid_from(&movie_json("504172", 6420.5));
let b = valid_from( let b = valid_from(
r#"{"jmanifest_version":1, r#"{"jmanifest_version":2,
"identity":{"type":"movie","tmdb_id":"504172","title":"A Film"}, "identity":{"type":"movie","tmdb_id":"504172","title":"A Film"},
"cut":{"runtime_sec":6420.5}, "cut":{"runtime_sec":6420.5},
"extraction":{"sample_fps":1,"extinction_sec":9,"pipeline_version":"other 9.9", "extraction":{"sample_fps":1,"extinction_sec":9,"pipeline_version":"other 9.9",
"gallery_size":5}, "gallery_size":5},
"actors":[{"name":"Steve Buscemi","tmdb_id":"884","scenes":[[10.0,20.0]]}, "actors":[{"name":"Steve Buscemi","tmdb_id":"884","scenes":[{"start":10.0,"end":20.0}]},
{"name":"Michael Palin","tmdb_id":"11007","scenes":[[30.0,40.0]]}]}"#, {"name":"Michael Palin","tmdb_id":"11007","scenes":[{"start":30.0,"end":40.0}]}]}"#,
); );
assert_eq!(compute_content_id(&a), compute_content_id(&b)); assert_eq!(compute_content_id(&a), compute_content_id(&b));
} }
@@ -375,29 +383,75 @@ mod tests {
let a = valid_from(&movie_json("504172", 6420.5)); let a = valid_from(&movie_json("504172", 6420.5));
let sig = format!("v1:{}", "A".repeat(1720)); let sig = format!("v1:{}", "A".repeat(1720));
let with_sig = format!( let with_sig = format!(
r#"{{"jmanifest_version":1, r#"{{"jmanifest_version":2,
"identity":{{"type":"movie","tmdb_id":"504172","title":"A Film"}}, "identity":{{"type":"movie","tmdb_id":"504172","title":"A Film"}},
"cut":{{"runtime_sec":6420.5,"audio_signature":"{sig}"}}, "cut":{{"runtime_sec":6420.5,"audio_signature":"{sig}"}},
"extraction":{{"sample_fps":5,"pipeline_version":"test 0.1"}}, "extraction":{{"sample_fps":5,"pipeline_version":"test 0.1"}},
"actors":[{{"name":"Steve Buscemi","tmdb_id":"884","scenes":[[10.0,20.0]]}}, "actors":[{{"name":"Steve Buscemi","tmdb_id":"884","scenes":[{{"start":10.0,"end":20.0}}]}},
{{"name":"Michael Palin","tmdb_id":"11007","scenes":[[30.0,40.0]]}}]}}"# {{"name":"Michael Palin","tmdb_id":"11007","scenes":[{{"start":30.0,"end":40.0}}]}}]}}"#
); );
let b = valid_from(&with_sig); let b = valid_from(&with_sig);
assert_eq!(compute_content_id(&a), compute_content_id(&b)); assert_eq!(compute_content_id(&a), compute_content_id(&b));
} }
#[test]
fn content_id_excludes_belief_and_route() {
// The trap in the SR-003 bump (UR-018). Belief is a producer-side
// estimate that may legitimately differ between pipeline versions for
// identical timings, so hashing it would give two servers different ids
// for the same manifest — the exact failure mode §9a quantises
// centiseconds to avoid, reintroduced one field along.
//
// Two manifests, same windows, wildly different confidence and routes.
let bare = r#"{"jmanifest_version":2,
"identity":{"type":"movie","tmdb_id":"504172","title":"A Film"},
"cut":{"runtime_sec":6420.5},
"actors":[{"name":"Steve Buscemi","tmdb_id":"884",
"scenes":[{"start":10.0,"end":20.0}]},
{"name":"Michael Palin","tmdb_id":"11007",
"scenes":[{"start":30.0,"end":40.0}]}]}"#;
let believed = r#"{"jmanifest_version":2,
"identity":{"type":"movie","tmdb_id":"504172","title":"A Film"},
"cut":{"runtime_sec":6420.5},
"actors":[{"name":"Steve Buscemi","tmdb_id":"884",
"scenes":[{"start":10.0,"end":20.0,"belief":0.98,"route":"live"}]},
{"name":"Michael Palin","tmdb_id":"11007",
"scenes":[{"start":30.0,"end":40.0,"belief":0.31,"route":"deferred"}]}]}"#;
assert_eq!(
compute_content_id(&valid_from(bare)),
compute_content_id(&valid_from(believed)),
"belief and route must not enter identity"
);
// And the same content at a *different* belief still deduplicates.
let other_belief = believed.replace("0.98", "0.42").replace("live", "pooled");
assert_eq!(
compute_content_id(&valid_from(believed)),
compute_content_id(&valid_from(&other_belief)),
);
// Sanity: a genuine timing change *does* alter the id, so the test above
// is not passing because the hash ignores everything.
let shifted = bare.replace("\"end\":20.0", "\"end\":21.0");
assert_ne!(
compute_content_id(&valid_from(bare)),
compute_content_id(&valid_from(&shifted))
);
}
#[test] #[test]
fn content_id_excludes_submitted_names() { fn content_id_excludes_submitted_names() {
// Names are not persisted, so they must not be part of identity either — // Names are not persisted, so they must not be part of identity either —
// otherwise a renamed resubmission would evade deduplication. // otherwise a renamed resubmission would evade deduplication.
let a = valid_from(&movie_json("504172", 6420.5)); let a = valid_from(&movie_json("504172", 6420.5));
let b = valid_from( let b = valid_from(
r#"{"jmanifest_version":1, r#"{"jmanifest_version":2,
"identity":{"type":"movie","tmdb_id":"504172","title":"A Film"}, "identity":{"type":"movie","tmdb_id":"504172","title":"A Film"},
"cut":{"runtime_sec":6420.5}, "cut":{"runtime_sec":6420.5},
"extraction":{"sample_fps":5,"pipeline_version":"test 0.1"}, "extraction":{"sample_fps":5,"pipeline_version":"test 0.1"},
"actors":[{"name":"Someone Else","tmdb_id":"884","scenes":[[10.0,20.0]]}, "actors":[{"name":"Someone Else","tmdb_id":"884","scenes":[{"start":10.0,"end":20.0}]},
{"name":"Another Person","tmdb_id":"11007","scenes":[[30.0,40.0]]}]}"#, {"name":"Another Person","tmdb_id":"11007","scenes":[{"start":30.0,"end":40.0}]}]}"#,
); );
assert_eq!(compute_content_id(&a), compute_content_id(&b)); assert_eq!(compute_content_id(&a), compute_content_id(&b));
} }
+92 -9
View File
@@ -190,8 +190,74 @@ pub struct Actor {
/// The **primary** actor join key (§2, §6 stage 3). /// The **primary** actor join key (§2, §6 stage 3).
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub tmdb_id: Option<String>, pub tmdb_id: Option<String>,
/// `[start_sec, end_sec]` inclusive, sorted. /// Presence windows, inclusive and sorted by start.
pub scenes: Vec<[f64; 2]>, pub scenes: Vec<Scene>,
}
/// TRACES: UR-017 | SR-003
/// How an actor was identified for a given window (`scene-actor-extraction`
/// AR-017: every presence claim carries its belief and identification route).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Route {
/// Identified while the track was live.
Live,
/// Resolved by the deferred pass, after the final frame was read.
Deferred,
/// Resolved from the per-subject embedding pool.
Pooled,
}
impl Route {
pub fn as_str(self) -> &'static str {
match self {
Route::Live => "live",
Route::Deferred => "deferred",
Route::Pooled => "pooled",
}
}
/// Parses a value read back from storage. Returns `None` for anything
/// unrecognised rather than guessing — a row written by a future schema
/// means something this build does not know, and inventing a route would
/// misreport provenance.
///
/// Deliberately not `FromStr`: that trait is for parsing *input*, and this
/// reads a value the server itself wrote from a closed enum. Keeping them
/// distinct stops a future refactor pointing user input at this path.
pub fn from_stored(s: &str) -> Option<Self> {
match s {
"live" => Some(Route::Live),
"deferred" => Some(Route::Deferred),
"pooled" => Some(Route::Pooled),
_ => None,
}
}
}
/// TRACES: UR-013, UR-017 | SR-002, SR-003
/// One presence window.
///
/// **A window is a claim about scene membership, not a recognition event**
/// (SR-002, UR-013). An actor who turns away or is off-camera during a reverse
/// shot is still present, so the server never merges, splits or trims these —
/// it stores what it was given, quantised but not reshaped.
///
/// `belief` and `route` are **excluded from `content_id`** (§9a). Belief is a
/// producer-side estimate that may legitimately differ between pipeline versions
/// for identical timings, so including it would give two servers different ids
/// for the same content — the exact failure mode §9a quantises centiseconds to
/// avoid. They replicate as attributes, exactly as `audio_signature` does.
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Scene {
pub start: f64,
pub end: f64,
/// Accumulated posterior that justified the claim, in `[0, 1]`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub belief: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<Route>,
} }
/// One shareable actor timeline for one cut of one title (§2). /// One shareable actor timeline for one cut of one title (§2).
@@ -240,7 +306,21 @@ pub struct Coverage {
} }
/// The current `jmanifest_version` this server speaks (§2). /// The current `jmanifest_version` this server speaks (§2).
pub const JMANIFEST_VERSION: u32 = 1; ///
/// Bumped to 2 with the SR-003 schema change, in lockstep with the truth file's
/// `schema_version` — breaking changes are batched and ship together across all
/// three repos, so a component moving alone is the defect this coordination
/// exists to prevent.
///
/// **Flag day, not dual-accept** (SR-003, `jRay` JR-003). Version 1 is rejected
/// outright rather than carried alongside: all three components are pre-release,
/// and a v1 read path would be the one nobody exercises, so it is the one that
/// would rot while being dragged through every later change to the reader.
///
/// The two version fields remain *independent by design* — `jmanifest_version`
/// versions the exchange envelope, `schema_version` the truth file — and they
/// coincide at 2 only because this bump touched both.
pub const JMANIFEST_VERSION: u32 = 2;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -248,7 +328,7 @@ mod tests {
#[test] #[test]
fn unknown_field_at_top_level_is_rejected() { fn unknown_field_at_top_level_is_rejected() {
let json = r#"{"jmanifest_version":1,"identity":{"type":"movie","tmdb_id":"1"}, let json = r#"{"jmanifest_version":2,"identity":{"type":"movie","tmdb_id":"1"},
"cut":{"runtime_sec":100.0},"actors":[],"surprise":"x"}"#; "cut":{"runtime_sec":100.0},"actors":[],"surprise":"x"}"#;
let err = serde_json::from_str::<Jmanifest>(json).unwrap_err().to_string(); let err = serde_json::from_str::<Jmanifest>(json).unwrap_err().to_string();
assert!(err.contains("surprise"), "error should name the field: {err}"); assert!(err.contains("surprise"), "error should name the field: {err}");
@@ -258,7 +338,7 @@ mod tests {
fn jellyfin_id_on_an_actor_is_a_parse_error() { fn jellyfin_id_on_an_actor_is_a_parse_error() {
// §2: `actors[].jellyfin_id` must not appear. `deny_unknown_fields` // §2: `actors[].jellyfin_id` must not appear. `deny_unknown_fields`
// makes this structural rather than a validator's responsibility. // makes this structural rather than a validator's responsibility.
let json = r#"{"jmanifest_version":1,"identity":{"type":"movie","tmdb_id":"1"}, let json = r#"{"jmanifest_version":2,"identity":{"type":"movie","tmdb_id":"1"},
"cut":{"runtime_sec":100.0}, "cut":{"runtime_sec":100.0},
"actors":[{"name":"A","tmdb_id":"2","jellyfin_id":"guid","scenes":[]}]}"#; "actors":[{"name":"A","tmdb_id":"2","jellyfin_id":"guid","scenes":[]}]}"#;
let err = serde_json::from_str::<Jmanifest>(json).unwrap_err().to_string(); let err = serde_json::from_str::<Jmanifest>(json).unwrap_err().to_string();
@@ -267,7 +347,7 @@ mod tests {
#[test] #[test]
fn movie_path_field_is_a_parse_error() { fn movie_path_field_is_a_parse_error() {
let json = r#"{"jmanifest_version":1,"movie":"/data/movies/x.mkv", let json = r#"{"jmanifest_version":2,"movie":"/data/movies/x.mkv",
"identity":{"type":"movie","tmdb_id":"1"}, "identity":{"type":"movie","tmdb_id":"1"},
"cut":{"runtime_sec":100.0},"actors":[]}"#; "cut":{"runtime_sec":100.0},"actors":[]}"#;
let err = serde_json::from_str::<Jmanifest>(json).unwrap_err().to_string(); let err = serde_json::from_str::<Jmanifest>(json).unwrap_err().to_string();
@@ -276,7 +356,7 @@ mod tests {
#[test] #[test]
fn unknown_field_nested_in_cut_is_rejected() { fn unknown_field_nested_in_cut_is_rejected() {
let json = r#"{"jmanifest_version":1,"identity":{"type":"movie","tmdb_id":"1"}, let json = r#"{"jmanifest_version":2,"identity":{"type":"movie","tmdb_id":"1"},
"cut":{"runtime_sec":100.0,"payload":"x"},"actors":[]}"#; "cut":{"runtime_sec":100.0,"payload":"x"},"actors":[]}"#;
assert!(serde_json::from_str::<Jmanifest>(json).is_err()); assert!(serde_json::from_str::<Jmanifest>(json).is_err());
} }
@@ -284,7 +364,7 @@ mod tests {
#[test] #[test]
fn spec_example_manifest_parses() { fn spec_example_manifest_parses() {
let json = r#"{ let json = r#"{
"jmanifest_version": 1, "jmanifest_version": 2,
"identity": { "type": "movie", "tmdb_id": "504172", "imdb_id": "tt4686844", "identity": { "type": "movie", "tmdb_id": "504172", "imdb_id": "tt4686844",
"title": "The Death of Stalin", "year": 2017 }, "title": "The Death of Stalin", "year": 2017 },
"cut": { "runtime_sec": 6420.5, "container_duration_sec": 6420.5, "cut": { "runtime_sec": 6420.5, "container_duration_sec": 6420.5,
@@ -293,7 +373,10 @@ mod tests {
"pipeline_version": "scene-actor-extraction 0.4.1", "pipeline_version": "scene-actor-extraction 0.4.1",
"gallery_size": 1820, "gallery_scope": "global" }, "gallery_size": 1820, "gallery_scope": "global" },
"actors": [ { "name": "Steve Buscemi", "imdb_id": "nm0000114", "tmdb_id": "884", "actors": [ { "name": "Steve Buscemi", "imdb_id": "nm0000114", "tmdb_id": "884",
"scenes": [[191.6, 209.2], [438.2, 465.6]] } ] "scenes": [
{ "start": 191.6, "end": 209.2, "belief": 0.98, "route": "live" },
{ "start": 438.2, "end": 465.6, "belief": 0.81, "route": "deferred" }
] } ]
}"#; }"#;
let m: Jmanifest = serde_json::from_str(json).unwrap(); let m: Jmanifest = serde_json::from_str(json).unwrap();
assert_eq!(m.actors.len(), 1); assert_eq!(m.actors.len(), 1);
+85 -28
View File
@@ -7,7 +7,9 @@
use unicode_general_category::{get_general_category, GeneralCategory}; use unicode_general_category::{get_general_category, GeneralCategory};
use unicode_normalization::{is_nfc, UnicodeNormalization}; use unicode_normalization::{is_nfc, UnicodeNormalization};
use crate::model::{Actor, Identity, IdentityType, Jmanifest, SeriesBundle, JMANIFEST_VERSION}; use crate::model::{
Actor, Identity, IdentityType, Jmanifest, Route, SeriesBundle, JMANIFEST_VERSION,
};
/// §6 stage 1 caps. Body-size limits are applied as a layer (see [`crate::app`]); /// §6 stage 1 caps. Body-size limits are applied as a layer (see [`crate::app`]);
/// these are the structural counts the schema layer enforces. /// these are the structural counts the schema layer enforces.
@@ -60,13 +62,35 @@ pub struct ValidManifest {
pub actor_scenes_cs: Vec<ActorScenes>, pub actor_scenes_cs: Vec<ActorScenes>,
} }
/// One validated window, quantised and carrying its provenance.
///
/// `belief` and `route` ride alongside `start_cs`/`end_cs` rather than being
/// folded into them, because §9a hashes only the timings: belief is a
/// producer-side estimate that may differ between pipeline versions for
/// identical content, so it is replicated as an attribute, never as identity.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SceneCs {
pub start_cs: i64,
pub end_cs: i64,
pub belief: Option<f64>,
pub route: Option<Route>,
}
impl SceneCs {
/// A window carrying timings only — the shape a producer that has not yet
/// adopted per-window belief emits, and the one `content_id` hashes.
pub fn plain(start_cs: i64, end_cs: i64) -> Self {
Self { start_cs, end_cs, belief: None, route: None }
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ActorScenes { pub struct ActorScenes {
/// NFC-normalised name, used only for matching in stage 3 and then dropped. /// NFC-normalised name, used only for matching in stage 3 and then dropped.
pub name: Option<String>, pub name: Option<String>,
pub tmdb_id: Option<u64>, pub tmdb_id: Option<u64>,
pub imdb_id: Option<String>, pub imdb_id: Option<String>,
pub scenes_cs: Vec<(i64, i64)>, pub scenes_cs: Vec<SceneCs>,
} }
/// Quantises seconds to whole centiseconds (§9a). /// Quantises seconds to whole centiseconds (§9a).
@@ -484,7 +508,7 @@ fn validate_actors(m: &Jmanifest) -> VResult<Vec<ActorScenes>> {
} }
/// TRACES: UR-013 | SR-002 /// TRACES: UR-013 | SR-002
fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<(i64, i64)>> { fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<SceneCs>> {
if a.scenes.len() > limits::MAX_SCENES_PER_ACTOR { if a.scenes.len() > limits::MAX_SCENES_PER_ACTOR {
return Err(err( return Err(err(
format!("actors[{idx}].scenes"), format!("actors[{idx}].scenes"),
@@ -494,8 +518,9 @@ fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<(i64,
let max_t = runtime_sec + limits::RUNTIME_TOLERANCE_SEC; let max_t = runtime_sec + limits::RUNTIME_TOLERANCE_SEC;
let mut out = Vec::with_capacity(a.scenes.len()); let mut out = Vec::with_capacity(a.scenes.len());
for (j, [start, end]) in a.scenes.iter().copied().enumerate() { for (j, scene) in a.scenes.iter().copied().enumerate() {
let field = format!("actors[{idx}].scenes[{j}]"); let field = format!("actors[{idx}].scenes[{j}]");
let (start, end) = (scene.start, scene.end);
// §6: non-finite values (NaN/Infinity), negative times, `end < start`, // §6: non-finite values (NaN/Infinity), negative times, `end < start`,
// or times beyond `runtime_sec` + tolerance. // or times beyond `runtime_sec` + tolerance.
if !start.is_finite() || !end.is_finite() { if !start.is_finite() || !end.is_finite() {
@@ -516,12 +541,33 @@ fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<(i64,
), ),
)); ));
} }
out.push((to_centiseconds(start), to_centiseconds(end)));
// A posterior outside scene(0, 1) is not a probability. Bounded here rather
// than merely stored, because §5a's Threat 1 argument rests on every
// accepted value being a *bounded* number — an unbounded float is a
// 64-bit channel, however harmless it looks.
if let Some(belief) = scene.belief {
if !belief.is_finite() || !(0.0..=1.0).contains(&belief) {
return Err(err(
format!("actors[{idx}].scenes[{j}].belief"),
"must be a finite probability in scene(0, 1)",
));
}
}
// `route` is a closed enum, so an unrecognised value is already a parse
// error — nothing to check here.
out.push(SceneCs {
start_cs: to_centiseconds(start),
end_cs: to_centiseconds(end),
belief: scene.belief,
route: scene.route,
});
} }
// §2: scenes are sorted. Checked on the quantised values so the stored form // §2: scenes are sorted. Checked on the quantised values so the stored form
// is the one guaranteed ordered. // is the one guaranteed ordered.
if out.windows(2).any(|w| w[1].0 < w[0].0) { if out.windows(2).any(|w| w[1].start_cs < w[0].start_cs) {
return Err(err(format!("actors[{idx}].scenes"), "windows must be sorted by start time")); return Err(err(format!("actors[{idx}].scenes"), "windows must be sorted by start time"));
} }
Ok(out) Ok(out)
@@ -641,13 +687,19 @@ fn base64_decode(s: &str) -> Result<Vec<u8>, &'static str> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::model::Scene;
/// A window with timings only — belief and route are exercised separately.
fn scene(start: f64, end: f64) -> Scene {
Scene { start, end, belief: None, route: None }
}
fn base_manifest() -> Jmanifest { fn base_manifest() -> Jmanifest {
serde_json::from_str( serde_json::from_str(
r#"{"jmanifest_version":1, r#"{"jmanifest_version":2,
"identity":{"type":"movie","tmdb_id":"504172","title":"The Death of Stalin"}, "identity":{"type":"movie","tmdb_id":"504172","title":"The Death of Stalin"},
"cut":{"runtime_sec":6420.5}, "cut":{"runtime_sec":6420.5},
"actors":[{"name":"Steve Buscemi","tmdb_id":"884","scenes":[[191.6,209.2]]}]}"#, "actors":[{"name":"Steve Buscemi","tmdb_id":"884","scenes":[{"start":191.6,"end":209.2}]}]}"#,
) )
.unwrap() .unwrap()
} }
@@ -656,7 +708,7 @@ mod tests {
fn accepts_a_realistic_manifest() { fn accepts_a_realistic_manifest() {
let v = validate_manifest(base_manifest()).unwrap(); let v = validate_manifest(base_manifest()).unwrap();
assert_eq!(v.actor_scenes_cs.len(), 1); assert_eq!(v.actor_scenes_cs.len(), 1);
assert_eq!(v.actor_scenes_cs[0].scenes_cs, vec![(19160, 20920)]); assert_eq!(v.actor_scenes_cs[0].scenes_cs, vec![SceneCs::plain(19160, 20920)]);
} }
#[test] #[test]
@@ -672,15 +724,20 @@ mod tests {
// left and returned (two windows). // left and returned (two windows).
let mut m = base_manifest(); let mut m = base_manifest();
m.actors[0].scenes = vec![ m.actors[0].scenes = vec![
[10.0, 20.0], scene(10.0, 20.0),
[20.0, 30.0], // exactly adjacent — must stay separate scene(20.0, 30.0), // exactly adjacent — must stay separate
[30.01, 40.0], // a hair's gap — likewise scene(30.01, 40.0), // a hair's gap — likewise
[100.0, 100.0], // zero-length — a real producer emits these scene(100.0, 100.0), // zero-length — a real producer emits these
]; ];
let v = validate_manifest(m).unwrap(); let v = validate_manifest(m).unwrap();
assert_eq!( assert_eq!(
v.actor_scenes_cs[0].scenes_cs, v.actor_scenes_cs[0].scenes_cs,
vec![(1000, 2000), (2000, 3000), (3001, 4000), (10000, 10000)], vec![
SceneCs::plain(1000, 2000),
SceneCs::plain(2000, 3000),
SceneCs::plain(3001, 4000),
SceneCs::plain(10000, 10000)
],
"windows must survive validation unchanged apart from quantisation" "windows must survive validation unchanged apart from quantisation"
); );
} }
@@ -723,7 +780,7 @@ mod tests {
#[test] #[test]
fn rejects_scene_beyond_runtime_tolerance() { fn rejects_scene_beyond_runtime_tolerance() {
let mut m = base_manifest(); let mut m = base_manifest();
m.actors[0].scenes = vec![[10.0, 6500.0]]; m.actors[0].scenes = vec![scene(10.0, 6500.0)];
let e = validate_manifest(m).unwrap_err(); let e = validate_manifest(m).unwrap_err();
assert!(e.field.starts_with("actors[0].scenes"), "got {}", e.field); assert!(e.field.starts_with("actors[0].scenes"), "got {}", e.field);
} }
@@ -731,17 +788,17 @@ mod tests {
#[test] #[test]
fn accepts_scene_within_runtime_tolerance() { fn accepts_scene_within_runtime_tolerance() {
let mut m = base_manifest(); let mut m = base_manifest();
m.actors[0].scenes = vec![[10.0, 6424.0]]; m.actors[0].scenes = vec![scene(10.0, 6424.0)];
assert!(validate_manifest(m).is_ok()); assert!(validate_manifest(m).is_ok());
} }
#[test] #[test]
fn rejects_end_before_start_and_negative_and_nonfinite() { fn rejects_end_before_start_and_negative_and_nonfinite() {
for scenes in [ for scenes in [
vec![[50.0, 10.0]], vec![scene(50.0, 10.0)],
vec![[-1.0, 10.0]], vec![scene(-1.0, 10.0)],
vec![[f64::NAN, 10.0]], vec![scene(f64::NAN, 10.0)],
vec![[0.0, f64::INFINITY]], vec![scene(0.0, f64::INFINITY)],
] { ] {
let mut m = base_manifest(); let mut m = base_manifest();
m.actors[0].scenes = scenes; m.actors[0].scenes = scenes;
@@ -752,7 +809,7 @@ mod tests {
#[test] #[test]
fn rejects_unsorted_scenes() { fn rejects_unsorted_scenes() {
let mut m = base_manifest(); let mut m = base_manifest();
m.actors[0].scenes = vec![[100.0, 120.0], [10.0, 20.0]]; m.actors[0].scenes = vec![scene(100.0, 120.0), scene(10.0, 20.0)];
let e = validate_manifest(m).unwrap_err(); let e = validate_manifest(m).unwrap_err();
assert_eq!(e.field, "actors[0].scenes"); assert_eq!(e.field, "actors[0].scenes");
} }
@@ -791,10 +848,10 @@ mod tests {
#[test] #[test]
fn episode_identity_requires_season_and_episode() { fn episode_identity_requires_season_and_episode() {
let json = r#"{"jmanifest_version":1, let json = r#"{"jmanifest_version":2,
"identity":{"type":"episode","series_tmdb_id":"1396","title":"Breaking Bad"}, "identity":{"type":"episode","series_tmdb_id":"1396","title":"Breaking Bad"},
"cut":{"runtime_sec":2820.0}, "cut":{"runtime_sec":2820.0},
"actors":[{"name":"Bryan Cranston","tmdb_id":"17419","scenes":[[10.0,20.0]]}]}"#; "actors":[{"name":"Bryan Cranston","tmdb_id":"17419","scenes":[{"start":10.0,"end":20.0}]}]}"#;
let m: Jmanifest = serde_json::from_str(json).unwrap(); let m: Jmanifest = serde_json::from_str(json).unwrap();
let e = validate_manifest(m).unwrap_err(); let e = validate_manifest(m).unwrap_err();
assert_eq!(e.field, "identity.season"); assert_eq!(e.field, "identity.season");
@@ -802,11 +859,11 @@ mod tests {
#[test] #[test]
fn valid_episode_identity_is_accepted() { fn valid_episode_identity_is_accepted() {
let json = r#"{"jmanifest_version":1, let json = r#"{"jmanifest_version":2,
"identity":{"type":"episode","series_tmdb_id":"1396","series_imdb_id":"tt0903747", "identity":{"type":"episode","series_tmdb_id":"1396","series_imdb_id":"tt0903747",
"title":"Breaking Bad","season":2,"episode":5}, "title":"Breaking Bad","season":2,"episode":5},
"cut":{"runtime_sec":2820.0}, "cut":{"runtime_sec":2820.0},
"actors":[{"name":"Bryan Cranston","tmdb_id":"17419","scenes":[[10.0,20.0]]}]}"#; "actors":[{"name":"Bryan Cranston","tmdb_id":"17419","scenes":[{"start":10.0,"end":20.0}]}]}"#;
let m: Jmanifest = serde_json::from_str(json).unwrap(); let m: Jmanifest = serde_json::from_str(json).unwrap();
assert!(validate_manifest(m).is_ok()); assert!(validate_manifest(m).is_ok());
} }
@@ -1040,12 +1097,12 @@ mod tests {
#[test] #[test]
fn bundle_envelope_checks() { fn bundle_envelope_checks() {
let ok = r#"{"jmanifest_version":1, let ok = r#"{"jmanifest_version":2,
"series":{"series_tmdb_id":"1396","title":"Breaking Bad"}, "series":{"series_tmdb_id":"1396","title":"Breaking Bad"},
"episodes":[{"jmanifest_version":1, "episodes":[{"jmanifest_version":2,
"identity":{"type":"episode","series_tmdb_id":"1396","season":1,"episode":1}, "identity":{"type":"episode","series_tmdb_id":"1396","season":1,"episode":1},
"cut":{"runtime_sec":2820.0}, "cut":{"runtime_sec":2820.0},
"actors":[{"tmdb_id":"17419","scenes":[[1.0,2.0]]}]}]}"#; "actors":[{"tmdb_id":"17419","scenes":[{"start":1.0,"end":2.0}]}]}]}"#;
let b: SeriesBundle = serde_json::from_str(ok).unwrap(); let b: SeriesBundle = serde_json::from_str(ok).unwrap();
assert!(validate_bundle_envelope(&b).is_ok()); assert!(validate_bundle_envelope(&b).is_ok());
+57 -20
View File
@@ -149,7 +149,7 @@ impl TestServer {
fn movie_manifest(tmdb_id: &str, runtime: f64) -> Value { fn movie_manifest(tmdb_id: &str, runtime: f64) -> Value {
json!({ json!({
"jmanifest_version": 1, "jmanifest_version": 2,
"identity": { "type": "movie", "tmdb_id": tmdb_id, "title": "The Death of Stalin", "identity": { "type": "movie", "tmdb_id": tmdb_id, "title": "The Death of Stalin",
"year": 2017 }, "year": 2017 },
"cut": { "runtime_sec": runtime, "video_hash": "opensubtitles:8e245d9679d31e12" }, "cut": { "runtime_sec": runtime, "video_hash": "opensubtitles:8e245d9679d31e12" },
@@ -157,8 +157,8 @@ fn movie_manifest(tmdb_id: &str, runtime: f64) -> Value {
"pipeline_version": "scene-actor-extraction 0.4.1", "pipeline_version": "scene-actor-extraction 0.4.1",
"gallery_scope": "global" }, "gallery_scope": "global" },
"actors": [ "actors": [
{ "name": "Steve Buscemi", "tmdb_id": "884", "scenes": [[191.6, 209.2], [438.2, 465.6]] }, { "name": "Steve Buscemi", "tmdb_id": "884", "scenes": [{"start":191.6,"end":209.2},{"start":438.2,"end":465.6}] },
{ "name": "Michael Palin", "tmdb_id": "11007", "scenes": [[300.0, 320.0]] } { "name": "Michael Palin", "tmdb_id": "11007", "scenes": [{"start":300.0,"end":320.0}] }
] ]
}) })
} }
@@ -341,6 +341,39 @@ async fn the_schema_bump_fields_round_trip() {
assert_eq!(status, StatusCode::BAD_REQUEST, "gallery_scope is a closed enum"); assert_eq!(status, StatusCode::BAD_REQUEST, "gallery_scope is a closed enum");
} }
#[tokio::test]
async fn per_window_belief_and_route_round_trip() {
// SR-003 gives each window its posterior and identification route
// (extraction AR-017). They are stored and served — a consumer needs them to
// know how much to trust a window — but they are never part of identity.
let s = TestServer::new("belief");
let token = s.token().await;
let mut m = movie_manifest("504172", 6420.5);
m["actors"][0]["scenes"] = json!([
{ "start": 191.6, "end": 209.2, "belief": 0.98, "route": "live" },
{ "start": 438.2, "end": 465.6, "belief": 0.81, "route": "deferred" }
]);
let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
assert_eq!(status, StatusCode::ACCEPTED, "{body}");
// A belief outside [0, 1] is not a probability. Bounded rather than merely
// stored, because §5a's Threat 1 argument rests on every accepted value
// being bounded — an unbounded float is a 64-bit channel.
for bad in [-0.1, 1.5] {
let mut m = movie_manifest("504173", 6420.5);
m["actors"][0]["scenes"] = json!([{ "start": 1.0, "end": 2.0, "belief": bad }]);
let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "belief {bad}: {body}");
}
// `route` is a closed vocabulary, so an invented value cannot be stored.
let mut m = movie_manifest("504174", 6420.5);
m["actors"][0]["scenes"] = json!([{ "start": 1.0, "end": 2.0, "route": "telepathy" }]);
let (status, _, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test] #[tokio::test]
async fn an_unknown_manifest_version_is_rejected() { async fn an_unknown_manifest_version_is_rejected() {
// UR-014 / SR-003: a consumer encountering an unknown `schema_version` // UR-014 / SR-003: a consumer encountering an unknown `schema_version`
@@ -348,7 +381,11 @@ async fn an_unknown_manifest_version_is_rejected() {
let s = TestServer::new("version"); let s = TestServer::new("version");
let token = s.token().await; let token = s.token().await;
for version in [0, 2, 99] { // Version 1 is the one that matters: SR-003 settled on a **flag day**, not a
// dual-accept period, so the immediately-previous version is refused exactly
// like a nonsensical one. A v1 read path would be the one nobody exercises,
// and so the one that rots while being dragged through every later change.
for version in [0, 1, 3, 99] {
let mut m = movie_manifest("504172", 6420.5); let mut m = movie_manifest("504172", 6420.5);
m["jmanifest_version"] = json!(version); m["jmanifest_version"] = json!(version);
let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await; let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await;
@@ -560,7 +597,7 @@ async fn exceeding_a_limit_returns_429_with_retry_after() {
// The bundle surface has the tightest write limit (20/hour), so it is the // The bundle surface has the tightest write limit (20/hour), so it is the
// cheapest to exhaust. // cheapest to exhaust.
let bundle = json!({ let bundle = json!({
"jmanifest_version": 1, "jmanifest_version": 2,
"series": { "series_tmdb_id": "1396", "title": "Breaking Bad" }, "series": { "series_tmdb_id": "1396", "title": "Breaking Bad" },
"episodes": [] "episodes": []
}); });
@@ -688,24 +725,24 @@ async fn bundle_upload_is_not_atomic() {
let good = |ep: i64| { let good = |ep: i64| {
json!({ json!({
"jmanifest_version": 1, "jmanifest_version": 2,
"identity": { "type": "episode", "series_tmdb_id": "1396", "title": "Breaking Bad", "identity": { "type": "episode", "series_tmdb_id": "1396", "title": "Breaking Bad",
"season": 1, "episode": ep }, "season": 1, "episode": ep },
"cut": { "runtime_sec": 2820.0 }, "cut": { "runtime_sec": 2820.0 },
"actors": [ { "name": "Bryan Cranston", "tmdb_id": "17419", "actors": [ { "name": "Bryan Cranston", "tmdb_id": "17419",
"scenes": [[10.0, 20.0]] } ] "scenes": [{"start":10.0,"end":20.0}] } ]
}) })
}; };
// Invalid: a scene window beyond the runtime tolerance (§6). // Invalid: a scene window beyond the runtime tolerance (§6).
let bad = json!({ let bad = json!({
"jmanifest_version": 1, "jmanifest_version": 2,
"identity": { "type": "episode", "series_tmdb_id": "1396", "season": 1, "episode": 3 }, "identity": { "type": "episode", "series_tmdb_id": "1396", "season": 1, "episode": 3 },
"cut": { "runtime_sec": 2820.0 }, "cut": { "runtime_sec": 2820.0 },
"actors": [ { "tmdb_id": "17419", "scenes": [[10.0, 99999.0]] } ] "actors": [ { "tmdb_id": "17419", "scenes": [{"start":10.0,"end":99999.0}] } ]
}); });
let bundle = json!({ let bundle = json!({
"jmanifest_version": 1, "jmanifest_version": 2,
"series": { "series_tmdb_id": "1396", "title": "Breaking Bad" }, "series": { "series_tmdb_id": "1396", "title": "Breaking Bad" },
"episodes": [ good(1), bad, good(2) ] "episodes": [ good(1), bad, good(2) ]
}); });
@@ -731,7 +768,7 @@ async fn bundle_envelope_errors_are_whole_request_400s() {
.post_json_auth( .post_json_auth(
"/api/v1/manifests/bundle", "/api/v1/manifests/bundle",
&token, &token,
&json!({ "jmanifest_version": 1, "series": {}, "episodes": [] }), &json!({ "jmanifest_version": 2, "series": {}, "episodes": [] }),
) )
.await; .await;
assert_eq!(status, StatusCode::BAD_REQUEST, "series needs an identifier"); assert_eq!(status, StatusCode::BAD_REQUEST, "series needs an identifier");
@@ -740,7 +777,7 @@ async fn bundle_envelope_errors_are_whole_request_400s() {
.post_json_auth( .post_json_auth(
"/api/v1/manifests/bundle", "/api/v1/manifests/bundle",
&token, &token,
&json!({ "jmanifest_version": 1, &json!({ "jmanifest_version": 2,
"series": { "series_tmdb_id": "1396" }, "series": { "series_tmdb_id": "1396" },
"episodes": [], "extra": 1 }), "episodes": [], "extra": 1 }),
) )
@@ -754,13 +791,13 @@ async fn bundle_rejects_an_episode_contradicting_the_envelope() {
let s = TestServer::new("bundle-mismatch"); let s = TestServer::new("bundle-mismatch");
let token = s.token().await; let token = s.token().await;
let bundle = json!({ let bundle = json!({
"jmanifest_version": 1, "jmanifest_version": 2,
"series": { "series_tmdb_id": "1396" }, "series": { "series_tmdb_id": "1396" },
"episodes": [ { "episodes": [ {
"jmanifest_version": 1, "jmanifest_version": 2,
"identity": { "type": "episode", "series_tmdb_id": "9999", "season": 1, "episode": 1 }, "identity": { "type": "episode", "series_tmdb_id": "9999", "season": 1, "episode": 1 },
"cut": { "runtime_sec": 2820.0 }, "cut": { "runtime_sec": 2820.0 },
"actors": [ { "tmdb_id": "17419", "scenes": [[1.0, 2.0]] } ] "actors": [ { "tmdb_id": "17419", "scenes": [{"start":1.0,"end":2.0}] } ]
} ] } ]
}); });
let (status, body, _) = s.post_json_auth("/api/v1/manifests/bundle", &token, &bundle).await; let (status, body, _) = s.post_json_auth("/api/v1/manifests/bundle", &token, &bundle).await;
@@ -776,16 +813,16 @@ async fn bundle_beyond_the_episode_cap_is_413() {
let episodes: Vec<Value> = (0..501) let episodes: Vec<Value> = (0..501)
.map(|i| { .map(|i| {
json!({ json!({
"jmanifest_version": 1, "jmanifest_version": 2,
"identity": { "type": "episode", "series_tmdb_id": "1396", "identity": { "type": "episode", "series_tmdb_id": "1396",
"season": 1, "episode": i }, "season": 1, "episode": i },
"cut": { "runtime_sec": 2820.0 }, "cut": { "runtime_sec": 2820.0 },
"actors": [ { "tmdb_id": "17419", "scenes": [[1.0, 2.0]] } ] "actors": [ { "tmdb_id": "17419", "scenes": [{"start":1.0,"end":2.0}] } ]
}) })
}) })
.collect(); .collect();
let bundle = json!({ let bundle = json!({
"jmanifest_version": 1, "jmanifest_version": 2,
"series": { "series_tmdb_id": "1396" }, "series": { "series_tmdb_id": "1396" },
"episodes": episodes "episodes": episodes
}); });
@@ -805,7 +842,7 @@ async fn bundle_route_accepts_a_body_larger_than_the_single_manifest_cap() {
.map(|ep| { .map(|ep| {
let scenes: Vec<Value> = (0..600).map(|j| json!([j as f64, (j + 1) as f64])).collect(); let scenes: Vec<Value> = (0..600).map(|j| json!([j as f64, (j + 1) as f64])).collect();
json!({ json!({
"jmanifest_version": 1, "jmanifest_version": 2,
"identity": { "type": "episode", "series_tmdb_id": "1396", "identity": { "type": "episode", "series_tmdb_id": "1396",
"season": 1, "episode": ep }, "season": 1, "episode": ep },
"cut": { "runtime_sec": 2820.0 }, "cut": { "runtime_sec": 2820.0 },
@@ -816,7 +853,7 @@ async fn bundle_route_accepts_a_body_larger_than_the_single_manifest_cap() {
}) })
.collect(); .collect();
let bundle = json!({ let bundle = json!({
"jmanifest_version": 1, "jmanifest_version": 2,
"series": { "series_tmdb_id": "1396" }, "series": { "series_tmdb_id": "1396" },
"episodes": episodes "episodes": episodes
}); });
+20 -20
View File
@@ -261,10 +261,10 @@ async fn sql_payloads_in_identifier_fields_are_rejected() {
for payload in SQL_PAYLOADS { for payload in SQL_PAYLOADS {
let manifest = json!({ let manifest = json!({
"jmanifest_version": 1, "jmanifest_version": 2,
"identity": { "type": "movie", "tmdb_id": payload }, "identity": { "type": "movie", "tmdb_id": payload },
"cut": { "runtime_sec": 100.0 }, "cut": { "runtime_sec": 100.0 },
"actors": [ { "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ] "actors": [ { "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ]
}); });
let (status, body) = s.post("/api/v1/manifests", Some(&token), &manifest).await; let (status, body) = s.post("/api/v1/manifests", Some(&token), &manifest).await;
assert_eq!( assert_eq!(
@@ -288,16 +288,16 @@ async fn sql_payloads_in_free_text_fields_are_rejected() {
for payload in SQL_PAYLOADS { for payload in SQL_PAYLOADS {
for manifest in [ for manifest in [
json!({ json!({
"jmanifest_version": 1, "jmanifest_version": 2,
"identity": { "type": "movie", "tmdb_id": "504172", "title": payload }, "identity": { "type": "movie", "tmdb_id": "504172", "title": payload },
"cut": { "runtime_sec": 100.0 }, "cut": { "runtime_sec": 100.0 },
"actors": [ { "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ] "actors": [ { "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ]
}), }),
json!({ json!({
"jmanifest_version": 1, "jmanifest_version": 2,
"identity": { "type": "movie", "tmdb_id": "504172" }, "identity": { "type": "movie", "tmdb_id": "504172" },
"cut": { "runtime_sec": 100.0 }, "cut": { "runtime_sec": 100.0 },
"actors": [ { "name": payload, "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ] "actors": [ { "name": payload, "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ]
}), }),
] { ] {
let (status, body) = s.post("/api/v1/manifests", Some(&token), &manifest).await; let (status, body) = s.post("/api/v1/manifests", Some(&token), &manifest).await;
@@ -326,10 +326,10 @@ async fn sql_payloads_in_a_report_note_cannot_escape() {
"/api/v1/manifests", "/api/v1/manifests",
Some(&token), Some(&token),
&json!({ &json!({
"jmanifest_version": 1, "jmanifest_version": 2,
"identity": { "type": "movie", "tmdb_id": "504172" }, "identity": { "type": "movie", "tmdb_id": "504172" },
"cut": { "runtime_sec": 100.0 }, "cut": { "runtime_sec": 100.0 },
"actors": [ { "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ] "actors": [ { "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ]
}), }),
) )
.await; .await;
@@ -375,10 +375,10 @@ async fn sql_payloads_in_a_bearer_token_are_inert() {
.header("authorization", format!("Bearer {payload}")) .header("authorization", format!("Bearer {payload}"))
.body(Body::from( .body(Body::from(
json!({ json!({
"jmanifest_version": 1, "jmanifest_version": 2,
"identity": { "type": "movie", "tmdb_id": "504172" }, "identity": { "type": "movie", "tmdb_id": "504172" },
"cut": { "runtime_sec": 100.0 }, "cut": { "runtime_sec": 100.0 },
"actors": [ { "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ] "actors": [ { "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ]
}) })
.to_string(), .to_string(),
)) ))
@@ -422,16 +422,16 @@ async fn json_structure_abuse_is_rejected_cleanly() {
let cases: Vec<(&str, String)> = vec![ let cases: Vec<(&str, String)> = vec![
("deep nesting", format!("{}{}", "[".repeat(20_000), "]".repeat(20_000))), ("deep nesting", format!("{}{}", "[".repeat(20_000), "]".repeat(20_000))),
("unterminated", "{\"identity\": {\"type\": \"movie\"".to_string()), ("unterminated", "{\"identity\": {\"type\": \"movie\"".to_string()),
("duplicate keys", r#"{"jmanifest_version":1,"jmanifest_version":2}"#.to_string()), ("duplicate keys", r#"{"jmanifest_version":2,"jmanifest_version":2}"#.to_string()),
("null bytes", "{\"jmanifest_version\":\u{0}1}".to_string()), ("null bytes", "{\"jmanifest_version\":\u{0}1}".to_string()),
("huge number", format!("{{\"jmanifest_version\":{}}}", "9".repeat(5000))), ("huge number", format!("{{\"jmanifest_version\":{}}}", "9".repeat(5000))),
("nan literal", r#"{"jmanifest_version":1,"cut":{"runtime_sec":NaN}}"#.to_string()), ("nan literal", r#"{"jmanifest_version":2,"cut":{"runtime_sec":NaN}}"#.to_string()),
("bare array", "[1,2,3]".to_string()), ("bare array", "[1,2,3]".to_string()),
("bare string", "\"just a string\"".to_string()), ("bare string", "\"just a string\"".to_string()),
("empty body", String::new()), ("empty body", String::new()),
( (
"prototype-style key", "prototype-style key",
r#"{"__proto__":{"admin":true},"jmanifest_version":1}"#.to_string(), r#"{"__proto__":{"admin":true},"jmanifest_version":2}"#.to_string(),
), ),
]; ];
@@ -463,7 +463,7 @@ async fn non_finite_scene_times_are_rejected() {
// Sent as raw JSON text rather than via `json!`, because rustc refuses an // Sent as raw JSON text rather than via `json!`, because rustc refuses an
// out-of-range float literal — and the point is to make the *server's* parser // out-of-range float literal — and the point is to make the *server's* parser
// handle it, which is the real attack path. // handle it, which is the real attack path.
let raw = r#"{"jmanifest_version":1, let raw = r#"{"jmanifest_version":2,
"identity":{"type":"movie","tmdb_id":"504172"}, "identity":{"type":"movie","tmdb_id":"504172"},
"cut":{"runtime_sec":100.0}, "cut":{"runtime_sec":100.0},
"actors":[{"tmdb_id":"884","scenes":[[1.0,1e400]]}]}"#; "actors":[{"tmdb_id":"884","scenes":[[1.0,1e400]]}]}"#;
@@ -479,10 +479,10 @@ async fn non_finite_scene_times_are_rejected() {
assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
// Likewise an overflowing runtime. // Likewise an overflowing runtime.
let raw = r#"{"jmanifest_version":1, let raw = r#"{"jmanifest_version":2,
"identity":{"type":"movie","tmdb_id":"504172"}, "identity":{"type":"movie","tmdb_id":"504172"},
"cut":{"runtime_sec":1e400}, "cut":{"runtime_sec":1e400},
"actors":[{"tmdb_id":"884","scenes":[[1.0,2.0]]}]}"#; "actors":[{"tmdb_id":"884","scenes":[{"start":1.0,"end":2.0}]}]}"#;
let req = Request::builder() let req = Request::builder()
.method("POST") .method("POST")
.uri("/api/v1/manifests") .uri("/api/v1/manifests")
@@ -583,10 +583,10 @@ async fn unicode_tricks_cannot_smuggle_text_past_the_character_class() {
"/api/v1/manifests", "/api/v1/manifests",
Some(&token), Some(&token),
&json!({ &json!({
"jmanifest_version": 1, "jmanifest_version": 2,
"identity": { "type": "movie", "tmdb_id": "504172" }, "identity": { "type": "movie", "tmdb_id": "504172" },
"cut": { "runtime_sec": 100.0 }, "cut": { "runtime_sec": 100.0 },
"actors": [ { "name": payload, "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ] "actors": [ { "name": payload, "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ]
}), }),
) )
.await; .await;
@@ -617,10 +617,10 @@ async fn the_audio_signature_field_cannot_carry_arbitrary_bytes() {
"/api/v1/manifests", "/api/v1/manifests",
Some(&token), Some(&token),
&json!({ &json!({
"jmanifest_version": 1, "jmanifest_version": 2,
"identity": { "type": "movie", "tmdb_id": "504172" }, "identity": { "type": "movie", "tmdb_id": "504172" },
"cut": { "runtime_sec": 6420.5, "audio_signature": sig }, "cut": { "runtime_sec": 6420.5, "audio_signature": sig },
"actors": [ { "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ] "actors": [ { "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ]
}), }),
) )
.await; .await;