diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c04c0965c4..db7f3c9b9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,11 +133,28 @@ jobs: set -eu npm install -g tasks-axi tasks-axi --version + - name: Require Ghostscript for the PDF conformance gate + run: | + set -eu + # tests/fm-pdf-output.test.sh is the only proof that the PDF gate + # works. Without Ghostscript it would self-skip and CI would stay + # green while nothing was checked, which is the exact class of failure + # that gate exists to prevent. + command -v gs >/dev/null || { + sudo apt-get update -qq + sudo apt-get install -y -qq ghostscript + } + command -v gs >/dev/null || { + echo "::error::ghostscript is required for the PDF conformance gate proof" + exit 1 + } + gs --version - name: Run portable serial remainder run: | set -eu mkdir -p "$RUNNER_TEMP/fm-test" bin/fm-test-run.sh --lane portable-serial \ + --fail-on-gate-skip 'ghostscript not found' \ --json "$RUNNER_TEMP/fm-test/fm-test-timing-portable-serial.json" - name: Upload portable serial timing artifact if: always() diff --git a/AGENTS.md b/AGENTS.md index 75013180e3..b57d5de64c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -437,6 +437,7 @@ Do not surface automatic fixes, retries, routine progress, or internal supervisi Batch non-urgent updates into the next natural reply. Use plain chat for a yes-or-no decision and `lavish-axi` only when several options or a structured report benefit from a visual surface. Whenever a PR is mentioned, include its full `https://...` URL before any shorthand reference. +Generate a PDF deliverable only through `bin/fm-pdf-finish.sh`, which refuses to publish a file a real reader cannot read, because a browser-printed document looks correct on screen and fails at the recipient (docs/pdf-output.md). Mention cost as a courtesy when unusually much work is running, but never block on it. ## 10. Backlog contract diff --git a/bin/fm-pdf-finish.sh b/bin/fm-pdf-finish.sh new file mode 100755 index 0000000000..80341efd71 --- /dev/null +++ b/bin/fm-pdf-finish.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# Mandatory last step of any PDF generation path: produce the deliverable from +# one or more rendered parts, then refuse to publish it unless it passes the +# conformance gate. +# +# Why this exists rather than a merge command called by hand: browser-printed +# documents were being assembled with poppler's `pdfunite`, which writes a +# trailer whose declared cross-reference size does not match the objects it +# actually wrote. The result opens in some viewers and fails in others, and it +# looks perfect on screen right up to the moment the recipient cannot open it. +# docs/pdf-output.md carries the evidence and the exact diagnosis. +# +# The fix is at the source: Ghostscript's pdfwrite device rebuilds the document +# and emits a conforming cross-reference table, so it both merges the parts and +# normalizes them in one pass. Ghostscript is then run a second time, as a +# reader, to check the result - a producer's own claim about its output is not +# evidence, so the file is read back before it is trusted. +# +# Publication is atomic and gated: the work happens on a temporary file beside +# the destination and is moved into place only after bin/fm-pdf-verify.sh +# passes. A failed gate leaves any previous untouched and creates no new +# one, so a non-conforming file has no path to a recipient. +# +# Usage: fm-pdf-finish.sh [--pages ] [--quiet] [...] +# --pages require the finished document to have exactly pages; use it +# whenever the expected length is known, so a silently dropped +# page fails the gate instead of shipping. +# --quiet print nothing on success. +# +# Inputs are concatenated in the order given. Passing a single input is the +# normal "normalize one rendered file" case. +# +# Exit: 0 published; 1 the gate rejected the result (nothing published); +# 2 usage error; 3 the gate could not run (nothing published). +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERIFY="$SCRIPT_DIR/fm-pdf-verify.sh" +PDF_LIB="$SCRIPT_DIR/fm-pdf-lib.sh" +# shellcheck source=bin/fm-pdf-lib.sh +# shellcheck disable=SC1091 +. "$PDF_LIB" || { + echo "fm-pdf-finish: CANNOT VERIFY - helper library missing at $PDF_LIB" >&2 + exit 3 +} + +usage() { + cat >&2 <<'EOF' +Usage: fm-pdf-finish.sh [--pages ] [--quiet] [...] + +Merge and normalize the input PDFs into through a conforming +producer, verify the result with a real PDF reader, and publish it only if the +verification passes. + + --pages require exactly pages in the finished document + --quiet suppress the success line + +Exit codes: 0 published, 1 rejected, 2 usage, 3 could not verify. +EOF +} + +fm_pdf_parse_options fm-pdf-finish usage "$@" +parse_rc=$? +case "$parse_rc" in + 0) ;; + 10) exit 0 ;; + *) exit "$parse_rc" ;; +esac +set -- "${FM_PDF_ARGV[@]+"${FM_PDF_ARGV[@]}"}" +EXPECT_PAGES=$FM_PDF_EXPECT_PAGES +QUIET=$FM_PDF_QUIET + +[ "$#" -ge 2 ] || { usage; exit 2; } + +OUT=$1 +shift + +for src in "$@"; do + [ -f "$src" ] || { echo "fm-pdf-finish: input not found: $src" >&2; exit 2; } + [ -s "$src" ] || { echo "fm-pdf-finish: input is empty: $src" >&2; exit 2; } +done + +[ -x "$VERIFY" ] || { echo "fm-pdf-finish: CANNOT VERIFY - gate script missing at $VERIFY" >&2; exit 3; } + +GS_BIN=$(fm_pdf_gs_bin) +command -v "$GS_BIN" >/dev/null 2>&1 || { + echo "fm-pdf-finish: no PDF producer found (looked for '$GS_BIN'; install ghostscript)" >&2 + exit 3 +} + +OUT_DIR=$(dirname "$OUT") +[ -d "$OUT_DIR" ] || { echo "fm-pdf-finish: output directory does not exist: $OUT_DIR" >&2; exit 2; } + +# The template ends at the X's: BSD/macOS mktemp rejects a suffix after them. +# The producer and the gate both go by content, not by file extension. +TMP_OUT=$(mktemp "$OUT_DIR/.fm-pdf-finish.XXXXXX") || { + echo "fm-pdf-finish: could not create a temporary file in $OUT_DIR" >&2 + exit 3 +} +trap 'rm -f "$TMP_OUT"' EXIT + +# Absolute input paths: Ghostscript reads a leading `-` as an option. +ABS_INPUTS=() +for src in "$@"; do + case "$src" in + /*) ABS_INPUTS+=("$src") ;; + *) ABS_INPUTS+=("$PWD/$src") ;; + esac +done + +# /prepress keeps images and fonts at delivery quality; the size reduction this +# step produces comes from rebuilding the document, not from discarding content. +if ! gs_out=$("$GS_BIN" \ + -dBATCH -dNOPAUSE -dQUIET -dSAFER \ + -sDEVICE=pdfwrite \ + -dPDFSETTINGS=/prepress \ + -dCompatibilityLevel=1.7 \ + -dEmbedAllFonts=true \ + -dSubsetFonts=true \ + -dDetectDuplicateImages=true \ + -sOutputFile="$TMP_OUT" \ + "${ABS_INPUTS[@]}" 2>&1); then + echo "fm-pdf-finish: the PDF producer failed - nothing published" >&2 + printf '%s\n' "$gs_out" >&2 + exit 1 +fi + +VERIFY_ARGS=() +[ -n "$EXPECT_PAGES" ] && VERIFY_ARGS+=(--pages "$EXPECT_PAGES") + +# The gate runs exactly once, here, on the temporary file, and its verdict is +# the only one. The result is captured rather than re-derived after publication: +# a second reader pass would double the cost of every generation and, being the +# last command, would hand its own status to the caller - reporting "nothing +# published" about a document that is already live at . +gate_out=$("$VERIFY" "${VERIFY_ARGS[@]+"${VERIFY_ARGS[@]}"}" "$TMP_OUT" 2>&1) +verdict=$? + +if [ "$verdict" -ne 0 ]; then + # The gate refused, so nothing reaches . A previous stays as it was. + printf '%s\n' "$gate_out" >&2 + if [ "$verdict" -eq 3 ]; then + echo "fm-pdf-finish: CANNOT VERIFY the finished document - nothing published to $OUT" >&2 + else + echo "fm-pdf-finish: the finished document was rejected - nothing published to $OUT" >&2 + fi + exit "$verdict" +fi + +mv -f "$TMP_OUT" "$OUT" || { + echo "fm-pdf-finish: could not publish to $OUT" >&2 + exit 3 +} +trap - EXIT + +chmod 644 "$OUT" 2>/dev/null || true + +if [ "$QUIET" -eq 0 ]; then + # The page count is the one the reader itself counted during the gate run. + pages=$(printf '%s\n' "$gate_out" \ + | sed -n 's/.*(\([0-9][0-9]*\) pages, conforming)$/\1/p' \ + | tail -n 1) + if [ -n "$pages" ]; then + echo "fm-pdf-finish: published $OUT ($pages pages, conforming)" + else + echo "fm-pdf-finish: published $OUT (conforming)" + fi +fi + +exit 0 diff --git a/bin/fm-pdf-lib.sh b/bin/fm-pdf-lib.sh new file mode 100755 index 0000000000..6fedad5414 --- /dev/null +++ b/bin/fm-pdf-lib.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# fm-pdf-lib.sh - single owner of the option surface the PDF scripts share. +# +# bin/fm-pdf-verify.sh and bin/fm-pdf-finish.sh accept the same --pages and +# --quiet options with the same integer validation, and both resolve the same +# Ghostscript binary through FM_PDF_GS. Two copies of that drift apart the first +# time only one of them is corrected, so the parsing lives here and both scripts +# source it. Each script keeps its own usage text, its own message prefix, and +# its own exit codes, because their contracts and their audiences differ. +# +# No side effects on source. set -u safe. + +FM_PDF_EXPECT_PAGES="" +FM_PDF_QUIET=0 +FM_PDF_ARGV=() + +# fm_pdf_gs_bin - the Ghostscript binary this repo drives, honoring FM_PDF_GS. +fm_pdf_gs_bin() { + printf '%s\n' "${FM_PDF_GS:-gs}" +} + +# fm_pdf_parse_options "$@" - parse the shared options. +# +# Sets FM_PDF_EXPECT_PAGES, FM_PDF_QUIET, and FM_PDF_ARGV to the operands that +# follow the options. Returns 0 to continue, 2 on a usage error the caller must +# exit with, and 10 when --help was handled and the caller should exit 0. +# shellcheck disable=SC2034 # The three globals are read by the sourcing script. +fm_pdf_parse_options() { + local prefix=$1 usage_fn=$2 + shift 2 + FM_PDF_EXPECT_PAGES="" + FM_PDF_QUIET=0 + FM_PDF_ARGV=() + while [ "$#" -gt 0 ]; do + case "$1" in + --pages) + [ "$#" -ge 2 ] || { echo "$prefix: --pages needs a value" >&2; "$usage_fn"; return 2; } + FM_PDF_EXPECT_PAGES=$2 + case "$FM_PDF_EXPECT_PAGES" in + ''|*[!0-9]*) + echo "$prefix: --pages must be a positive integer, got '$FM_PDF_EXPECT_PAGES'" >&2 + return 2 + ;; + esac + [ "$FM_PDF_EXPECT_PAGES" -gt 0 ] || { + echo "$prefix: --pages must be a positive integer" >&2 + return 2 + } + shift 2 + ;; + --quiet) FM_PDF_QUIET=1; shift ;; + -h|--help) "$usage_fn"; return 10 ;; + --) shift; break ;; + -*) echo "$prefix: unknown option '$1'" >&2; "$usage_fn"; return 2 ;; + *) break ;; + esac + done + FM_PDF_ARGV=("$@") + return 0 +} diff --git a/bin/fm-pdf-verify.sh b/bin/fm-pdf-verify.sh new file mode 100755 index 0000000000..13bee805e7 --- /dev/null +++ b/bin/fm-pdf-verify.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env bash +# Conformance gate for generated PDFs: refuse a file that a real PDF reader +# cannot read as a spec-conforming document. +# +# It exists because a browser-printed handover PDF reached the captain unusable +# while looking correct on screen (docs/pdf-output.md). Screen fidelity is not +# evidence of a readable file, so every generated PDF passes through here before +# it is delivered. bin/fm-pdf-finish.sh wires this in as the mandatory last step +# of generation; call this directly only to audit a file that already exists. +# +# The reader is Ghostscript, which interprets every page rather than only +# reading the trailer, so a structurally broken document is caught even when the +# page tree looks intact. +# +# THE EXIT CODE OF THE READER IS NOT THE VERDICT. Ghostscript exits 0 on a file +# it reports as non-conforming, and `-q` suppresses the report while still +# exiting 0, so an exit-code check here would silently wave through exactly the +# defect this gate exists to catch. The verdict comes from the reader's +# diagnostics, and this script fails closed in both directions: it refuses when +# the reader reports a problem, and it equally refuses when the reader is +# missing, errors out, or prints output this script does not recognize. A gate +# that passes when it could not actually check reads like an assurance and is +# not one. +# +# The two refusals are told apart on purpose. REJECTED means the reader's output +# positively named a document problem. CANNOT VERIFY means the check did not +# happen - no reader, a reader that could not run, or a reader that said nothing +# recognizable - and it carries the reader's own message so the failure is +# attributable. Nothing is published either way, so the only thing at stake is +# whose fault it is: calling a broken, missing-library, OOM-killed or sandboxed +# reader a bad document sends someone to debug a file that was fine. +# +# Usage: fm-pdf-verify.sh [--pages ] [--quiet] [...] +# --pages also require every file to have exactly pages, counted by +# the reader itself, so a generation path cannot quietly drop or +# duplicate content while staying conforming. +# --quiet print nothing on success; failures always print. +# +# Exit: 0 every file conforms; 1 a file was rejected; 2 usage error; +# 3 verification could not be performed (treat as a failure, not a pass). +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PDF_LIB="$SCRIPT_DIR/fm-pdf-lib.sh" +# shellcheck source=bin/fm-pdf-lib.sh +# shellcheck disable=SC1091 +. "$PDF_LIB" || { + echo "fm-pdf-verify: CANNOT VERIFY - helper library missing at $PDF_LIB" >&2 + exit 3 +} + +usage() { + cat >&2 <<'EOF' +Usage: fm-pdf-verify.sh [--pages ] [--quiet] [...] + +Verify that each PDF is readable and spec-conforming, using Ghostscript as a +real PDF reader that interprets every page. + + --pages require exactly pages in every file + --quiet suppress the per-file success line + +Exit codes: 0 all conform, 1 rejected, 2 usage, 3 could not verify. +EOF +} + +fm_pdf_parse_options fm-pdf-verify usage "$@" +parse_rc=$? +case "$parse_rc" in + 0) ;; + 10) exit 0 ;; + *) exit "$parse_rc" ;; +esac +set -- "${FM_PDF_ARGV[@]+"${FM_PDF_ARGV[@]}"}" +EXPECT_PAGES=$FM_PDF_EXPECT_PAGES +QUIET=$FM_PDF_QUIET + +[ "$#" -ge 1 ] || { usage; exit 2; } + +GS_BIN=$(fm_pdf_gs_bin) +if ! command -v "$GS_BIN" >/dev/null 2>&1; then + # Fail closed: with no reader there is no verdict, and "unverified" must never + # be reported as "conforming". + echo "fm-pdf-verify: CANNOT VERIFY - no PDF reader found (looked for '$GS_BIN'; install ghostscript)" >&2 + exit 3 +fi + +# One rejection reason per file, printed by the caller loop. +REASON="" + +# read_pdf - interpret every page and echo the reader's combined output. +# Deliberately runs without -q so the conformance diagnostics survive, and with +# -dSAFER like the producer in bin/fm-pdf-finish.sh, because this script is also +# pointed at files this repo did not make and the reader interprets whatever it +# is handed. -dSAFER does not change the diagnostics the verdict is taken from. +# Ghostscript reads a leading `-` as an option and `--` as "run this file as a +# program", so the path is always passed absolute and never bare. +read_pdf() { + local abs=$1 + case "$abs" in + /*) : ;; + *) abs="$PWD/$abs" ;; + esac + "$GS_BIN" -o /dev/null -sDEVICE=nullpage -dNOPAUSE -dBATCH -dSAFER "$abs" 2>&1 +} + +# check_file - 0 conforming, 1 rejected (REASON set), 3 unverifiable. +check_file() { + local file=$1 out rc pages last_page complaint + + if [ ! -f "$file" ]; then + REASON="not a file" + return 1 + fi + if [ ! -r "$file" ]; then + REASON="not readable" + return 1 + fi + if [ ! -s "$file" ]; then + REASON="empty file" + return 1 + fi + + out=$(read_pdf "$file") + rc=$? + + # The reader's own complaint comes first - before its exit status and before + # any proof-of-work check - so that a file the reader positively condemned is + # reported as rejected rather than as merely unverifiable. A badly truncated + # file, for instance, draws the banner without ever reaching a page report, + # and it is a bad file, not an unchecked one. Ghostscript prints this banner + # while still exiting 0; `****` prefixes its error and repair notices. + case "$out" in + *"does not conform"*) + REASON="does not conform to the PDF specification" + printf '%s\n' "$out" >&2 + return 1 + ;; + *"had errors that were repaired or ignored"*) + REASON="contains errors the reader had to repair or ignore" + printf '%s\n' "$out" >&2 + return 1 + ;; + *"were encountered at least once"*) + REASON="the PDF reader raised structural errors or warnings" + printf '%s\n' "$out" >&2 + return 1 + ;; + *"Unrecoverable error"*|*"**** Error"*) + REASON="the PDF reader hit an unrecoverable error" + printf '%s\n' "$out" >&2 + return 1 + ;; + esac + + # Only the reader's output condemns a document. A non-zero exit that named no + # document problem is a reader that could not run - a missing library, an OOM + # kill, a sandbox denial - so it is reported as an unperformed check with the + # reader's own message attached, not as a bad file. It still fails closed. + if [ "$rc" -ne 0 ]; then + complaint=$(printf '%s\n' "$out" | awk 'NF { print; exit }') + REASON="the PDF reader failed (exit $rc) without naming a document problem: ${complaint:-no output}" + printf '%s\n' "$out" >&2 + return 3 + fi + + # Proof of work. The reader announces its page range before interpreting, so + # its absence - with no complaint either - means the tool did not do what this + # gate assumes it did: a changed, wrapped, or stubbed reader. Refuse rather + # than infer success from silence. + pages=$(printf '%s\n' "$out" \ + | sed -n 's/^[Pp]rocessing pages [0-9][0-9]* through \([0-9][0-9]*\).*$/\1/p' \ + | tail -n 1) + if [ -z "$pages" ]; then + REASON="the PDF reader produced no recognizable page report - cannot verify" + printf '%s\n' "$out" >&2 + return 3 + fi + + # And proof it reached the end: the last page must actually have been + # interpreted, not just announced. + last_page=$(printf '%s\n' "$out" | grep -c "^Page ${pages}[[:space:]]*$") + if [ "$last_page" -eq 0 ]; then + REASON="the PDF reader stopped before page $pages - document truncated or corrupt" + printf '%s\n' "$out" >&2 + return 1 + fi + + if [ -n "$EXPECT_PAGES" ] && [ "$pages" != "$EXPECT_PAGES" ]; then + REASON="has $pages pages, expected $EXPECT_PAGES" + return 1 + fi + + VERIFIED_PAGES=$pages + return 0 +} + +VERIFIED_PAGES="" +status=0 + +for file in "$@"; do + REASON="" + VERIFIED_PAGES="" + if check_file "$file"; then + [ "$QUIET" -eq 1 ] || echo "fm-pdf-verify: OK $file ($VERIFIED_PAGES pages, conforming)" + else + rc=$? + if [ "$rc" -eq 3 ]; then + echo "fm-pdf-verify: CANNOT VERIFY $file - $REASON" >&2 + [ "$status" -eq 0 ] && status=3 + else + echo "fm-pdf-verify: REJECTED $file - $REASON" >&2 + status=1 + fi + fi +done + +exit "$status" diff --git a/docs/configuration.md b/docs/configuration.md index 553717e53b..860ce0c714 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -533,6 +533,7 @@ FM_CODEX_WATCH_CHECKPOINT=180 # seconds per foreground watcher checkpoint in C FM_CREW_STATE_NM_TIMEOUT=10 # seconds allowed per no-mistakes query inside fm-crew-state.sh FM_CREW_STATE_RUNS_LIMIT=200 # recent no-mistakes run rows scanned when axi status cannot be attributed to the current code FM_CREW_STATE_BIN=bin/fm-crew-state.sh # test override for the current-state reader used by working/paused watcher triage +FM_PDF_GS=gs # Ghostscript binary bin/fm-pdf-finish.sh and bin/fm-pdf-verify.sh drive as PDF producer and reader; absent means both refuse with exit 3 (docs/pdf-output.md) FMX_PAIRING_TOKEN= # X mode pairing token; .env opt-in authorizes replies and eligible lifecycle actions FMX_RELAY_URL=https://myfirstmate.io # optional X relay override, mainly for local relay development FMX_ENV_FILE= # optional alternate .env file for direct X client invocations; bootstrap still checks $FM_HOME/.env diff --git a/docs/pdf-output.md b/docs/pdf-output.md new file mode 100644 index 0000000000..0f4286a589 --- /dev/null +++ b/docs/pdf-output.md @@ -0,0 +1,185 @@ +# Generated PDF output + +Firstmate occasionally produces a PDF for the captain by printing an HTML document from a headless browser. +On 2026-07-26 one of those documents reached the captain unopenable, and this page records what was wrong, what fixed it, and what now prevents a repeat. +It is evidence, not narrative: every command and every quoted output below was run on 2026-07-27 against the file that actually failed. + +`bin/fm-pdf-finish.sh` and `bin/fm-pdf-verify.sh` own the mechanics; their headers and `--help` are authoritative for flags and exit codes. +They share one option surface, parsed in `bin/fm-pdf-lib.sh`, so the two cannot drift apart when only one is corrected. + +## The incident + +A 27-page handover document rendered correctly on screen and would not open on the captain's phone. +Size was not the cause. +Rewriting the file through Ghostscript both repaired it and shrank it from 2,448,482 to 780,372 bytes, and the rewritten copy read cleanly through all 27 pages. + +That combination - correct on screen, refused by the recipient, fixed by a rewrite - is the signature of a structurally invalid file rather than a content problem. + +## The cause + +The generation path printed the document with a headless browser, split it with poppler's `pdfseparate`, and reassembled it with poppler's `pdfunite`. +`pdfunite` is the step that produced an invalid file. + +The trailer of the failing document: + +``` +trailer +<> +startxref +1663372 +%%EOF +``` + +Three numbers that cannot all be true at once: + +| Value | Where it comes from | Meaning | +| ----- | ------------------- | ------- | +| `/Size 2935` | trailer dictionary | the file claims 2935 cross-reference entries | +| `0 39249` | the cross-reference table's own subsection header | the table actually declares 39249 entries | +| `39248` | highest object number written in the body | the highest object the file defines | + +PDF 32000-1 section 7.5.5 requires the trailer's `/Size` to be one greater than the highest object number used in the file. +That would be 39249, which is exactly what the cross-reference table declares. +The trailer instead carries 2935, the *count* of objects written, and `/Root 39220 0 R` points at an object number that the declared size does not even reach. +`pdfunite` offsets each input file's object numbers by the previous file's size, which leaves the numbering sparse, and then writes the count where the specification requires the maximum plus one. + +Ghostscript names it directly: + +``` +$ gs -o /dev/null -sDEVICE=nullpage -dNOPAUSE -dBATCH frota-modelo-de-operacao.pdf +Processing pages 1 through 27. +Page 1 +... +Page 27 + +The following warnings were encountered at least once while processing this file: + incorrect xref size + + **** This file had errors that were repaired or ignored. + **** Please notify the author of the software that produced this + **** file that it does not conform to Adobe's published PDF + **** specification. +``` + +The browser is not at fault. +A known-good PDF pushed through the same two poppler steps acquires the identical defect, which isolates the cause to the assembly step: + +``` +$ pdfseparate good.pdf p-%d.pdf && pdfunite p-*.pdf united.pdf +$ gs -o /dev/null -sDEVICE=nullpage -dNOPAUSE -dBATCH united.pdf 2>&1 | grep -i conform + **** file that it does not conform to Adobe's published PDF +``` + +Versions used for every command on this page: Ghostscript 10.02.1 (2023-11-01), poppler-utils 24.02.0, Linux 6.8.0. + +## Why it went unnoticed + +Nothing in the generation path read the file back. +The document looked right in every viewer that tolerates a broken cross-reference table, which includes most desktop viewers, and failed only in a stricter one. +A defect that is invisible at the point of production and visible only at the recipient is the expensive kind, so the fix is not a better habit but a step that cannot be skipped. + +## The fix + +Assembly no longer goes through `pdfunite`. +`bin/fm-pdf-finish.sh` merges and normalizes the rendered parts with Ghostscript's `pdfwrite` device, which rebuilds the document and emits a correct cross-reference table, then reads the result back with `bin/fm-pdf-verify.sh` and publishes it only if that check passes. + +``` +$ bin/fm-pdf-finish.sh --pages 27 handover.pdf rendered.pdf +fm-pdf-finish: published handover.pdf (27 pages, conforming) +``` + +The gate runs once, on the temporary file, before publication. +The success line reports that single verdict rather than re-reading the published document, so a 27-page file is interpreted once and the step's exit status stays its own: 0 means published. + +Applied to the file that failed, this reproduces the original repair exactly: 2,448,482 bytes in, 780,372 bytes out, 27 pages, conforming. +The reduction comes from rebuilding the document, not from dropping content - the page count is asserted, so a repair that lost pages would fail the gate rather than ship. + +Publication is atomic and conditional. +The work happens on a temporary file beside the destination and is moved into place only after the check passes, so a rejected result leaves any previous file untouched and creates no new one. + +## Why the check does not trust an exit code + +This is the part most likely to be undone by a well-meaning simplification, so it is recorded explicitly. + +**Ghostscript exits 0 on a file it has just reported as non-conforming.** + +``` +$ gs -o /dev/null -sDEVICE=nullpage -dNOPAUSE -dBATCH broken.pdf >/dev/null 2>&1; echo $? +0 +``` + +`-dPDFSTOPONERROR` and `-dPDFSTOPONWARNING` do not change this, and `-q` additionally suppresses the diagnostics while still exiting 0: + +``` +$ gs -q -o /dev/null -sDEVICE=nullpage -dNOPAUSE -dBATCH -dPDFSTOPONWARNING broken.pdf; echo $? +0 +``` + +A gate written as "run Ghostscript, check `$?`" would therefore pass the exact file this whole page is about. +`bin/fm-pdf-verify.sh` takes its verdict from the reader's diagnostics instead, and treats three separate conditions as failures rather than passes: + +- the reader reports a conformance problem, an error, or a structural warning; +- the reader is absent, or exits non-zero; +- the reader runs but prints no recognizable page report, which is what a stubbed, wrapped, or changed tool looks like. + +The last one is the reason the script asserts that the reader announced its page range *and* interpreted the final page. +The reader's own complaint is weighed before that proof-of-work check, so a file the reader positively condemns is reported as rejected rather than as unchecked; badly truncated files draw the banner without ever reaching a page report, and they are bad files, not unexamined ones. +Silence from a checking tool is not evidence of a clean file, and a gate that passes when it could not actually check reads like an assurance while being none. + +## Rejected versus could not verify + +The two refusals are told apart deliberately, and both fail closed. +`REJECTED` (exit 1) means the reader's output positively named a document problem, or the gate itself found no readable, non-empty file to hand over. +A damaged file - a truncated document, or one that is not a PDF at all - is normally named by the reader and lands here, but which of the two refusals it draws follows the installed reader's wording and is deliberately not pinned (see "Proof in both directions"). +`CANNOT VERIFY` (exit 3) means the check did not happen: no reader, a reader that exited non-zero without naming a document problem, or a reader that printed nothing recognizable. +That second class carries the reader's own message verbatim, so the failure is attributable. + +Nothing is published either way, so the only thing at stake is whose fault it is. +Calling a broken, missing-library, OOM-killed or sandboxed reader a bad document sends someone to debug a file that was fine. +Understating genuine garbage is the safer error here, because the file is still refused. + +## Proof in both directions + +`tests/fm-pdf-output.test.sh` covers the contract and is proven in both directions, because a gate that only ever rejects is as useless as one that only ever accepts. +It builds its own conforming fixture with Ghostscript, then reproduces the field defect deterministically by rewriting only the trailer's `/Size` - the body is copied byte-for-byte, so the fixture needs no poppler and cannot drift. +When poppler is present it additionally asserts against genuine `pdfunite` output. + +The suite asserts that a non-conforming file is rejected with the reader's own diagnosis surfaced, that a conforming file passes with its real page count, that a wrong page count is rejected and the right one accepted, that a missing reader, a silent reader, and a reader that could not run each refuse rather than pass, that a reader naming a document problem still rejects, that assembly repairs the defect while preserving every page, and that a rejected result never reaches the destination, never overwrites a previous file, and leaves no temporary artifacts. +It asserts on Ghostscript's `does not conform` banner, which is what the gate itself matches on, and not on version-specific warning wording, because no Ghostscript version is pinned in this repo. +For the same reason, damaged real files are asserted only to be refused with nothing published, not to land in one exact refusal class: which class they land in follows the installed reader's wording. +The `REJECTED` versus `CANNOT VERIFY` distinction is locked exactly by the stub-reader cases, where the suite controls the reader's output byte for byte. + +The suite is not vacuous: replacing the gate with an unconditional success - which is behaviorally what an exit-code-only check would be here, since the reader exits 0 on the broken file - fails it at the first assertion. + +The proof has to actually run. +Without Ghostscript the suite would skip and CI would stay green while nothing was checked, so the `portable-serial` lane in `.github/workflows/ci.yml` installs Ghostscript, requires it, and passes `--fail-on-gate-skip 'ghostscript not found'`, which turns that skip into a lane failure. +The lane is the single owner of that requirement. +A developer run without Ghostscript skips, as it does for every other optional-tool suite in this repo. + +## Using it + +Any path that generates a PDF finishes through `bin/fm-pdf-finish.sh` rather than writing its output file directly, and passes `--pages` whenever the expected length is known. +Use `bin/fm-pdf-verify.sh` on its own only to audit a file that already exists. +Both treat any non-zero exit as a failure, including exit 3, which means the check could not be performed. +Both need Ghostscript on the machine that generates, resolved through `FM_PDF_GS` and defaulting to `gs`; when it is absent they refuse with exit 3 and publish nothing rather than falling back to an unchecked file. + +The failing document was assembled by splitting a browser-printed file and reuniting it, because the browser draws the page footer on the cover as well and the cover has to come from a separate footerless print. +That shape is fine; only the reassembly tool was wrong. +`pdfseparate` is not implicated and can still split, but the final join belongs to the gated step: + +``` +# before - produces a non-conforming file, and nothing notices +pdfseparate full.pdf body-%d.pdf # drop page 1 +pdfunite cover.pdf body-*.pdf out.pdf + +# after - conforming producer, and the file is read back before it is published +pdfseparate -f 2 full.pdf body-%d.pdf +bin/fm-pdf-finish.sh --pages 27 out.pdf cover.pdf body-1.pdf body-2.pdf ... +``` + +Passing the parts straight to `bin/fm-pdf-finish.sh` also removes the separate merge tool: it concatenates its inputs in order, so the split-and-rejoin only needs poppler for the split. + +One consequence worth knowing before comparing old and new output: rebuilding the document maps `fi` and `fl` to their ligature codepoints, so extracted text carries `fi` where the browser's output carried `fi`. +Rendering is unchanged and the word count is identical; only text extraction and in-viewer search for those words differ. +This matches the repaired copy the captain already accepted, so it is the established baseline rather than a new change. diff --git a/docs/scripts.md b/docs/scripts.md index de61bde060..a998f33a64 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -62,6 +62,9 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-merge-local.sh` | Fast-forward a `local-only` project's local default branch after approval | | `fm-bridge-relay.sh` | Guardedly relay envelope-only `send`/`inbox`/`status`/`broadcast` calls to the coditan-bridge checkout's own scripts | | `fm-review-diff.sh` | Review a crewmate branch or recorded PR head against the authoritative base | +| `fm-pdf-finish.sh` | Assemble a generated PDF through a conforming producer and publish it only if the gate passes (docs/pdf-output.md) | +| `fm-pdf-verify.sh` | Refuse a PDF a real reader cannot read as spec-conforming; fails closed when it cannot check | +| `fm-pdf-lib.sh` | Shared `--pages`/`--quiet` parsing and Ghostscript resolution for both PDF scripts | | `fm-marker-lib.sh` | Compatibility entry point for the from-firstmate carrier owned by `fm-operational-input.sh` | | `fm-mark-parked.sh` | Validate and declare an ordinary terminal task parked through a seatbelt-safe wrapper | | `fm-pending-reply-lib.sh` | Parent-owned secondmate pending-reply expectations, recovery, and one-shot escalation | diff --git a/tests/fm-pdf-output.test.sh b/tests/fm-pdf-output.test.sh new file mode 100755 index 0000000000..d9793fd79b --- /dev/null +++ b/tests/fm-pdf-output.test.sh @@ -0,0 +1,293 @@ +#!/usr/bin/env bash +# Behavior tests for the PDF conformance gate (bin/fm-pdf-verify.sh) and the +# gated generation step (bin/fm-pdf-finish.sh). +# +# The defect these guard against shipped once already: a browser-printed +# handover PDF was assembled with poppler's `pdfunite`, whose trailer declared a +# cross-reference size that did not match the objects it wrote. It rendered +# correctly on screen and could not be opened by the recipient +# (docs/pdf-output.md). +# +# A gate is only worth its cost if it is proven in BOTH directions, so this +# suite asserts that a non-conforming file is refused, that a conforming file +# passes, and - the part that is easy to get wrong - that the gate refuses +# rather than passes whenever it could not actually perform the check. +set -u + +# shellcheck source=tests/lib.sh +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +VERIFY="$ROOT/bin/fm-pdf-verify.sh" +FINISH="$ROOT/bin/fm-pdf-finish.sh" +fm_test_tmproot TMP_ROOT fm-pdf-output + +# Ghostscript is this suite's only proof of the conformance gate, so CI must +# never report green without it. The portable-serial lane owns that: it installs +# Ghostscript, requires it, and passes --fail-on-gate-skip for this exact token, +# which turns the skip below into a lane failure. A developer run without +# Ghostscript skips. +command -v gs >/dev/null 2>&1 || { echo "skip: ghostscript not found"; exit 0; } + +# --- one owner for the shared option surface -------------------------------- + +# Both scripts parse --pages and --quiet and resolve the reader the same way. +# They must do it through bin/fm-pdf-lib.sh rather than each carrying a copy, +# because two copies drift apart the first time only one gets corrected. +PDF_LIB="$ROOT/bin/fm-pdf-lib.sh" +assert_present "$PDF_LIB" "the shared PDF option library must exist" +assert_grep 'fm-pdf-lib.sh' "$VERIFY" "the gate must source the shared option library" +assert_grep 'fm-pdf-lib.sh' "$FINISH" "the generation step must source the shared option library" +pass "both PDF scripts share one option library" + +# --- fixtures --------------------------------------------------------------- + +# make_good - a conforming PDF written by a conforming producer. +make_good() { + local out=$1 pages=$2 ps="$TMP_ROOT/src-$2.ps" i + : > "$ps" + printf '/Helvetica findfont 24 scalefont setfont\n' >> "$ps" + for ((i = 1; i <= pages; i++)); do + printf '72 700 moveto (Page %d) show showpage\n' "$i" >> "$ps" + done + gs -dBATCH -dNOPAUSE -dQUIET -sDEVICE=pdfwrite -sOutputFile="$out" "$ps" >/dev/null 2>&1 +} + +# make_broken - reproduce the field defect exactly: a +# trailer whose /Size no longer matches the highest object number in the file +# (PDF 32000-1 section 7.5.5). The body is copied byte-for-byte with `head -c` +# and only the trailing ASCII trailer is rewritten, so no binary stream is +# disturbed and the fixture is deterministic without needing poppler. +make_broken() { + local src=$1 out=$2 off + off=$(grep -abo 'trailer' "$src" | tail -n 1 | cut -d: -f1) + [ -n "$off" ] || fail "fixture: no trailer found in $src" + head -c "$off" "$src" > "$out" + tail -c "+$((off + 1))" "$src" | sed 's|/Size [0-9][0-9]*|/Size 3|' >> "$out" +} + +GOOD="$TMP_ROOT/good.pdf" +BROKEN="$TMP_ROOT/broken.pdf" +make_good "$GOOD" 5 || fail "fixture: could not build a conforming PDF" +[ -s "$GOOD" ] || fail "fixture: conforming PDF is empty" +make_broken "$GOOD" "$BROKEN" + +# --- direction 1: a non-conforming file is REFUSED -------------------------- + +out=$("$VERIFY" "$BROKEN" 2>&1); rc=$? +expect_code 1 "$rc" "a non-conforming PDF must be rejected" +assert_contains "$out" "REJECTED" "rejection must be stated plainly" +assert_contains "$out" "does not conform" "rejection must name the conformance failure" +pass "non-conforming PDF is rejected" + +# --- direction 2: a conforming file PASSES ---------------------------------- + +out=$("$VERIFY" "$GOOD" 2>&1); rc=$? +expect_code 0 "$rc" "a conforming PDF must pass" +assert_contains "$out" "OK" "a passing file must be reported as OK" +assert_contains "$out" "5 pages" "the page count must come from the reader" +pass "conforming PDF passes" + +# A gate that rejects everything is as useless as one that accepts everything: +# prove the pass is real by asserting the correct page count is accepted. +out=$("$VERIFY" --pages 5 "$GOOD" 2>&1); rc=$? +expect_code 0 "$rc" "a conforming PDF with the expected page count must pass" +pass "page-count assertion accepts the correct count" + +out=$("$VERIFY" --pages 4 "$GOOD" 2>&1); rc=$? +expect_code 1 "$rc" "a wrong page count must be rejected" +assert_contains "$out" "expected 4" "a page-count mismatch must say what was expected" +pass "page-count assertion rejects a wrong count" + +# --- fail closed: an unperformed check is never a pass ---------------------- + +out=$(FM_PDF_GS=fm-no-such-pdf-reader "$VERIFY" "$GOOD" 2>&1); rc=$? +expect_code 3 "$rc" "a missing PDF reader must not report success" +assert_contains "$out" "CANNOT VERIFY" "a missing reader must say the check did not happen" +assert_not_contains "$out" "OK $GOOD" "a missing reader must never print a pass" +pass "missing reader fails closed" + +# A reader that runs but tells us nothing is the dangerous case: exit 0 with no +# diagnostics looks exactly like success. It must not be treated as one. +STUB_BIN="$TMP_ROOT/stub" +mkdir -p "$STUB_BIN" +printf '#!/bin/sh\nexit 0\n' > "$STUB_BIN/gs" +chmod +x "$STUB_BIN/gs" +out=$(FM_PDF_GS="$STUB_BIN/gs" "$VERIFY" "$GOOD" 2>&1); rc=$? +expect_code 3 "$rc" "a silent reader must not report success" +assert_contains "$out" "CANNOT VERIFY" "a silent reader must say the check did not happen" +pass "silent reader fails closed" + +# A reader that could not run is not a verdict on the document. Both refuse and +# nothing is published either way, so the only thing at stake is attribution: +# calling a broken, missing-library, OOM-killed or sandboxed reader a bad +# document sends someone to debug a file that was fine. +cat > "$STUB_BIN/gs" <<'STUB' +#!/bin/sh +echo "gs: error while loading shared libraries: libgs.so.10: cannot open shared object file" >&2 +exit 127 +STUB +out=$(FM_PDF_GS="$STUB_BIN/gs" "$VERIFY" "$GOOD" 2>&1); rc=$? +expect_code 3 "$rc" "a reader that could not run must not report success" +assert_contains "$out" "CANNOT VERIFY" "a reader that could not run must say the check did not happen" +assert_contains "$out" "error while loading shared libraries" "the reader's own message must be attributable" +assert_not_contains "$out" "REJECTED" "a broken reader must not be reported as a bad document" +pass "a reader that could not run fails closed as unverifiable" + +# The other direction: a non-zero exit whose output DOES name a document problem +# is a verdict on the file and stays a rejection. +cat > "$STUB_BIN/gs" <<'STUB' +#!/bin/sh +echo "Processing pages 1 through 5." +echo " **** This file had errors that were repaired or ignored." +exit 4 +STUB +out=$(FM_PDF_GS="$STUB_BIN/gs" "$VERIFY" "$GOOD" 2>&1); rc=$? +expect_code 1 "$rc" "a reader that names a document problem must reject the file" +assert_contains "$out" "REJECTED" "a condemned document must be reported as rejected" +assert_not_contains "$out" "CANNOT VERIFY" "a condemned document must not be downgraded to unverifiable" +pass "a failing reader that names a document problem still rejects" + +# Missing and empty inputs are rejected, not skipped. +out=$("$VERIFY" "$TMP_ROOT/does-not-exist.pdf" 2>&1); rc=$? +expect_code 1 "$rc" "a missing file must be rejected" +: > "$TMP_ROOT/empty.pdf" +out=$("$VERIFY" "$TMP_ROOT/empty.pdf" 2>&1); rc=$? +expect_code 1 "$rc" "an empty file must be rejected" +assert_contains "$out" "empty file" "an empty file must say so" +pass "missing and empty inputs are rejected" + +# Damaged real files must be REFUSED, and nothing may be published from them. +# Which refusal class they land in is decided by the installed reader's own +# wording, and no Ghostscript version is pinned anywhere in this repo, so +# pinning the exact class here would fail on a correct gate the day the runner +# image moves. The stub cases above own the class distinction, because there the +# reader's output is controlled byte for byte. +# +# refuses_damaged