Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions docs/UAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,14 @@ Test at least one valid result from each available class:
- [`provider-matrix/signed-redirect`] Provider-proxied/signed URL with bounded redirects.
- [`provider-matrix/subtitle-text`] Text subtitle provider.
- [`provider-matrix/subtitle-ass`] ASS/SSA subtitle provider.
- [`provider-matrix/subtitle-vobsub`] VobSub archive source when available.
- [`provider-matrix/subtitle-vobsub`] Use the deterministic Jumpgate VobSub fixture on
both devices. Capture the exact visible bitmap text `JUMPGATE VOBSUB 1`, `2`, and `3`
while the media progress rail shows the respective 2-5, 7-10, and 12-15 second
windows. Before recording each capture, inspect it and explicitly attest that the exact
cue/time rail are visible and that no notification, account label, pairing code, private
URL, QR code, or unrelated screen content is present. The six captures across both devices
must be distinct. Evidence PNGs are canonical RGB-only files without ancillary/private chunks
or alpha channels. A delivered IDX/SUB pair without visible rendering is a failure.

For each source, record whether canonical identity was claimed. Transport success and
identity success are separate results.
Expand Down Expand Up @@ -253,7 +260,9 @@ Use Trakt's account activity and sanitized device logs to verify:
- [`subtitles/text-ass-fidelity`] Text and ASS/SSA content retains expected text, timing,
encoding, and styling.
- [`subtitles/vobsub-atomic-pair`] VobSub publishes a complete matching IDX/SUB pair before
Kodi injection.
Kodi injection. The finalized report must hash-bind the three PNG render captures;
they are committed beside the report as `<device>-vobsub-cue-1.png` through
`<device>-vobsub-cue-3.png`.
- [`subtitles/picker-controls`] Subtitle picker, enable/disable, language selection, and
delay controls work.
- [`subtitles/replacement-generation`] Replacing a subtitle removes the previous generation
Expand Down
21 changes: 19 additions & 2 deletions release/evidence/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,27 @@ npm run uat:evidence -- init --device-class tv --manufacturer Google \
npm run uat:evidence -- record --workbook .uat/tv.json \
--case lifecycle/start-first-frames \
--observation "First rendered frames appeared and playback remained responsive."
npm run uat:evidence -- record-vobsub --workbook .uat/tv.json \
--cue 1 --capture release/evidence/tv-vobsub-cue-1.png \
--visual-review cue-and-time-rail-confirmed \
--privacy-review sanitized-for-publication
npm run uat:evidence -- status --workbook .uat/tv.json
npm run uat:evidence -- finalize --workbook .uat/tv.json \
--output release/evidence/tv.json
```

Each device report requires three PNG captures named
`<device>-vobsub-cue-1.png` through `<device>-vobsub-cue-3.png` beside the finalized
JSON report. Capture each exact cue while the fixture's visible progress rail lies in
its locked media-time window. Inspect each capture before providing both required review
attestations: the exact cue and time rail must be visible, and the image must be safe to
publish. The recorder rejects duplicate captures, malformed or undecodable PNGs, every ancillary
or private chunk, alpha channels, non-canonical input paths, and captures without both
attestations. All six phone/TV captures must have distinct hashes. Release validation
downloads the exact paths from the report's immutable commit and repeats structure and
hash verification. Do not include notifications, account labels, pairing codes, private
URLs, QR codes, or other unrelated screen content.

Commit the finalized phone and TV reports first. In a second commit, build
`physical-uat.json` using immutable blob URLs at that first commit SHA:

Expand All @@ -43,8 +59,9 @@ npm run uat:evidence -- index \
--output release/evidence/physical-uat.json
```

`evidenceUrl` must be an immutable public blob URL in a Jumpgate repository at a full
commit SHA. The validator downloads that blob and verifies `evidenceSha256`; issue pages,
`evidenceUrl` must be the canonical `release/evidence/phone.json` or
`release/evidence/tv.json` immutable blob URL in the Jumpgate repository at a full commit
SHA. The validator downloads that blob and verifies `evidenceSha256`; issue pages,
branch URLs, expiring Action artifacts, query strings, and fragments are rejected. Serial
numbers, account names, private URLs, tokens, logs, pairing data, and QR images are not
allowed in either the index or the evidence blob.
Expand Down
97 changes: 97 additions & 0 deletions release/png-evidence.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { inflateSync } from "node:zlib";

const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ALLOWED_CHUNKS = new Set(["IHDR", "IDAT", "IEND"]);
const CRC_TABLE = Array.from({ length: 256 }, (_, value) => {
let crc = value;
for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
return crc >>> 0;
});

function fail(message) {
throw new Error(message);
}

function crc32(bytes) {
let crc = 0xffffffff;
for (const byte of bytes) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}

export function validatePublicEvidencePng(bytes, maximumBytes = 20 * 1024 * 1024) {
if (!Buffer.isBuffer(bytes) || bytes.length < 57 || bytes.length > maximumBytes) {
fail("VobSub cue capture must be a bounded complete PNG file");
}
if (!bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
fail("VobSub cue capture must be a PNG file");
}

let offset = PNG_SIGNATURE.length;
let width = 0;
let height = 0;
let channels = 0;
let sawIhdr = false;
let sawIdat = false;
let sawIend = false;
const compressed = [];

while (offset < bytes.length) {
if (offset + 12 > bytes.length) fail("VobSub cue capture has a truncated PNG chunk");
const length = bytes.readUInt32BE(offset);
const end = offset + 12 + length;
if (end > bytes.length) fail("VobSub cue capture has a truncated PNG chunk");
const typeBytes = bytes.subarray(offset + 4, offset + 8);
const type = typeBytes.toString("ascii");
if (!/^[A-Za-z]{4}$/.test(type)) fail("VobSub cue capture has an invalid PNG chunk type");
const data = bytes.subarray(offset + 8, offset + 8 + length);
const expectedCrc = bytes.readUInt32BE(offset + 8 + length);
if (crc32(Buffer.concat([typeBytes, data])) !== expectedCrc) {
fail(`VobSub cue capture has an invalid ${type} checksum`);
}
if (!ALLOWED_CHUNKS.has(type)) fail(`VobSub cue capture must not contain ancillary or private PNG chunk ${type}`);

if (!sawIhdr) {
if (type !== "IHDR" || length !== 13) fail("VobSub cue capture must begin with IHDR");
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
const bitDepth = data[8];
const colorType = data[9];
if (width < 320 || width > 7680 || height < 240 || height > 4320) {
fail("VobSub cue capture dimensions are outside the public evidence bounds");
}
if (bitDepth !== 8 || colorType !== 2 || data[10] !== 0 || data[11] !== 0 || data[12] !== 0) {
fail("VobSub cue capture must be a non-interlaced 8-bit RGB PNG");
}
channels = 3;
sawIhdr = true;
} else if (type === "IHDR") {
fail("VobSub cue capture must contain exactly one IHDR chunk");
} else if (type === "IDAT") {
if (sawIend) fail("VobSub cue capture contains image data after IEND");
sawIdat = true;
compressed.push(data);
} else if (type === "IEND") {
if (length !== 0 || !sawIdat || sawIend) fail("VobSub cue capture has an invalid IEND chunk");
sawIend = true;
offset = end;
if (offset !== bytes.length) fail("VobSub cue capture has trailing bytes after IEND");
break;
}
offset = end;
}

if (!sawIhdr || !sawIdat || !sawIend) fail("VobSub cue capture is missing required PNG chunks");
const rowBytes = width * channels;
const expectedLength = height * (rowBytes + 1);
let pixels;
try {
pixels = inflateSync(Buffer.concat(compressed), { maxOutputLength: expectedLength });
} catch {
fail("VobSub cue capture contains invalid compressed image data");
}
if (pixels.length !== expectedLength) fail("VobSub cue capture has an invalid decoded image size");
for (let row = 0; row < height; row += 1) {
if (pixels[row * (rowBytes + 1)] > 4) fail("VobSub cue capture has an invalid PNG row filter");
}
return { width, height };
}
45 changes: 45 additions & 0 deletions release/tests/png-fixture.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { deflateSync } from "node:zlib";

const SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const CRC_TABLE = Array.from({ length: 256 }, (_, value) => {
let crc = value;
for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
return crc >>> 0;
});

function crc32(bytes) {
let crc = 0xffffffff;
for (const byte of bytes) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}

function chunk(type, data = Buffer.alloc(0)) {
const typeBytes = Buffer.from(type, "ascii");
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])));
return Buffer.concat([length, typeBytes, data, crc]);
}

export function evidencePng(marker = 1, extraChunks = []) {
const width = 320;
const height = 240;
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr.set([8, 2, 0, 0, 0], 8);
const rows = Buffer.alloc(height * (1 + width * 3));
for (let row = 0; row < height; row += 1) {
const offset = row * (1 + width * 3);
rows[offset] = 0;
rows.fill((marker + row) & 0xff, offset + 1, offset + 1 + width * 3);
}
return Buffer.concat([
SIGNATURE,
chunk("IHDR", ihdr),
...extraChunks.map(({ type, data }) => chunk(type, data)),
chunk("IDAT", deflateSync(rows)),
chunk("IEND"),
]);
}
42 changes: 42 additions & 0 deletions release/tests/uat-evidence.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import {
createWorkbook,
finalizeWorkbook,
recordPass,
recordVobSubCue,
validateWorkbook,
} from "../uat-evidence.mjs";
import {
REQUIRED_UAT_CASES,
requiredUatCasesForDevice,
validateEvidence,
} from "../validate-release.mjs";
import { evidencePng } from "./png-fixture.mjs";

const candidate = JSON.parse(readFileSync(new URL("../candidate.json", import.meta.url), "utf8"));
const now = new Date("2026-08-13T12:00:00Z");
Expand All @@ -31,6 +33,13 @@ function input(deviceClass = "tv") {

function completedReport(deviceClass) {
let workbook = createWorkbook(candidate, input(deviceClass), now);
for (const cue of [1, 2, 3]) workbook = recordVobSubCue(
workbook,
candidate,
cue,
evidencePng(cue + (deviceClass === "tv" ? 10 : 0)),
{ capturePath: `release/evidence/${deviceClass}-vobsub-cue-${cue}.png`, visualReview: "cue-and-time-rail-confirmed", privacyReview: "sanitized-for-publication" },
);
for (const [index, id] of requiredUatCasesForDevice(deviceClass).entries()) {
workbook = recordPass(workbook, candidate, id, `Observed expected behavior for policy case ${index + 1}.`);
}
Expand All @@ -46,6 +55,39 @@ test("workbooks bind public candidate artifacts and exact device-scoped policy c
assert.throws(() => finalizeWorkbook(workbook, candidate, now), /remain pending/);
});

test("VobSub render evidence requires three bounded PNG captures", () => {
let workbook = createWorkbook(candidate, input(), now);
assert.throws(
() => recordVobSubCue(workbook, candidate, 1, Buffer.from("not a PNG")),
/PNG file/,
);
assert.throws(
() => recordVobSubCue(workbook, candidate, 1, evidencePng(1)),
/visual-review/,
);
assert.throws(
() => recordVobSubCue(workbook, candidate, 1, evidencePng(1), { capturePath: "elsewhere.png", visualReview: "cue-and-time-rail-confirmed", privacyReview: "sanitized-for-publication" }),
/must be read from release\/evidence/,
);
const review = (cue) => ({ capturePath: `release/evidence/tv-vobsub-cue-${cue}.png`, visualReview: "cue-and-time-rail-confirmed", privacyReview: "sanitized-for-publication" });
workbook = recordVobSubCue(workbook, candidate, 1, evidencePng(1), review(1));
assert.throws(
() => recordVobSubCue(workbook, candidate, 2, evidencePng(1), review(2)),
/distinct images/,
);
assert.throws(
() => recordVobSubCue(workbook, candidate, 2, evidencePng(2, [{ type: "tEXt", data: Buffer.from("Account=test") }]), review(2)),
/ancillary or private/,
);
for (const cue of [2, 3]) workbook = recordVobSubCue(workbook, candidate, cue, evidencePng(cue), review(cue));
workbook = recordVobSubCue(workbook, candidate, 3, evidencePng(3), review(3));
assert.deepEqual(workbook.vobsubRenderEvidence.map(({ cue, status }) => ({ cue, status })), [
{ cue: 1, status: "pass" },
{ cue: 2, status: "pass" },
{ cue: 3, status: "pass" },
]);
});

test("recording rejects unknown cases and secret-shaped observations", () => {
const workbook = createWorkbook(candidate, input(), now);
assert.throws(
Expand Down
Loading