diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index ed7365b..3e6f8ed 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2,7 +2,7 @@ Append-only, one line per decision, newest last. Date-prefixed. Architecture-shaping decisions graduate to an ADR under -[`../development/decisions/adrs/`](../development/decisions/adrs/). +`.abcd/development/decisions/adrs/` (created with the first ADR). - 2026-07-17 — Adopt the three-tier working-state layout (`.abcd/development/` durable, `.abcd/work/` shared, `.abcd/.work.local/` local-only) and the @@ -268,3 +268,18 @@ Architecture-shaping decisions graduate to an ADR under (endpoint preconditions, evidence cap, sessionEnd bound, ffmpeg's real consumers, the demo page's CDN fetch disclosed in privacy). Vendoring rrweb (offline capture) needs a dependency decision — reported, not done. +- 2026-07-29 — Bug-hunt round 10: four hunters, two adversarial refuters. + 3 substantive and 22 nitpick findings confirmed and fixed; 3 candidates + refuted (the -audio sidecar wording, the mode default claim, the + line-wrap premise). Headlines: analyze refuses duplicate timeline ids + (the id-keyed quote validator paired quotes with the wrong moment) and, + with report, refuses unknown-src entries instead of silently omitting + them; the recorder permissions headline fires only on a device-open + failure, not on ffmpeg's ordinary banner; the offset sidecar writes + atomically; the CI smoke gains event-half assertions and the release + workflow asserts the shipped binary names the tag; install.sh verifies + the installed binary before announcing success. Recorded, not fixed: + the VERSION-pin window before a tag exists (process), docs-lint gates + not running in CI (tooling), doubled CI runs on PR branches (trigger + set is tied to the merge queue), and the /dev/null TTY gate (per + round 8). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d4a5cd..eae00d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,24 @@ jobs: grep -q "## Findings" examples/sample-session/report.md grep -q "### Confirmed (1)" examples/sample-session/report.md grep -q "\*\*F-001\*\* bug" examples/sample-session/report.md + # The event half of the pipeline: every assertion above is satisfiable + # from the transcript and findings alone (verified: deleting + # interactions.jsonl left them all green), so the header counts and an + # event-only selector string gate the interactions → timeline → report + # path too. The exact counts pin the bundled fixture; extending the + # sample session means updating them here and in release.yml. + grep -q "\*\*Utterances:\*\* 10 · \*\*Events:\*\* 10" examples/sample-session/report.md + grep -q "data-testid=save-btn" examples/sample-session/report.md + + # install.sh is served live from main as the documented install path, so + # a merged syntax error breaks `curl | sh` for every new user with no + # gate having run. Parse it with both shells an operator realistically + # pipes it into. + - name: Installer syntax + run: | + set -euo pipefail + sh -n install.sh + bash -n install.sh # Secrets never land in history: full-history scan on every push/PR. Run as a # pinned, checksum-verified CLI (no PR-comment / SARIF GitHub-API integration diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ee7c7f..82df5b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -123,6 +123,9 @@ jobs: grep -q "## Findings" examples/sample-session/report.md grep -q "### Confirmed (1)" examples/sample-session/report.md grep -q "\*\*F-001\*\* bug" examples/sample-session/report.md + # The event half of the pipeline — see ci.yml's identical assertions. + grep -q "\*\*Utterances:\*\* 10 · \*\*Events:\*\* 10" examples/sample-session/report.md + grep -q "data-testid=save-btn" examples/sample-session/report.md # Build and publish ONLY after verify is green. Tarballs are cross-compiled from # the same pushed commit (github.sha) that verify just gated, so the shipped @@ -249,6 +252,32 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Assert the released binary reports the tag + # The version stamp rides on the -X flag naming internal/cli.Version, + # and the Go linker silently ignores -X for a symbol that does not + # exist — rename or move the variable and every tarball ships + # reporting "testimony dev" while the release stays green, the first + # observer being an end user. Download one tarball fresh from the + # published Release and require the binary's own version output to + # name the tag. Unlike the attestation step above this runs on private + # repos too, so it is a separate step with its own download. + run: | + set -euo pipefail + DL="$RUNNER_TEMP/version-check" + mkdir -p "$DL" + gh release download "$TAG" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "testimony_${TAG}_linux_amd64.tar.gz" \ + --dir "$DL" + tar -xzf "$DL/testimony_${TAG}_linux_amd64.tar.gz" -C "$DL" testimony + got="$("$DL/testimony" version)" + if [ "$got" != "testimony $TAG" ]; then + echo "released binary reports \"$got\", expected \"testimony $TAG\"" >&2 + exit 1 + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Assert the release job pushed nothing to the default branch # Close the no-branch-commit tripwire opened at job start. Runs last so it # covers every preceding step. !cancelled(): the tripwire must still run when diff --git a/CHANGELOG.md b/CHANGELOG.md index d3eb42e..86bfb91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,74 @@ All notable changes to Testimony are recorded here. The format follows uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a leading `v`. -Before v1.0.0, minor releases may make breaking changes; each one is -called out in a **Breaking** section. +Before v1.0.0, minor releases may make breaking changes; a change that can +break an existing invocation is called out in the entry that records it. ## [Unreleased] ### Fixed -Invocation contract (from the round-8 bug-hunt): +Evidence integrity: + +- **Behaviour:** `merge` refuses a transcript whose utterance ids repeat or + collide with the event ids it synthesises, and `analyze` refuses a timeline + carrying duplicate entry ids — inputs earlier versions accepted. Of two + utterances sharing an id, only the later one reached the quote validator, + so an honest verbatim quote of the first was rejected while a quote of the + second validated for a finding anchored at the first one's time. `report`, + whose join is positional, still renders such a timeline. +- **Behaviour:** `report` and `analyze` refuse a timeline entry whose `src` + is neither `speech` nor `event` — previously accepted at exit 0 — instead + of dropping it from the rendered timeline while counting its time into the + header duration and keeping its id citable as evidence. +- The audio offset sidecar is written atomically (temp file and rename): a + write failure part-way through used to leave a truncated sidecar behind, + blocking every later bare `transcribe` with the prior offset + unrecoverable from the session. + +Capture and diagnostics: + +- A recorder start-up failure is headlined as a likely permissions issue + only when the ffmpeg output carries a device-failure signature; the module + banner alone — printed on every successful open — used to route a full + disk or a missing encoder to the System Settings pane. +- `demo -addr :0` prints the actually-bound address instead of the + unopenable `http://localhost:0`; the same applies to `record -demo`. +- The demo page falls back to `fetch` when `sendBeacon` refuses to queue a + capture batch, which used to drop the batch silently; its seeded display + name now uses the Alice persona. +- Diagnostic stderr tails cut on rune boundaries rather than mid-character; + a record refused by the JSONL writer is named as a 1-based line of the + output file; `transcribe` defaults its log sink instead of panicking when + a caller leaves it unset. + +Checks and installer: + +- The pipeline smoke test asserts the event half of the pipeline (the + header counts and an event-only selector); every prior assertion was + satisfiable from the transcript and findings alone, so a regression that + dropped all events kept the gate green. +- The release workflow downloads a published tarball and requires the + binary's own version output to name the tag; the checks parse `install.sh` with + `sh -n` and `bash -n`. +- `install.sh` verifies the release binary runs and reports the pinned + release before installing it; an unrunnable binary (a wrong-platform + asset) used to replace any previously installed one and print a success + line at exit 0. Its trust-model comments now state precisely which + attestation failures refuse the install (a verification attempted and + rejected, or failed mid-way) and which fall back to the checksum with a + note (a gh that cannot attempt it). + +Documentation: + +- Reference and how-to corrections against the code: the report header + duration definition, `report`'s inputs, `record`'s ffmpeg prerequisite, + the loopback `Origin` rule, the command that changes a recorded verdict, + and the word-timestamp claim scoped to the utterance boundaries the join + operates on. The README states that voice and screen capture need macOS, + and its pipeline diagram joins up. + +Invocation contract: - Every usage error now exits 2, as the CLI reference documents: a missing required `-session`, a disallowed flag combination (`analyze -out` with @@ -32,9 +92,6 @@ Invocation contract (from the round-8 bug-hunt): workflow now gates the pin against the tag so it cannot go stale again. The installer also renders single-option prompts correctly and names the actual cause when a release download fails. - -Invocation contract (round 9): - - A stray positional argument is refused as a usage error; it used to be silently swallowed together with every flag after it, so the command ran with defaults at exit 0. @@ -43,7 +100,7 @@ Invocation contract (round 9): `transcribe -engine`, and a malformed capture `-addr` (which also no longer creates a session directory before refusing). -Capture integrity (round 9): +Capture integrity: - `POST /api/interactions` refuses with 400 any record `merge` would refuse — a non-object body, or a missing/implausible `t` or missing `kind` — instead @@ -57,7 +114,7 @@ Capture integrity (round 9): - A recorder that exits on its own mid-session still validates the artefacts the other recorders left and prints the next-command block. -Installer (round 9): +Installer: - An unauthenticated (or attestation-incapable) `gh` no longer refuses the install as a false provenance failure: it falls back to the verified diff --git a/README.md b/README.md index 6937395..f48062b 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ timestamped interaction stream, rendered as a report that shows what was said ne to what was done. ``` - voice ──► local Whisper ──► transcript.jsonl ─┐ - ├─► timeline.jsonl ─► report.md - clicks ──► rrweb / hooks ──► interactions.jsonl ┘ + voice ──► local Whisper ──► transcript.jsonl ─────┐ + ├─► timeline.jsonl ─► report.md + clicks ──► rrweb / hooks ──► interactions.jsonl ──┘ ``` Raw audio and video never leave your machine; only derived text is analysed. See @@ -56,9 +56,12 @@ open examples/sample-session/report.md ``` Then capture a real one: `testimony record -demo` starts a capture session — -recording your voice and clicks in one command — and prints every step. The -[getting-started tutorial](docs/tutorials/getting-started.md) walks the whole -path — record, think aloud, transcribe, merge, report — in about five minutes. The result interleaves speech with interface events: +recording your voice and clicks in one command — and prints every step. Voice +and screen capture need macOS; elsewhere, `record` skips those streams and +says so, and an external recording joins the session via `transcribe -audio`. +The [getting-started tutorial](docs/tutorials/getting-started.md) walks the +whole path — record, think aloud, transcribe, merge, report — in about five +minutes. The result interleaves speech with interface events: ``` **[00:22] P1:** “Hm. I clicked save and nothing happened. No message, no diff --git a/docs/explanation/how-alignment-works.md b/docs/explanation/how-alignment-works.md index bd5f2d9..9eee0ca 100644 --- a/docs/explanation/how-alignment-works.md +++ b/docs/explanation/how-alignment-works.md @@ -30,4 +30,4 @@ A window — rather than exact matching — is deliberate. Transcription timesta Plain speech recognition emits segment-level times, which can be off by several seconds — as coarse as the join window itself, which makes joining speech to an individual click unreliable. WhisperX adds forced alignment: it pins each recognised word to its own moment in the audio. -That precision is what the pipeline is built around. When a participant says "I expected *this* button to save immediately", the word "this" carries its own timestamp, close to the click on the thing it refers to. Word times make the utterance-to-event join trustworthy at its default width, allow it to be tightened when a single deictic word needs anchoring, and are the reason WhisperX is the preferred engine — whisper.cpp works, but its segment-level times lean harder on the window. +That precision is what the pipeline is built around. When a participant says "I expected *this* button to save immediately", the word "this" carries its own timestamp, close to the click on the thing it refers to. The join itself operates on whole utterances — each utterance's span, widened by the window — so what the alignment buys is trustworthy utterance boundaries: word-accurate segment times keep the join honest at its default width, and they are the reason WhisperX is the preferred engine — whisper.cpp works, but its segment-level times lean harder on the window. diff --git a/docs/how-to/analyse-a-session.md b/docs/how-to/analyse-a-session.md index e409120..301bf21 100644 --- a/docs/how-to/analyse-a-session.md +++ b/docs/how-to/analyse-a-session.md @@ -104,8 +104,10 @@ open sessions//report.md The Findings section lists findings under **Confirmed**, **Unverified**, **Duplicate**, and **Rejected**, each with its quote, anchor, and — where you -recorded one — the verdict and its date. Re-run `review` any time to change a -verdict; the latest one wins, and the history is kept. +recorded one — the verdict and its date. Change a verdict at any time with +`testimony review -session sessions/ -finding F-NNN -verdict ` +(the interactive walk offers only findings that are still unverified); the +latest one wins, and the history is kept. For the exact field rules, see the [session directory reference](../reference/session-directory.md#findingsjsonl); diff --git a/docs/how-to/instrument-your-own-app.md b/docs/how-to/instrument-your-own-app.md index 31e1f85..094c5dd 100644 --- a/docs/how-to/instrument-your-own-app.md +++ b/docs/how-to/instrument-your-own-app.md @@ -17,7 +17,7 @@ This creates a fresh session directory (with `manifest.json` anchoring the sessi Both endpoints accept POST only (anything else returns 405) and return `204 No Content` on success. `/api/interactions` caps the body at 4 MiB — the readable JSONL line limit, since one request becomes one line — and rejects with 400 anything the merge step could not read back: invalid JSON, a body that is not a JSON object, or an object missing the required `t` or `kind` from the table below; `/api/events` caps the batch body at 8 MiB and rejects anything that is not a JSON array with 400. A body over its cap gets 413, as does a batch element that would itself exceed the 4 MiB line limit. -To defend the evidence against cross-origin forgery (CSRF) and DNS-rebinding, the write endpoints require `Content-Type: application/json`, a loopback `Host`, and — when present — a same-origin `Origin`. Post from your app's own origin (see the proxy in step 5) and always set the JSON content type, as the snippet below does. Each accepted body is re-encoded to a single line, so one request is always exactly one JSONL record. +To defend the evidence against cross-origin forgery (CSRF) and DNS-rebinding, the write endpoints require `Content-Type: application/json`, a loopback `Host`, and — when present — a loopback `Origin` (any loopback host, whatever its port; anything else gets 403). Post from your app's own origin (see the proxy in step 5) and always set the JSON content type, as the snippet below does. Each accepted body is re-encoded to a single line, so one request is always exactly one JSONL record. ## 2. Add stable `data-testid` attributes @@ -54,11 +54,24 @@ A minimal capture script, following the same conventions as the demo app — pre if (el.id) return el.tagName.toLowerCase() + "#" + el.id; return el.tagName.toLowerCase(); } + function labelFor(el) { + if (!(el instanceof Element)) return ""; + var t = (el.closest("[data-testid]") || el).textContent || ""; + return t.trim().replace(/\s+/g, " ").slice(0, 40); + } function post(url, body) { try { - navigator.sendBeacon - ? navigator.sendBeacon(url, new Blob([JSON.stringify(body)], { type: "application/json" })) - : fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), keepalive: true }); + var json = JSON.stringify(body); + // sendBeacon returns false when the UA refuses to queue the body + // (e.g. the in-flight beacon quota): fall back to fetch so the + // records are not lost silently. + var queued = navigator.sendBeacon + ? navigator.sendBeacon(url, new Blob([json], { type: "application/json" })) + : false; + if (!queued) { + fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: json, keepalive: true }) + .catch(function () { /* an async refusal must not surface as an unhandled rejection */ }); + } } catch (e) { /* capture must never break the app */ } } function interaction(kind, el, extra) { @@ -66,7 +79,7 @@ A minimal capture script, following the same conventions as the demo app — pre t: Date.now(), kind: kind, selector: selectorFor(el), - text: ((el.closest("[data-testid]") || el).textContent || "").trim().replace(/\s+/g, " ").slice(0, 40), + text: labelFor(el), route: location.hash || location.pathname }; if (extra) for (var k in extra) payload[k] = extra[k]; diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 5b54925..221d2fd 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -97,7 +97,7 @@ testimony report -session DIR [-window 2.5] | `-session` | *(required)* | session directory | | `-window` | `2.5` | utterance-to-event join window, in seconds | -Behaviour: attaches each event to the first utterance whose span, widened by the window on both sides, contains it; events matched by no utterance appear as standalone lines. Writes `report.md` into the session directory and prints `wrote `. +Behaviour: reads `manifest.json` (required) and `timeline.jsonl`, plus `findings.jsonl` when present (the Findings section; without the file it is a short notice pointing at `analyze` and `review`). A timeline entry whose `src` is neither `speech` nor `event` is refused rather than silently omitted from the rendered record. Attaches each event to the first utterance whose span, widened by the window on both sides, contains it; events matched by no utterance appear as standalone lines. Writes `report.md` into the session directory and prints `wrote `. ## `testimony record` @@ -120,7 +120,7 @@ testimony record [-out sessions] [-app NAME] [-participant P1] [-commit HASH] | `-demo` | off | also serve the instrumented demo app into the same session directory | | `-addr` | `:8737` | demo server listen address (with `-demo`) | -Behaviour: creates a new session directory named after the current time (`YYYY-MM-DD_HHMMSS`) under the `-out` root and writes `manifest.json` (app, participant, tasks, commit, `t0_epoch_ms` set to now) through the same code path as `demo`. On macOS it captures the default microphone to `audio.wav` (16 kHz mono PCM, the canonical ASR input — no re-conversion needed downstream) and, with `-video`, the screen to `screen.mp4`. Audio-only is the default; `-video` opts in. With `-demo` it also serves the demo app so one command captures voice and clicks into the same directory. +Behaviour: creates a new session directory named after the current time (`YYYY-MM-DD_HHMMSS`) under the `-out` root and writes `manifest.json` (app, participant, tasks, commit, `t0_epoch_ms` set to now) through the same code path as `demo`. On macOS it captures the default microphone to `audio.wav` (16 kHz mono PCM, the canonical ASR input — no re-conversion needed downstream) and, with `-video`, the screen to `screen.mp4`; both recorders need ffmpeg on `PATH`. Audio-only is the default; `-video` opts in. With `-demo` it also serves the demo app so one command captures voice and clicks into the same directory. The command blocks until interrupted (`Ctrl+C`). On SIGINT/SIGTERM — and on SIGHUP, so closing the terminal window mid-session finalises exactly like Ctrl+C — it sends each recorder an interrupt so it finalises its container, waits up to five seconds, and hard-kills only on timeout. It then validates each recorder's artefact — `audio.wav`, and `screen.mp4` with `-video` — and prints the exact next commands with the real session directory: with a usable `audio.wav` in place it offers `transcribe` → `merge` → `report` without `-audio`, because the recording is already present. @@ -141,7 +141,7 @@ testimony analyze -session DIR -ingest FILE # validate the answer → find | `-out` | *(stdout)* | emit mode: write the request to `FILE` instead of stdout | | `-ingest` | *(off)* | ingest mode: validate the answer JSON at `FILE` (or `-` for stdin) into `findings.jsonl` | -`analyze` runs in exactly one mode: emit (no `-ingest`) or ingest (`-ingest`). Combining `-out` and `-ingest` is an error. Emit reads `manifest.json` and `timeline.jsonl`; ingest reads `timeline.jsonl` only. Both hint to run `merge` first when the timeline is missing. +`analyze` runs in exactly one mode: emit (no `-ingest`) or ingest (`-ingest`). Combining `-out` and `-ingest` is an error. Emit reads `manifest.json` and `timeline.jsonl`; ingest reads `timeline.jsonl` only. Both hint to run `merge` first when the timeline is missing, and both refuse a timeline whose entries carry a `src` other than `speech` or `event`, or a duplicated entry id — findings cite evidence by id, so a reused one cannot be resolved unambiguously (a `merge`-produced timeline never carries either defect: `merge` refuses a transcript whose utterance ids repeat or collide with the `ev-NNN` event ids it synthesises). Emit behaviour: writes a single self-contained prompt — the rubric version header (`testimony-analysis/v1`), the second-coder stance, two-pass instructions (segment coding, then session synthesis), the rubric body (five `type` definitions, the `1..4` severity scale, the evidence hard-constraints), the session context (app, participant, tasks), the timeline lines inline, and the required output shape with a worked example. Nothing in the session directory is mutated. The timeline is emitted whole (v1 does not chunk by task boundary; the manifest carries no task timestamps). With `-out FILE` the prompt goes to a file and the command prints `wrote `; otherwise it prints to stdout. diff --git a/docs/reference/session-directory.md b/docs/reference/session-directory.md index 879322e..df9d32d 100644 --- a/docs/reference/session-directory.md +++ b/docs/reference/session-directory.md @@ -65,7 +65,7 @@ One utterance per line. Times are session-relative seconds (audio time plus the | Field | Type | Required | Meaning | |---|---|---|---| -| `id` | string | yes | sequential utterance ID: `utt-001`, `utt-002`, … | +| `id` | string | yes | sequential utterance ID: `utt-001`, `utt-002`, …; unique within the file and outside the `ev-NNN` namespace `merge` synthesises for events (`merge` refuses a reused or colliding id, since findings cite evidence by id) | | `t0` | number | yes | utterance start, session-relative seconds | | `t1` | number | yes | utterance end, session-relative seconds | | `speaker` | string | no | speaker label; `"P1"` when the engine supplies no diarisation | @@ -96,7 +96,7 @@ The merged record — one entry per line, speech and interface events on the sha | Field | Type | Meaning | |---|---|---| | `t` | number | entry time, session-relative seconds | -| `src` | string | `"speech"` or `"event"` | +| `src` | string | `"speech"` or `"event"` (a closed set: `report` and `analyze` refuse any other value); entry ids must be unique for `analyze`, which resolves cited evidence by id | | `id` | string | `utt-NNN` (from the transcript) or `ev-NNN` (assigned at merge, in input order) | | `payload` | object | source-dependent, see below | @@ -120,7 +120,7 @@ Ingest validates every finding against the merged timeline and is the sole valid | Field | Type | Required | Meaning | |---|---|---|---| | `id` | string | yes | `F-NNN`, zero-padded (`^F-\d{3}$`); unique within the file | -| `t` | number | yes | finding time, session-relative seconds; within `[sessionStart, sessionEnd]` (`sessionStart` is `0` unless the timeline holds negative-time utterances from a recording predating `t0`; `sessionEnd` is the latest moment on the timeline — the end (`t1`) of the last utterance when speech closes the session, else the latest entry time) | +| `t` | number | yes | finding time, session-relative seconds; within `[sessionStart, sessionEnd]` (`sessionStart` is `0` unless the timeline holds negative-time utterances from a recording predating `t0`; `sessionEnd` is the latest moment on the timeline — the maximum over all entries, taking an utterance's end (`t1`) and an event's time) | | `type` | string | yes | one of `bug`, `friction`, `inconsistency`, `preference`, `idea` | | `severity` | integer | yes | usability-severity scale `1..4`: cosmetic, minor, major, blocker | | `mode` | string | no | `A` or `B`, default `A`; only Mode A is produced today | @@ -154,6 +154,6 @@ A finding's effective status starts `unverified`; verdict records apply in file Human-readable Markdown rendered from the timeline and findings: -- a header with session name, app, participant, duration (`MM:SS`, from the last entry), and utterance/event counts, plus the task list; +- a header with session name, app, participant, duration (`MM:SS`, the latest moment on the timeline — the maximum over all entries, taking an utterance's end `t1` and an event's time), and utterance/event counts, plus the task list; - a **Timeline** section: each utterance as `**[MM:SS] :** ""`, with the events joined to it (within the report's join window) as indented bullets `[MM:SS] "" value="…" ()`; events matched by no utterance appear as standalone bullets in time order; - a **Findings** section rendering `findings.jsonl` grouped by effective status (Confirmed, Unverified, Duplicate, Rejected), each group headed with a count and each finding line carrying its id, type, severity, clock, quote, anchor, and any verdict and date. When there is no `findings.jsonl` the section is a short notice pointing at `analyze` and `review`. diff --git a/install.sh b/install.sh index 85e5c5d..176941d 100644 --- a/install.sh +++ b/install.sh @@ -25,9 +25,13 @@ # CLI) is available it ALSO runs `gh attestation verify` against the release # workflow's SLSA build-provenance (authenticity — cryptographic proof the tarball # was built by REPPL/Testimony's own release.yml, the strong anchor). A gh that -# cannot attempt the verification — absent, unauthenticated, or too old to know -# attestations — means the install proceeds on the checksum alone, with a note -# saying so; only a verification gh performs and rejects refuses the install. +# cannot attempt the verification — absent, too old to know attestations, or +# unable to establish its authentication (which an unreachable API also causes +# at the auth check) — means the install proceeds on the checksum alone, with +# a note saying so. Once the verification is attempted, any failure refuses +# the install, fail closed: a rejection, and equally a verification that +# could not complete, because mid-verification failure cannot be told apart +# from an attacker blocking it. Re-run when connectivity returns. # No per-release hash is pinned in this script: the checksums are fetched from # the release itself and the attestation binds them to the workflow. # Everything installs into user-owned locations by default; sudo is never invoked. @@ -124,8 +128,8 @@ install_binary() { # the script: a trap that only cleans up returns control after the handler, # so Ctrl+C at a dependency prompt read was swallowed into the safe-default # answer and the run carried on. - trap 'rm -rf ${tmp:+"$tmp"} ${tmp2:+"$tmp2"} ${gnupg:+"$gnupg"} ${uvd:+"$uvd"}' EXIT - trap 'rm -rf ${tmp:+"$tmp"} ${tmp2:+"$tmp2"} ${gnupg:+"$gnupg"} ${uvd:+"$uvd"}; trap - EXIT; exit 130' INT TERM + trap 'rm -rf ${tmp:+"$tmp"} ${tmp2:+"$tmp2"} ${gnupg:+"$gnupg"} ${uvd:+"$uvd"} ${staged:+"$staged"}' EXIT + trap 'rm -rf ${tmp:+"$tmp"} ${tmp2:+"$tmp2"} ${gnupg:+"$gnupg"} ${uvd:+"$uvd"} ${staged:+"$staged"}; trap - EXIT; exit 130' INT TERM say "Downloading $tarball ..." # A bad --version (or a platform the release never published) surfaces from @@ -163,8 +167,14 @@ Refusing to install." # command or --signer-workflow fails the same way — treating those as # "attestation FAILED" made a freshly `brew install`ed gh strictly worse # than no gh at all, refusing a tarball whose checksum had just verified. - # Only a verification gh actually performed and rejected refuses the - # install, and gh's own output is shown instead of being swallowed. + # Once the verification is attempted, every failure refuses the install, + # fail closed — a rejected attestation, and equally a verification that + # fails mid-way (network, API outage): at that point an inconclusive + # answer cannot be told apart from one an attacker is blocking. The auth + # check above draws the attempt boundary: a gh whose authentication + # cannot be established — which an unreachable API also causes — is the + # checksum-fallback case, with a note naming it. gh's own output is shown + # instead of being swallowed. if have gh && ! gh auth status --hostname github.com >/dev/null 2>&1; then say "NOTE: 'gh' is installed but not authenticated — installed on the checksum alone." say " 'gh attestation verify' needs an authenticated gh; run 'gh auth login'" @@ -191,10 +201,26 @@ Refusing to install." say " https://cli.github.com — then re-run this installer." fi - mkdir -p "$INSTALL_DIR" tar -xzf "$tmp/$tarball" -C "$tmp" testimony - install -m 0755 "$tmp/testimony" "$INSTALL_DIR/testimony" - say "Installed: $INSTALL_DIR/testimony ($("$INSTALL_DIR/testimony" version))" + # Prove the binary runs and is the release it claims BEFORE it replaces + # anything: a failing command substitution inside say's argument does not + # trip `set -e`, so an unrunnable binary (a wrong-platform asset) + # previously replaced the installed one and printed "Installed: ... ()" + # at exit 0. The probe runs from a staged copy inside INSTALL_DIR — the + # download's temp directory may sit on a noexec mount (a hardened /tmp, + # a noexec TMPDIR), where executing "$tmp/testimony" fails for a + # perfectly good binary — and only the verified copy is renamed onto the + # final name, so a refusal leaves any previously installed binary + # untouched. Releases predating the version stamp (v0.1.0) report + # "testimony dev" and are refused here; every release since prints its + # own tag. + mkdir -p "$INSTALL_DIR" + staged="$INSTALL_DIR/.testimony.staged.$$" + install -m 0755 "$tmp/testimony" "$staged" + installed_version="$("$staged" version)" || { rm -f "$staged"; die "the release binary failed to run from $INSTALL_DIR (a wrong-platform asset, or a noexec mount?): $tarball"; } + [ "$installed_version" = "testimony $VERSION" ] || { rm -f "$staged"; die "the release binary reports \"$installed_version\", expected \"testimony $VERSION\"; refusing to install it"; } + mv -f "$staged" "$INSTALL_DIR/testimony" + say "Installed: $INSTALL_DIR/testimony ($installed_version)" case ":$PATH:" in *":$INSTALL_DIR:"*) : ;; diff --git a/internal/analyze/analyze_test.go b/internal/analyze/analyze_test.go index e69bd81..1ab599e 100644 --- a/internal/analyze/analyze_test.go +++ b/internal/analyze/analyze_test.go @@ -766,3 +766,49 @@ func TestEmitRequestSanitisesTimelineBidi(t *testing.T) { t.Fatalf("sanitised timeline text missing from the emitted request:\n%s", got) } } + +// TestIngestRefusesDuplicateTimelineIDs pins the duplicate-id refusal in +// loadTimeline. Before it, indexTimeline's id-keyed uttText map silently kept +// only the LAST utterance sharing an id, so against this timeline a quote +// spoken in the second utt-001 validated for a finding anchored at the first +// one's time — the exact quote-to-moment fabrication the verbatim gate exists +// to prevent — while an honest quote of the first utterance was rejected. +func TestIngestRefusesDuplicateTimelineIDs(t *testing.T) { + const dupTimeline = `{"t":1,"src":"speech","id":"utt-001","payload":{"speaker":"P1","t1":2,"text":"first thing"}} +{"t":19.2,"src":"event","id":"ev-003","payload":{"kind":"click","route":"#general","selector":"[data-testid=save-btn]","text":"Save"}} +{"t":40,"src":"speech","id":"utt-001","payload":{"speaker":"P1","t1":45,"text":"second thing entirely"}} +` + const answer = `{"findings":[ + {"id":"F-001","t":1.0,"type":"bug","severity":3,"quote":"second thing entirely", + "evidence":["utt-001","ev-003"]} +]}` + dir := writeSession(t, dupTimeline) + _, err := Ingest(dir, strings.NewReader(answer)) + if err == nil { + t.Fatal("Ingest accepted a timeline with duplicate utterance ids") + } + if !strings.Contains(err.Error(), "utt-001") { + t.Fatalf("error must name the duplicated id: %v", err) + } + if _, statErr := os.Stat(filepath.Join(dir, session.FindingsFile)); statErr == nil { + t.Fatal("findings.jsonl written despite the refusal") + } +} + +// TestIngestRefusesUnknownTimelineSrc pins the CheckSrc gate on analyze's +// read path: an entry with an unrecognised src previously still contributed +// its id to the citable evidence set and its time to the session end bound, +// although report would never render it. +func TestIngestRefusesUnknownTimelineSrc(t *testing.T) { + const badSrc = `{"t":22,"src":"speech","id":"utt-004","payload":{"speaker":"P1","t1":28,"text":"I clicked save and nothing happened"}} +{"t":100,"src":"Event","id":"ev-009","payload":{"kind":"click","selector":"[data-testid=save-btn]"}} +` + dir := writeSession(t, badSrc) + _, err := Ingest(dir, strings.NewReader(goodAnswer)) + if err == nil { + t.Fatal("Ingest accepted a timeline entry with unknown src") + } + if !strings.Contains(err.Error(), "unknown src") || !strings.Contains(err.Error(), "entry 2") { + t.Fatalf("error must name the unknown src and its entry: %v", err) + } +} diff --git a/internal/analyze/ingest.go b/internal/analyze/ingest.go index cbe55db..c2f8895 100644 --- a/internal/analyze/ingest.go +++ b/internal/analyze/ingest.go @@ -21,12 +21,44 @@ import ( const maxAnswerBytes = 16 << 20 // loadTimeline reads the merged timeline, hinting to run merge first when it is -// missing (matching report). +// missing (matching report). It refuses a timeline this package cannot validate +// findings against: an unknown src (timeline.CheckSrc) or a duplicate entry id. +// The duplicate check exists because analyze is the one id-keyed consumer left: +// indexTimeline keys uttText by id, so of two utterances sharing an id only the +// later survives the index — an honest verbatim quote of the first is rejected, +// while a quote of the second validates for a finding anchored at the first's +// time, durably pairing a quote with a moment it was never spoken at. Merge +// refuses a duplicated utterance id at the transcript boundary and an +// utterance id colliding with the event ids it synthesises (see +// timeline.checkedUtterances and the id scan in timeline.Merge), so this +// check bites on a hand-edited or exchanged timeline.jsonl, which reaches this +// reader without passing through merge — report deliberately stays positional +// and keeps rendering such a file. Ids are compared in their session.SafeText +// form, the form the index and the emitted request use, so two ids distinct +// only by stripped bytes count as duplicates too. Entries with an empty id are +// skipped: they cannot be cited, and calling two id-less lines "duplicates of +// \"\"" would misname the actual problem. Positions are 1-based entry +// ordinals, which match file lines only when the file has no blank lines +// (ReadJSONL skips those). func loadTimeline(dir string) ([]timeline.Entry, error) { entries, err := session.ReadJSONL[timeline.Entry](filepath.Join(dir, session.TimelineFile)) if err != nil { return nil, fmt.Errorf("read timeline (run `testimony merge` first?): %w", err) } + if err := timeline.CheckSrc(entries); err != nil { + return nil, err + } + seen := map[string]int{} + for i, e := range entries { + id := session.SafeText(e.ID) + if id == "" { + continue + } + if prev, dup := seen[id]; dup { + return nil, fmt.Errorf("timeline id %q appears at entries %d and %d: ids must be unique for evidence to cite them unambiguously", id, prev, i+1) + } + seen[id] = i + 1 + } return entries, nil } diff --git a/internal/demo/assets/index.html b/internal/demo/assets/index.html index 46c2fb8..d32ba6d 100644 --- a/internal/demo/assets/index.html +++ b/internal/demo/assets/index.html @@ -81,7 +81,7 @@

General

Shown on your reports
- +
Sent on Mondays
@@ -146,14 +146,26 @@

About

return s; } function labelFor(el) { + if (!(el instanceof Element)) return ""; var t = (el.closest("[data-testid]") || el).textContent || ""; return t.trim().replace(/\s+/g, " ").slice(0, 40); } function post(url, body) { try { - navigator.sendBeacon - ? navigator.sendBeacon(url, new Blob([JSON.stringify(body)], { type: "application/json" })) - : fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), keepalive: true }); + var json = JSON.stringify(body); + // sendBeacon returns false (it does not throw) when the UA refuses to + // queue the body — most commonly the in-flight beacon quota, which a + // large rrweb batch can hit. By then the batch is already out of the + // buffer, and nothing reaches the server, so its stderr refusal log + // cannot see the drop either: fall back to fetch instead of losing the + // records silently. + var queued = navigator.sendBeacon + ? navigator.sendBeacon(url, new Blob([json], { type: "application/json" })) + : false; + if (!queued) { + fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: json, keepalive: true }) + .catch(function () { /* an async refusal must not surface as an unhandled rejection */ }); + } } catch (e) { /* capture must never break the app under test */ } } function interaction(kind, el, extra) { diff --git a/internal/demo/demo.go b/internal/demo/demo.go index da18362..baf4dae 100644 --- a/internal/demo/demo.go +++ b/internal/demo/demo.go @@ -98,7 +98,7 @@ func Run(addr, outRoot string) error { testimony merge -session %s testimony report -session %s -`, dir, DisplayURL(addr), dir, dir, dir) +`, dir, DisplayURL(displayAddr(srv, addr)), dir, dir, dir) // Block until interrupted, then shut the server down cleanly. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) @@ -195,6 +195,17 @@ func Serve(addr, dir string) (*http.Server, error) { ReadTimeout: readTimeout, IdleTimeout: idleTimeout, } + // Surface the actually-bound address on the returned server, keeping the + // operator's requested host but taking the port from the listener: ":0" + // binds an ephemeral port only net.Listen knows, and printing the requested + // addr rendered the unopenable http://localhost:0 while the real port was + // discoverable only outside the tool. For any explicit port the two agree + // and srv.Addr equals the requested addr. + if _, boundPort, perr := net.SplitHostPort(ln.Addr().String()); perr == nil { + if reqHost, _, herr := net.SplitHostPort(addr); herr == nil { + srv.Addr = net.JoinHostPort(reqHost, boundPort) + } + } go srv.Serve(ln) return srv, nil } @@ -356,11 +367,12 @@ func tooLongForJSONL(line []byte) bool { // allowWrite guards the capture write endpoints against cross-origin forgery // (CSRF) and DNS-rebinding of the loopback server. It requires a loopback Host -// (a rebinding page still sends the attacker hostname), a same-origin Origin -// when present, and a JSON Content-Type — a non-CORS-safelisted type that forces -// a preflight the server never answers permissively, so a cross-origin no-cors -// "simple request" POST cannot reach the write. It writes the error response and -// returns false when the request must be refused. +// (a rebinding page still sends the attacker hostname), a loopback Origin when +// present (any loopback host, whatever its port), and a JSON Content-Type — a +// non-CORS-safelisted type that forces a preflight the server never answers +// permissively, so a cross-origin no-cors "simple request" POST cannot reach +// the write. It writes the error response and returns false when the request +// must be refused. func allowWrite(w http.ResponseWriter, r *http.Request) bool { if !loopbackHost(r.Host) { refuseWrite(w, r, fmt.Sprintf("unexpected Host %q", r.Host), "unexpected Host", http.StatusForbidden) @@ -414,6 +426,17 @@ func isJSONContentType(ct string) bool { return strings.EqualFold(strings.TrimSpace(ct), "application/json") } +// displayAddr returns the address to print for a running capture server: the +// bound address Serve recorded on the server when available (which differs +// from the request only when an ephemeral ":0" port was asked for), else the +// requested one. +func displayAddr(srv *http.Server, requested string) string { + if srv != nil && srv.Addr != "" { + return srv.Addr + } + return requested +} + // DisplayURL renders the human-facing URL an operator opens for a capture // server bound to addr. It shows "localhost" only for the host-less default // (":8737" -> http://localhost:8737); when an operator passes an explicit host diff --git a/internal/demo/demo_test.go b/internal/demo/demo_test.go index 3b631a3..25a3bab 100644 --- a/internal/demo/demo_test.go +++ b/internal/demo/demo_test.go @@ -3,6 +3,7 @@ package demo import ( "errors" "io" + "net" "net/http" "net/http/httptest" "os" @@ -567,3 +568,29 @@ func TestServeRefusesSymlinkStream(t *testing.T) { t.Fatalf("victim file was modified through the symlink: %q", b) } } + +// TestServeSurfacesBoundAddr pins the ephemeral-port fix: with ":0" the +// requested address names no real port, so Serve records the bound one on the +// returned server for callers to print — previously demo -addr :0 printed the +// unopenable http://localhost:0 while the real port was knowable only outside +// the tool. An explicit port must round-trip unchanged. +func TestServeSurfacesBoundAddr(t *testing.T) { + srv, err := Serve(":0", manifestDir(t)) + if err != nil { + t.Fatalf("Serve: %v", err) + } + defer Shutdown(srv) + host, port, err := net.SplitHostPort(srv.Addr) + if err != nil { + t.Fatalf("srv.Addr %q: %v", srv.Addr, err) + } + if host != "" { + t.Fatalf("requested host must be kept (empty for \":0\"), got %q", host) + } + if port == "0" || port == "" { + t.Fatalf("bound port not surfaced: srv.Addr = %q", srv.Addr) + } + if got := DisplayURL(displayAddr(srv, ":0")); strings.Contains(got, ":0") || !strings.HasPrefix(got, "http://localhost:") { + t.Fatalf("display URL still unopenable: %q", got) + } +} diff --git a/internal/record/proc.go b/internal/record/proc.go index c90fa60..f43d404 100644 --- a/internal/record/proc.go +++ b/internal/record/proc.go @@ -6,6 +6,7 @@ import ( "sync" "syscall" "time" + "unicode/utf8" ) // proc is the minimal process surface the lifecycle drives, so the SIGINT → @@ -100,7 +101,14 @@ func (l *lockedBuffer) tail() string { const max = 1200 b := l.buf if len(b) > max { - return "…" + string(b[len(b)-max:]) + // Advance the cut to the next rune boundary so the tail never opens + // mid-rune: a split UTF-8 sequence (a non-ASCII device name in ffmpeg's + // listing straddling the cut) renders as replacement garbage. + i := len(b) - max + for i < len(b) && !utf8.RuneStart(b[i]) { + i++ + } + return "…" + string(b[i:]) } if l.dropped { return "…" + string(b) diff --git a/internal/record/record.go b/internal/record/record.go index 1e987d8..eb6eb40 100644 --- a/internal/record/record.go +++ b/internal/record/record.go @@ -158,7 +158,14 @@ func Run(opts Options) error { } } - printStatus(opts.Log, recorders, opts.Demo, opts.Addr) + // Print the address the demo server actually bound (Serve records it on + // the returned server), not the requested one: with -addr :0 the requested + // form renders the unopenable http://localhost:0. + statusAddr := opts.Addr + if srv != nil && srv.Addr != "" { + statusAddr = srv.Addr + } + printStatus(opts.Log, recorders, opts.Demo, statusAddr) // Nothing is running to wait on (degraded platform, no demo): the session // dir and manifest are written; print next steps and exit cleanly. diff --git a/internal/record/record_test.go b/internal/record/record_test.go index ad0a8b5..b69e7aa 100644 --- a/internal/record/record_test.go +++ b/internal/record/record_test.go @@ -16,6 +16,7 @@ import ( "syscall" "testing" "time" + "unicode/utf8" "github.com/REPPL/Testimony/internal/demo" "github.com/REPPL/Testimony/internal/session" @@ -1170,3 +1171,58 @@ func TestAnyExitReportsDeadChild(t *testing.T) { // Clean up the still-live child so no goroutine lingers. stopChild(live, time.Second) } + +// TestClassifyRecorderExitBannerIsNotAPermissionsSignature pins the signature +// list to device-open failures only. ffmpeg prints its avfoundation input +// banner on every SUCCESSFUL open, so with the bare module name in the list a +// start-up failure whose real cause followed the banner — a full disk, a +// missing encoder, an unwritable session directory — was headlined "most +// likely a permissions issue" with a System Settings pointer, misdirecting +// the operator to grant a permission they already hold. +func TestClassifyRecorderExitBannerIsNotAPermissionsSignature(t *testing.T) { + tails := map[string]string{ + "full disk": "Input #0, avfoundation, from ':default':\n Duration: N/A, start: 761698.132517, bitrate: 1536 kb/s\nav_interleaved_write_frame(): No space left on device", + "missing encoder": "Input #0, avfoundation, from '1:none':\nUnknown encoder 'libx264'", + "unwritable dir": "Input #0, avfoundation, from ':default':\nsessions/2026-07-29/audio.wav: Permission denied", + } + for name, tail := range tails { + if looksLikeAVFailure(tail) { + t.Fatalf("%s: the avfoundation input banner alone must not read as a device-open failure: %q", name, tail) + } + msg := classifyRecorderExit(streamMicrophone, errors.New("exit status 1"), tail, true) + if strings.Contains(msg, "permissions") || strings.Contains(msg, "Privacy & Security") { + t.Fatalf("%s: must not claim permissions: %q", name, msg) + } + } + // The genuine TCC denial lines keep matching. + for _, tail := range []string{ + "[AVFoundation indev @ 0x14f604580] Failed to create AVCaptureDeviceInput: -11852", + "[AVFoundation indev @ 0x0] Failed to open device.", + "[avfoundation @ 0x0] audio device not authorized to capture (status 0)", + } { + if !looksLikeAVFailure(tail) { + t.Fatalf("a real device-open failure must keep matching: %q", tail) + } + } +} + +// TestTailsCutOnRuneBoundaries pins the rune-aligned truncation of the two +// diagnostic tails in this package: a byte-offset cut could open the tail +// mid-rune (a non-ASCII device name straddling it), rendering replacement +// garbage at the head of an operator-facing message. +func TestTailsCutOnRuneBoundaries(t *testing.T) { + // 2-byte runes with one trailing ASCII byte put every rune start on an even + // offset while the cut lands on an odd one — mid-rune before the fix. + long := strings.Repeat("é", 700) + "a" + + if got := outputTail([]byte(long)); !utf8.ValidString(got) { + t.Fatalf("outputTail split a rune: %q...", got[:12]) + } + var lb lockedBuffer + if _, err := lb.Write([]byte(long)); err != nil { + t.Fatalf("Write: %v", err) + } + if got := lb.tail(); !utf8.ValidString(got) { + t.Fatalf("lockedBuffer.tail split a rune: %q...", got[:12]) + } +} diff --git a/internal/record/recorders.go b/internal/record/recorders.go index 8cbe9e1..ae3286e 100644 --- a/internal/record/recorders.go +++ b/internal/record/recorders.go @@ -9,6 +9,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" ) // deviceListTimeout bounds the avfoundation device enumeration. It is a var, not a @@ -261,7 +262,14 @@ func outputTail(out []byte) string { const max = 400 s := strings.TrimSpace(string(out)) if len(s) > max { - s = "..." + s[len(s)-max:] + // Advance the cut to the next rune boundary: a byte-offset cut can open + // the tail mid-rune (a non-ASCII device or file name straddling it), and + // a split UTF-8 sequence renders as replacement garbage in the message. + i := len(s) - max + for i < len(s) && !utf8.RuneStart(s[i]) { + i++ + } + s = "..." + s[i:] } return s } diff --git a/internal/record/tcc.go b/internal/record/tcc.go index 7c5788b..f671bfa 100644 --- a/internal/record/tcc.go +++ b/internal/record/tcc.go @@ -9,12 +9,29 @@ import ( // cannot be opened. Their presence makes a permissions denial the most likely // cause — but because a busy or absent device fails the same way, the message // is phrased as "most likely", never asserted. +// +// Each signature must indicate a device failure on its own. The list once +// held the bare module name "avfoundation" and the bare "failed to" — but +// ffmpeg prints "Input #0, avfoundation, from ':default':" on every SUCCESSFUL +// input open (no -hide_banner is passed), so the module name sits in the +// stderr tail of essentially every start-up failure, whatever its cause. That +// made looksLikeAVFailure true unconditionally: a full disk, a missing +// encoder, or an unwritable session directory was headlined "most likely a +// permissions issue" with a System Settings pointer, and the non-permissions +// branch of classifyRecorderExit was unreachable for any failure past the +// input dump. The real macOS denial lines stay matched: TCC prints +// "[AVFoundation indev @ …] Failed to create AVCaptureDeviceInput" / +// "Failed to open device" and "not authorized to capture video". The list is +// a heuristic on both sides, deliberately: "input/output error" appears in +// real denial tails but is also the generic EIO string, so a genuine disk I/O +// failure at start-up still earns the permissions headline (phrased "most +// likely", with the raw tail appended); and dropping the bare "abort" trades +// away detection of a denial whose only trace is a crash line. var avSignatures = []string{ - "avfoundation", "input/output error", "not authorized", - "failed to", - "abort", + "failed to open", + "failed to create", } // permissionPane maps a recorder stream to the exact System Settings pane the diff --git a/internal/report/report.go b/internal/report/report.go index c9b2f73..7137ff1 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -36,6 +36,16 @@ func Render(dir string, window float64) (string, error) { if err != nil { return "", fmt.Errorf("read timeline (run `testimony merge` first?): %w", err) } + // Refuse an entry this renderer cannot place. The src switch below buckets + // into speech and events with no default, so an unknown src (reachable only + // from a hand-edited or exchanged timeline) previously vanished from the + // rendered timeline while end() still counted its time into the Duration + // header — the report asserted a session span its own timeline never + // reached, and the entry's id stayed citable by findings. Checked before + // the sort so the named entry ordinal reflects the file's record order. + if err := timeline.CheckSrc(entries); err != nil { + return "", err + } // Order the record by time rather than trusting the file's line order. The // timeline this reads is not necessarily the one merge wrote: a hand-edited or // exchanged timeline.jsonl reaches Render directly — the same assumption the diff --git a/internal/report/report_test.go b/internal/report/report_test.go index 3a22548..53f9d74 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -570,3 +570,30 @@ func TestRenderSortsByTime(t *testing.T) { prev = at } } + +// TestRenderRefusesUnknownSrc pins the CheckSrc gate on report's read path. +// Before it, an entry whose src was neither "speech" nor "event" (reachable +// only from a hand-edited or exchanged timeline.jsonl) fell out of both render +// buckets and vanished from the rendered timeline, while end() still counted +// its time — the report claimed a Duration its own timeline never reached, at +// exit 0, silently omitting the entry from the human evidence artefact. +func TestRenderRefusesUnknownSrc(t *testing.T) { + dir := t.TempDir() + if err := session.SaveManifest(dir, session.Manifest{Session: "fixture"}); err != nil { + t.Fatalf("SaveManifest: %v", err) + } + const tl = `{"t":10,"src":"speech","id":"utt-001","payload":{"t1":12,"speaker":"P1","text":"hello"}} +{"t":11,"src":"event","id":"ev-002","payload":{"kind":"scroll"}} +{"t":100,"src":"Event","id":"ev-009","payload":{"kind":"click","selector":"[data-testid=save-btn]"}} +` + if err := os.WriteFile(filepath.Join(dir, session.TimelineFile), []byte(tl), 0o644); err != nil { + t.Fatalf("write timeline: %v", err) + } + _, err := Render(dir, 2.5) + if err == nil { + t.Fatal("Render silently accepted a timeline entry with unknown src") + } + if !strings.Contains(err.Error(), "unknown src") || !strings.Contains(err.Error(), "entry 3") { + t.Fatalf("error must name the unknown src and its entry: %v", err) + } +} diff --git a/internal/session/session.go b/internal/session/session.go index 4b2b01a..922f92a 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -16,10 +16,12 @@ package session import ( "bufio" "bytes" + "crypto/rand" "encoding/json" "errors" "fmt" "io" + "io/fs" "os" "path/filepath" "strings" @@ -252,6 +254,89 @@ func WriteFileNoFollow(path string, data []byte, perm os.FileMode) error { return f.Close() } +// WriteFileAtomicNoFollow replaces path with data all-or-nothing: the bytes +// are written to a same-directory temp file and renamed into place, so a +// failure at any point leaves whatever was at path untouched. WriteFileNoFollow +// cannot promise that — its O_TRUNC open destroys the prior bytes before the +// first write, so a write that then fails leaves a truncated file AND an error, +// which for the offset sidecar meant the one durable record of an external +// audio's clock offset was gone with nothing to roll back to. The no-follow +// guarantee is kept by refusing up front when path names a symlink (rename +// would otherwise replace the planted link rather than follow it, but refusal +// matches WriteFileNoFollow so a hostile session directory cannot even retarget +// the name); the temp file is created fresh in the same directory, so the +// rename never crosses a filesystem. +// +// Permission semantics: a NEW file is created with perm filtered by the +// process umask, exactly as WriteFileNoFollow's open(2) would create it; an +// EXISTING regular file's own mode is preserved exactly across the +// replacement (applied with fchmod on the temp file, which the umask does not +// filter), so an operator-tightened sidecar stays tightened and a +// deliberately widened one is not silently narrowed. Two deliberate +// differences from a truncating open, both inherent to rename-into-place: a +// read-only target is replaced (rename consults the directory's permissions, +// not the file's), and a crash between create and rename can leave a +// dot-prefixed "..tmp-*" file behind — nothing reads it, and the next +// successful write does not depend on it. Temp names carry a random suffix so +// an attacker who can write the directory cannot pre-plant the whole name +// space and deterministically block the write (O_EXCL already stops +// write-through). +func WriteFileAtomicNoFollow(path string, data []byte, perm os.FileMode) error { + priorPerm, havePrior := os.FileMode(0), false + if fi, err := os.Lstat(path); err == nil { + if fi.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("refusing to write %s: it is a symlink", path) + } + if fi.Mode().IsRegular() { + priorPerm, havePrior = fi.Mode().Perm(), true + } + } + dir, base := filepath.Dir(path), filepath.Base(path) + var f *os.File + var tmp string + for i := 0; ; i++ { + var rnd [4]byte + if _, err := rand.Read(rnd[:]); err != nil { + return err + } + tmp = filepath.Join(dir, fmt.Sprintf(".%s.tmp-%x", base, rnd)) + var err error + f, err = os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, perm) + if err == nil { + break + } + if !errors.Is(err, fs.ErrExist) || i >= 100 { + return err + } + } + cleanup := func() { + f.Close() + os.Remove(tmp) + } + if havePrior { + if err := f.Chmod(priorPerm); err != nil { + cleanup() + return err + } + } + if _, err := f.Write(data); err != nil { + cleanup() + return err + } + // Close before rename, and surface the Close error: a filesystem that + // defers write-back errors to close would otherwise rename a corrupt temp + // file into place (see WriteJSONL's identical stance). + if err := f.Close(); err != nil { + os.Remove(tmp) + return err + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return err + } + return nil +} + // SafeText neutralises untrusted text before it is written into a human-facing // artefact (report.md) or a terminal line (review). It strips C0/C1 control // bytes — including the newline and carriage return that could forge report @@ -369,7 +454,11 @@ func WriteJSONL[T any](path string, values []T) error { // tooLongForJSONL draws the boundary on the same side, so the capture and // artefact writers accept exactly the same set of records. if buf.Len() > MaxJSONLLine { - return fmt.Errorf("%s: record %d encodes to %d bytes, over the %d-byte JSONL line limit", path, i, buf.Len(), MaxJSONLLine) + // 1-based, and named as a line of the file being written: the caller's + // slice is already merged and time-sorted, so a 0-based slice index + // pointed the operator at nothing they could count to — "record 0" is + // no line of any file, and no line of the source transcript either. + return fmt.Errorf("%s: line %d of the output encodes to %d bytes, over the %d-byte JSONL line limit", path, i+1, buf.Len(), MaxJSONLLine) } } diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 41f65a6..89d8e94 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -276,8 +276,11 @@ func TestWriteJSONLRefusesOverlongRecord(t *testing.T) { if err == nil { t.Fatal("WriteJSONL persisted a record over MaxJSONLLine; want refusal") } - if !strings.Contains(err.Error(), "record 0") { - t.Errorf("error does not name the offending index: %v", err) + // The offending record is named as a 1-based line of the output file — the + // only position an operator can count to. The earlier "record 0" was a + // 0-based index into the caller's already-sorted slice, a line of no file. + if !strings.Contains(err.Error(), "line 1 of the output") { + t.Errorf("error does not name the offending output line: %v", err) } // The earlier artefact is intact: nothing was written, not even a truncation. @@ -510,3 +513,83 @@ func TestLoadManifestAcceptsOrdinary(t *testing.T) { t.Fatalf("want session s, got %q", m.Session) } } + +// TestWriteFileAtomicNoFollowRefusesSymlink keeps the atomic write under the +// same planted-symlink refusal as WriteFileNoFollow: rename would replace the +// link rather than follow it, but a hostile session directory must not be able +// to retarget an artefact name at all. +func TestWriteFileAtomicNoFollowRefusesSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + if err := os.WriteFile(target, []byte("original\n"), 0o644); err != nil { + t.Fatalf("write target: %v", err) + } + link := filepath.Join(dir, "link") + if err := os.Symlink(target, link); err != nil { + t.Fatalf("symlink: %v", err) + } + if err := WriteFileAtomicNoFollow(link, []byte("clobber\n"), 0o644); err == nil { + t.Fatal("WriteFileAtomicNoFollow wrote through a symlink; want refusal") + } + got, err := os.ReadFile(target) + if err != nil || string(got) != "original\n" { + t.Fatalf("symlink target disturbed: %q, %v", got, err) + } + if _, err := os.Lstat(link); err != nil { + t.Fatalf("planted symlink removed: %v", err) + } +} + +// TestWriteFileAtomicNoFollowReplaces covers the happy path: content replaced, +// no temp file left behind. +func TestWriteFileAtomicNoFollowReplaces(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "f") + if err := os.WriteFile(path, []byte("old\n"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + if err := WriteFileAtomicNoFollow(path, []byte("new\n"), 0o644); err != nil { + t.Fatalf("WriteFileAtomicNoFollow: %v", err) + } + got, err := os.ReadFile(path) + if err != nil || string(got) != "new\n" { + t.Fatalf("content: %q, %v", got, err) + } + names, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("readdir: %v", err) + } + if len(names) != 1 { + t.Fatalf("temp file left behind: %v", names) + } +} + +// TestWriteFileAtomicNoFollowPreservesExistingMode pins the permission +// semantics the atomic write shares with a plain truncating open: replacing an +// existing regular file keeps that file's own mode, so an operator-tightened +// artefact (a 0600 sidecar) does not silently widen to the caller's default +// perm on rewrite. +func TestWriteFileAtomicNoFollowPreservesExistingMode(t *testing.T) { + // Both a tightened and a widened mode round-trip exactly: preservation is + // applied with fchmod on the temp file, which the umask does not filter. + for _, mode := range []os.FileMode{0o600, 0o664} { + dir := t.TempDir() + path := filepath.Join(dir, "f") + if err := os.WriteFile(path, []byte("old\n"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + if err := os.Chmod(path, mode); err != nil { + t.Fatalf("chmod: %v", err) + } + if err := WriteFileAtomicNoFollow(path, []byte("new\n"), 0o644); err != nil { + t.Fatalf("WriteFileAtomicNoFollow: %v", err) + } + fi, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if got := fi.Mode().Perm(); got != mode { + t.Fatalf("existing file's mode not preserved: got %o, want %o", got, mode) + } + } +} diff --git a/internal/timeline/timeline.go b/internal/timeline/timeline.go index 7831e81..5d6fb9c 100644 --- a/internal/timeline/timeline.go +++ b/internal/timeline/timeline.go @@ -132,6 +132,31 @@ func BuildEntries(t0EpochMS int64, utts []Utterance, ints []Interaction) []Entry return entries } +// CheckSrc refuses a timeline whose entries carry a src outside the +// documented "speech" | "event" set. Merge only ever writes those two, so +// this bites solely on a hand-edited or exchanged timeline.jsonl — where an +// unknown src previously vanished without a word: report bucketed entries by +// src with no default case, so the entry was absent from the rendered +// timeline while end() still counted its time into the Duration header, and +// analyze still indexed its id as citable evidence. Silently omitting an +// entry from the human evidence artefact is the outcome the report package's +// own +Inf-sentinel fix rules out, so an unrenderable entry refuses loudly, +// naming its 1-based entry ordinal — the position among the file's records in +// order, which is the file's line number only when the file carries no blank +// lines (ReadJSONL skips those). The src value is routed through +// session.SafeText because it is attacker-authorable and the message reaches +// a terminal. +func CheckSrc(entries []Entry) error { + for i, e := range entries { + switch e.Src { + case "speech", "event": + default: + return fmt.Errorf("timeline entry %d: unknown src %q (want \"speech\" or \"event\")", i+1, session.SafeText(e.Src)) + } + } + return nil +} + // SpeechEnd returns the end time of a speech entry (its t1), falling back to // its start time. func SpeechEnd(e Entry) float64 { @@ -293,10 +318,27 @@ func CheckInteraction(line []byte, t0EpochMS int64) error { // window would be the disproportionate answer. func checkedUtterances(path string, raw []rawUtterance) ([]Utterance, error) { out := make([]Utterance, 0, len(raw)) + // Refuse a duplicated utterance id at the boundary where the operator can + // repair it. BuildEntries copies each utterance's id verbatim, so a + // transcript reusing one would flow into timeline.jsonl and be refused only + // by analyze — naming a generated file the next merge would recreate, + // instead of the transcript that carries the defect. Ids are compared in + // their session.SafeText form, the form analyze indexes and the emitted + // request shows, so two ids distinct only by stripped bytes count as the + // collision they later become; empty ids are skipped (they cannot be cited, + // and calling two id-less utterances duplicates of "" would misname the + // problem). + seen := map[string]int{} for i, r := range raw { if r.T0 == nil { return nil, fmt.Errorf("%s: utterance %d is missing t0; cannot place it on the session clock", path, i+1) } + if id := session.SafeText(r.ID); id != "" { + if prev, dup := seen[id]; dup { + return nil, fmt.Errorf("%s: utterance %d reuses id %q (first used by utterance %d; ids are compared with invisible characters stripped); ids must be unique for findings to cite them unambiguously", path, i+1, id, prev) + } + seen[id] = i + 1 + } // A present t1 is accepted only when it does not precede t0. An explicit // t1 < t0 is the same inverted-window hazard the nil case is defaulted away // from: EventsNear would join over [t0-window, t1+window] with hi < lo and @@ -383,6 +425,25 @@ func Merge(dir string) (speech, events int, err error) { } entries := BuildEntries(t0, utts, ints) + // The last id gate: checkedUtterances refuses an utterance reusing another + // utterance's id, but a transcript utterance named into the ev-NNN + // namespace collides with the event ids BuildEntries has just synthesised + // — producing a duplicate-id timeline from merge itself, which analyze + // then refuses while the repairable defect lives in transcript.jsonl. + // Scanning the built entries closes that cross-namespace case (and any + // future id source), so a merge-produced timeline never carries a + // duplicate id — the guarantee the CLI reference states. + seen := map[string]bool{} + for _, e := range entries { + id := session.SafeText(e.ID) + if id == "" { + continue + } + if seen[id] { + return 0, 0, fmt.Errorf("%s: utterance id %q collides with a synthesized event id; utterance ids must stay outside the ev-NNN namespace", uttsPath, id) + } + seen[id] = true + } if err := session.WriteJSONL(filepath.Join(dir, session.TimelineFile), entries); err != nil { return 0, 0, fmt.Errorf("write timeline: %w", err) } diff --git a/internal/timeline/timeline_test.go b/internal/timeline/timeline_test.go index 884685a..5dd1ba4 100644 --- a/internal/timeline/timeline_test.go +++ b/internal/timeline/timeline_test.go @@ -514,3 +514,64 @@ func TestMergeClampsUtteranceInvertedSpan(t *testing.T) { t.Fatalf("want the event to attach to the utterance, got %v", near) } } + +// TestMergeRejectsDuplicateUtteranceID pins the transcript-boundary refusal of +// reused utterance ids. BuildEntries copies each utterance's id verbatim, so a +// transcript reusing one previously merged at exit 0 and produced a +// duplicate-id timeline.jsonl — which analyze then refuses while naming the +// generated file the next merge would recreate, not the transcript that +// carries the defect and that the operator can actually repair. +func TestMergeRejectsDuplicateUtteranceID(t *testing.T) { + dir := t.TempDir() + if err := session.SaveManifest(dir, session.Manifest{Session: "s", T0EpochMS: t0}); err != nil { + t.Fatalf("SaveManifest: %v", err) + } + lines := "" + + `{"id":"utt-001","t0":1.0,"t1":2.0,"speaker":"P1","text":"first"}` + "\n" + + `{"id":"utt-001","t0":40.0,"t1":45.0,"speaker":"P1","text":"second"}` + "\n" + if err := os.WriteFile(filepath.Join(dir, session.TranscriptFile), []byte(lines), 0o644); err != nil { + t.Fatalf("write transcript: %v", err) + } + + _, _, err := Merge(dir) + if err == nil { + t.Fatalf("expected a duplicate-id error, got nil") + } + if !strings.Contains(err.Error(), "utterance 2") || !strings.Contains(err.Error(), `"utt-001"`) { + t.Fatalf("error should name the reusing utterance and the id, got %v", err) + } + if _, statErr := os.Stat(filepath.Join(dir, session.TimelineFile)); statErr == nil { + t.Fatalf("timeline.jsonl was written despite the duplicate id") + } +} + +// TestMergeRejectsUtteranceIDInEventNamespace pins the cross-namespace half of +// the duplicate-id gate: checkedUtterances only compares utterances with each +// other, so a transcript utterance named into the ev-NNN namespace previously +// collided with the event ids BuildEntries synthesises — merge exited 0 with a +// duplicate-id timeline that analyze then refused, blaming the generated file. +func TestMergeRejectsUtteranceIDInEventNamespace(t *testing.T) { + dir := t.TempDir() + if err := session.SaveManifest(dir, session.Manifest{Session: "s", T0EpochMS: t0}); err != nil { + t.Fatalf("SaveManifest: %v", err) + } + utt := `{"id":"ev-001","t0":1.0,"t1":2.0,"speaker":"P1","text":"first"}` + "\n" + if err := os.WriteFile(filepath.Join(dir, session.TranscriptFile), []byte(utt), 0o644); err != nil { + t.Fatalf("write transcript: %v", err) + } + ev := `{"t":` + strconv.FormatInt(t0+1500, 10) + `,"kind":"click"}` + "\n" + if err := os.WriteFile(filepath.Join(dir, session.InteractionsFile), []byte(ev), 0o644); err != nil { + t.Fatalf("write interactions: %v", err) + } + + _, _, err := Merge(dir) + if err == nil { + t.Fatalf("expected a namespace-collision error, got nil") + } + if !strings.Contains(err.Error(), `"ev-001"`) || !strings.Contains(err.Error(), session.TranscriptFile) { + t.Fatalf("error should name the colliding id and the transcript, got %v", err) + } + if _, statErr := os.Stat(filepath.Join(dir, session.TimelineFile)); statErr == nil { + t.Fatalf("timeline.jsonl was written despite the collision") + } +} diff --git a/internal/transcribe/transcribe.go b/internal/transcribe/transcribe.go index de253cb..b644f13 100644 --- a/internal/transcribe/transcribe.go +++ b/internal/transcribe/transcribe.go @@ -18,6 +18,7 @@ import ( "os" "path/filepath" "strings" + "unicode/utf8" "github.com/REPPL/Testimony/internal/session" "github.com/REPPL/Testimony/internal/timeline" @@ -56,6 +57,11 @@ type segment struct { // Run performs the full pipeline and returns the number of utterances // written to transcript.jsonl in the session directory. func Run(opts Options) (int, error) { + // Default the log sink as record.Run does, so a caller that leaves Log nil + // gets progress on stderr instead of a panic at the first status line. + if opts.Log == nil { + opts.Log = os.Stderr + } man, err := session.LoadManifest(opts.SessionDir) if err != nil { return 0, err @@ -162,7 +168,7 @@ func Run(opts Options) (int, error) { if wrote { switch { case priorCaptured: - _ = session.WriteFileNoFollow(sidecar, prior, 0o644) + _ = session.WriteFileAtomicNoFollow(sidecar, prior, 0o644) case !priorExists: _ = os.Remove(sidecar) } @@ -314,14 +320,19 @@ const maxOffsetSidecarBytes = 64 << 10 const maxOffsetSeconds = 1e9 // writeOffsetSidecar persists offset (with its provenance, for the operator) -// beside audio.wav via the no-follow write guard, so a session's own directory -// cannot redirect the write through a planted symlink. +// beside audio.wav. The write is atomic (temp + rename) as well as +// no-follow: a plain truncating write destroyed the prior sidecar's bytes +// before writing the new ones, so a write failure in between left a +// truncated sidecar behind — and because the caller's rollback triggers only +// on a write that SUCCEEDED before the conversion failed, that truncated +// file was never restored: every later bare transcribe then refused on the +// unreadable sidecar, with the prior offset unrecoverable from the session. func writeOffsetSidecar(dir string, offset float64, provenance string) error { b, err := json.Marshal(offsetSidecar{OffsetSeconds: offset, Provenance: provenance}) if err != nil { return err } - return session.WriteFileNoFollow(filepath.Join(dir, session.AudioOffsetFile), append(b, '\n'), 0o644) + return session.WriteFileAtomicNoFollow(filepath.Join(dir, session.AudioOffsetFile), append(b, '\n'), 0o644) } // offsetSidecarExists reports whether a sidecar is already present beside @@ -330,8 +341,8 @@ func writeOffsetSidecar(dir string, offset float64, provenance string) error { // (malformed, oversized) is precisely the one an explicit -offset repairs — the // guidance readOffsetSidecar prints says so — so parsing here would refuse the // repair. os.Lstat, not os.Stat: a symlink planted at the sidecar name must -// count as present, so the rewrite meets session.WriteFileNoFollow's refusal -// rather than being mistaken for absence and skipped. +// count as present, so the rewrite meets session.WriteFileAtomicNoFollow's +// refusal rather than being mistaken for absence and skipped. func offsetSidecarExists(dir string) (bool, error) { if _, err := os.Lstat(filepath.Join(dir, session.AudioOffsetFile)); err != nil { if errors.Is(err, os.ErrNotExist) { @@ -446,7 +457,14 @@ func tail(b []byte) string { s := strings.TrimSpace(string(b)) const max = 800 if len(s) > max { - s = "…" + s[len(s)-max:] + // Advance the cut to the next rune boundary so the tail never opens + // mid-rune: a split UTF-8 sequence (a non-ASCII path in the engine's + // output straddling the cut) renders as replacement garbage. + i := len(s) - max + for i < len(s) && !utf8.RuneStart(s[i]) { + i++ + } + s = "…" + s[i:] } return s } diff --git a/internal/transcribe/transcribe_test.go b/internal/transcribe/transcribe_test.go index d738c35..0d222da 100644 --- a/internal/transcribe/transcribe_test.go +++ b/internal/transcribe/transcribe_test.go @@ -10,6 +10,7 @@ import ( "syscall" "testing" "time" + "unicode/utf8" "github.com/REPPL/Testimony/internal/session" "github.com/REPPL/Testimony/internal/timeline" @@ -1249,3 +1250,60 @@ func TestResolveModelAcceptsRegularFile(t *testing.T) { t.Fatalf("want %q, got %q", model, got) } } + +// TestWriteOffsetSidecarFailurePreservesPrior pins the atomic sidecar write. +// The previous truncating write (O_TRUNC, then write) destroyed the prior +// sidecar's bytes the moment the open succeeded, so a write that failed after +// that point left a truncated sidecar with the rollback skipped — and, in this +// read-only-directory arrangement, no failure at all: the existing file's own +// permissions let the truncating open through, silently replacing the one +// durable record of the offset. With temp + rename the write must instead +// fail without touching the prior bytes. +func TestWriteOffsetSidecarFailurePreservesPrior(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("directory write permissions do not bind root") + } + dir := t.TempDir() + sidecar := filepath.Join(dir, session.AudioOffsetFile) + const prior = `{"offset_seconds":12.5,"provenance":"derived: audio creation_time - manifest t0"}` + "\n" + if err := os.WriteFile(sidecar, []byte(prior), 0o644); err != nil { + t.Fatalf("seed sidecar: %v", err) + } + if err := os.Chmod(dir, 0o555); err != nil { + t.Fatalf("chmod: %v", err) + } + t.Cleanup(func() { os.Chmod(dir, 0o755) }) + + if err := writeOffsetSidecar(dir, 99, "explicit -offset"); err == nil { + t.Fatal("writeOffsetSidecar succeeded in a directory it cannot create the temp file in") + } + got, err := os.ReadFile(sidecar) + if err != nil { + t.Fatalf("read sidecar back: %v", err) + } + if string(got) != prior { + t.Fatalf("prior sidecar bytes not preserved:\ngot %q\nwant %q", got, prior) + } +} + +// TestRunDefaultsNilLog pins the log-sink default: record.Run defaults its +// Log to os.Stderr, but transcribe.Run wrote its offset status line straight +// to opts.Log, so a caller leaving Log nil panicked at the first Fprintf. +func TestRunDefaultsNilLog(t *testing.T) { + fakeTools(t) + dir, _ := seedSession(t, session.Manifest{Session: "s", T0EpochMS: 1_700_000_000_000}) + + if _, err := Run(Options{SessionDir: dir, Engine: EngineWhisperX}); err != nil { + t.Fatalf("Run with nil Log: %v", err) + } +} + +// TestTailCutsOnRuneBoundary pins the rune-aligned truncation of the engine +// diagnostic tail (see the identical property in record's tails): a byte-offset +// cut could open the tail mid-rune and render replacement garbage. +func TestTailCutsOnRuneBoundary(t *testing.T) { + long := strings.Repeat("é", 450) + "a" + if got := tail([]byte(long)); !utf8.ValidString(got) { + t.Fatalf("tail split a rune: %q...", got[:12]) + } +}