From 183ce9a18eca150b5f50298b329d695af808f8c1 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:39:48 +0000 Subject: [PATCH 1/4] fix(bin): produce conforming PDFs and gate every generated file on a real reader A browser-printed handover document reached the captain unopenable while looking correct on screen. The cause was the assembly step, not the browser and not the file size: poppler's `pdfunite` wrote a trailer declaring `/Size 2935` for a document whose own cross-reference table declares 39249 entries and whose highest object number is 39248, violating PDF 32000-1 section 7.5.5. A known-good PDF pushed through the same two poppler steps acquires the identical defect, which isolates the cause. `bin/fm-pdf-finish.sh` replaces that assembly with Ghostscript's pdfwrite device, which rebuilds the document and emits a correct cross-reference table, then reads the result back and publishes it only if the check passes. Work happens on a temporary file beside the destination, so a rejected result leaves any previous file untouched and creates no new one. `bin/fm-pdf-verify.sh` is that check. It deliberately does not trust an exit code: Ghostscript exits 0 on a file it has just reported as non-conforming, `-dPDFSTOPONERROR` and `-dPDFSTOPONWARNING` do not change that, and `-q` additionally hides the diagnostics. A gate written as "run Ghostscript, check $?" would pass the exact file this commit is about. The verdict comes from the reader's diagnostics, and the script refuses in both directions - when the reader reports a problem, and equally when the reader is absent, fails, or prints nothing recognizable. A check that passes when it could not actually check reads like an assurance and is not one. `tests/fm-pdf-output.test.sh` proves both directions. It builds its own conforming fixture and reproduces the field defect deterministically by rewriting only the trailer, so it needs no poppler, and additionally asserts against genuine `pdfunite` output when poppler is present. It covers the fail-closed paths, the no-publish-on-reject guarantee, and page preservation. The suite is not vacuous: an always-pass gate fails it at the first assertion. Applied to the file that failed, this reproduces the original repair exactly - 2,448,482 bytes in, 780,372 bytes out, 27 pages, conforming, with text identical to the repaired copy the captain already accepted. docs/pdf-output.md carries the evidence, versions, and exact output. --- AGENTS.md | 1 + bin/fm-pdf-finish.sh | 159 +++++++++++++++++++++++++ bin/fm-pdf-verify.sh | 204 ++++++++++++++++++++++++++++++++ docs/pdf-output.md | 160 +++++++++++++++++++++++++ docs/scripts.md | 2 + tests/fm-pdf-output.test.sh | 226 ++++++++++++++++++++++++++++++++++++ 6 files changed, 752 insertions(+) create mode 100755 bin/fm-pdf-finish.sh create mode 100755 bin/fm-pdf-verify.sh create mode 100644 docs/pdf-output.md create mode 100755 tests/fm-pdf-output.test.sh 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..f3422edfeb --- /dev/null +++ b/bin/fm-pdf-finish.sh @@ -0,0 +1,159 @@ +#!/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" + +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 +} + +EXPECT_PAGES="" +QUIET=0 + +while [ "$#" -gt 0 ]; do + case "$1" in + --pages) + [ "$#" -ge 2 ] || { echo "fm-pdf-finish: --pages needs a value" >&2; usage; exit 2; } + EXPECT_PAGES=$2 + case "$EXPECT_PAGES" in + ''|*[!0-9]*) echo "fm-pdf-finish: --pages must be a positive integer, got '$EXPECT_PAGES'" >&2; exit 2 ;; + esac + [ "$EXPECT_PAGES" -gt 0 ] || { echo "fm-pdf-finish: --pages must be a positive integer" >&2; exit 2; } + shift 2 + ;; + --quiet) QUIET=1; shift ;; + -h|--help) usage; exit 0 ;; + --) shift; break ;; + -*) echo "fm-pdf-finish: unknown option '$1'" >&2; usage; exit 2 ;; + *) break ;; + esac +done + +[ "$#" -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:-gs} +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") +VERIFY_ARGS+=(--quiet) + +"$VERIFY" "${VERIFY_ARGS[@]}" "$TMP_OUT" +verdict=$? + +if [ "$verdict" -ne 0 ]; then + # The gate refused, so nothing reaches . A previous stays as it was. + 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 + "$VERIFY" "$OUT" +fi diff --git a/bin/fm-pdf-verify.sh b/bin/fm-pdf-verify.sh new file mode 100755 index 0000000000..d58532ec48 --- /dev/null +++ b/bin/fm-pdf-verify.sh @@ -0,0 +1,204 @@ +#!/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. +# +# 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 + +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 +} + +EXPECT_PAGES="" +QUIET=0 + +while [ "$#" -gt 0 ]; do + case "$1" in + --pages) + [ "$#" -ge 2 ] || { echo "fm-pdf-verify: --pages needs a value" >&2; usage; exit 2; } + EXPECT_PAGES=$2 + case "$EXPECT_PAGES" in + ''|*[!0-9]*) echo "fm-pdf-verify: --pages must be a positive integer, got '$EXPECT_PAGES'" >&2; exit 2 ;; + esac + [ "$EXPECT_PAGES" -gt 0 ] || { echo "fm-pdf-verify: --pages must be a positive integer" >&2; exit 2; } + shift 2 + ;; + --quiet) QUIET=1; shift ;; + -h|--help) usage; exit 0 ;; + --) shift; break ;; + -*) echo "fm-pdf-verify: unknown option '$1'" >&2; usage; exit 2 ;; + *) break ;; + esac +done + +[ "$#" -ge 1 ] || { usage; exit 2; } + +GS_BIN=${FM_PDF_GS:-gs} +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. +# 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 "$abs" 2>&1 +} + +# check_file - 0 conforming, 1 rejected (REASON set), 3 unverifiable. +check_file() { + local file=$1 out rc pages last_page + + 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=$? + + # A reader that failed to run at all is an unverifiable result, not a pass. + if [ "$rc" -ne 0 ]; then + REASON="the PDF reader failed (exit $rc) - file unreadable or reader broken" + printf '%s\n' "$out" >&2 + return 1 + fi + + # The verdict comes first, 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 + + # 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/pdf-output.md b/docs/pdf-output.md new file mode 100644 index 0000000000..ca67a537e2 --- /dev/null +++ b/docs/pdf-output.md @@ -0,0 +1,160 @@ +# 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. + +## 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-verify: OK handover.pdf (27 pages, conforming) +``` + +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. + +## 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 failing reader each refuse rather than pass, 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. + +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. + +## 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. + +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..128379b587 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -62,6 +62,8 @@ 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-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..f9a892f800 --- /dev/null +++ b/tests/fm-pdf-output.test.sh @@ -0,0 +1,226 @@ +#!/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 + +command -v gs >/dev/null 2>&1 || { echo "skip: ghostscript not found"; exit 0; } + +# --- 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" + +# The reader's own diagnosis reaches the caller, so the failure is actionable +# rather than a bare exit code. +assert_contains "$out" "incorrect xref size" "the reader's diagnosis must be surfaced" +pass "reader diagnosis is surfaced to the caller" + +# --- 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 dies mid-document must not be mistaken for a clean read. +printf '#!/bin/sh\nexit 4\n' > "$STUB_BIN/gs" +out=$(FM_PDF_GS="$STUB_BIN/gs" "$VERIFY" "$GOOD" 2>&1); rc=$? +expect_code 1 "$rc" "a failing reader must not report success" +assert_contains "$out" "REJECTED" "a failing reader must reject the file" +pass "failing reader fails closed" + +# 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" + +# A file the reader positively condemns is REJECTED, not merely unverifiable, +# even when the damage stops it from reaching a page report. Truncation is the +# ordinary way that happens, and calling it "could not check" would understate a +# file that is definitely bad. +head -c 900 "$GOOD" > "$TMP_ROOT/truncated.pdf" +out=$("$VERIFY" "$TMP_ROOT/truncated.pdf" 2>&1); rc=$? +expect_code 1 "$rc" "a truncated file must be rejected" +assert_contains "$out" "REJECTED" "a truncated file must be reported as rejected" +pass "a truncated file is rejected, not called unverifiable" + +# A batch is only as good as its worst file. +out=$("$VERIFY" "$GOOD" "$BROKEN" 2>&1); rc=$? +expect_code 1 "$rc" "a batch containing a bad file must fail" +pass "a batch fails when any file fails" + +# --- the generation step publishes only what passes ------------------------- + +OUT="$TMP_ROOT/finished.pdf" +out=$("$FINISH" --pages 5 "$OUT" "$BROKEN" 2>&1); rc=$? +expect_code 0 "$rc" "finishing a broken input must produce a conforming document" +assert_present "$OUT" "the finished document must exist" +pass "the generation step repairs the field defect" + +# And the repaired output really is conforming, checked independently. +out=$("$VERIFY" --pages 5 "$OUT" 2>&1); rc=$? +expect_code 0 "$rc" "the published document must pass the gate on its own" +pass "the published document is independently verified as conforming" + +# Content is preserved, not truncated: the page count survives the repair. This +# is the guard against "fixing" a document by dropping what would not parse. +out=$("$VERIFY" "$OUT" 2>&1) +assert_contains "$out" "5 pages" "the repair must preserve every page" +pass "the repair preserves page count" + +# Merging several parts is the real assembly case, and it must stay conforming. +MERGED="$TMP_ROOT/merged.pdf" +PART_A="$TMP_ROOT/part-a.pdf" +PART_B="$TMP_ROOT/part-b.pdf" +make_good "$PART_A" 2 +make_good "$PART_B" 3 +out=$("$FINISH" --pages 5 "$MERGED" "$PART_A" "$PART_B" 2>&1); rc=$? +expect_code 0 "$rc" "merging parts must produce a conforming document" +out=$("$VERIFY" --pages 5 "$MERGED" 2>&1); rc=$? +expect_code 0 "$rc" "the merged document must be conforming with all pages" +pass "multi-part assembly stays conforming" + +# --- nothing non-conforming can reach the destination ----------------------- + +# The gate refuses (here via a page-count mismatch, the one rejection a caller +# can force deterministically), and the previous deliverable must survive +# untouched rather than being replaced by a file that failed. +KEEP="$TMP_ROOT/keep.pdf" +cp "$GOOD" "$KEEP" +before=$(cksum < "$KEEP") +out=$("$FINISH" --pages 99 "$KEEP" "$BROKEN" 2>&1); rc=$? +expect_code 1 "$rc" "a rejected result must fail the generation step" +assert_contains "$out" "nothing published" "a rejected result must say nothing was published" +after=$(cksum < "$KEEP") +[ "$before" = "$after" ] || fail "a rejected result must not overwrite the previous document" +pass "a rejected result never reaches the destination" + +# A destination that did not exist must still not exist after a rejection. +NEVER="$TMP_ROOT/never.pdf" +"$FINISH" --pages 99 "$NEVER" "$BROKEN" >/dev/null 2>&1 +assert_absent "$NEVER" "a rejected result must not create the destination" +pass "a rejected result creates no destination file" + +# No temporary artifacts are left beside the destination. +leftovers=$(find "$TMP_ROOT" -maxdepth 1 -name '.fm-pdf-finish.*' -print -quit) +[ -z "$leftovers" ] || fail "the generation step left a temporary file behind: $leftovers" +pass "no temporary artifacts are left behind" + +# When the gate itself cannot run, the step publishes nothing. +GATED="$TMP_ROOT/gated.pdf" +out=$(FM_PDF_GS=fm-no-such-pdf-reader "$FINISH" "$GATED" "$GOOD" 2>&1); rc=$? +expect_code 3 "$rc" "an unrunnable gate must not publish" +assert_absent "$GATED" "an unrunnable gate must not create the destination" +pass "an unrunnable gate publishes nothing" + +# --- the original field defect, when poppler is available ------------------- + +if command -v pdfseparate >/dev/null 2>&1 && command -v pdfunite >/dev/null 2>&1; then + SPLIT_DIR="$TMP_ROOT/split" + mkdir -p "$SPLIT_DIR" + if pdfseparate "$GOOD" "$SPLIT_DIR/p-%d.pdf" >/dev/null 2>&1 && + pdfunite "$SPLIT_DIR"/p-*.pdf "$TMP_ROOT/united.pdf" >/dev/null 2>&1; then + out=$("$VERIFY" "$TMP_ROOT/united.pdf" 2>&1); rc=$? + expect_code 1 "$rc" "the real pdfunite output must be rejected" + pass "the original pdfunite defect is caught" + + out=$("$FINISH" --pages 5 "$TMP_ROOT/repaired.pdf" "$TMP_ROOT/united.pdf" 2>&1); rc=$? + expect_code 0 "$rc" "the generation step must repair real pdfunite output" + pass "the original pdfunite defect is repaired" + else + echo "ok - skip: poppler split/unite unavailable for the field-defect check" + fi +else + echo "ok - skip: poppler not found, field-defect check not run" +fi + +echo "ok - fm-pdf-output" From ad35160468f255be1114811838c13f7fd0cf6626 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:00:06 +0000 Subject: [PATCH 2/4] no-mistakes(review): fix(pdf): correct exit status, reader-failure class, and gate skips --- .github/workflows/ci.yml | 17 +++++++ bin/fm-pdf-finish.sh | 61 ++++++++++++++----------- bin/fm-pdf-lib.sh | 60 +++++++++++++++++++++++++ bin/fm-pdf-verify.sh | 89 +++++++++++++++++++++---------------- docs/pdf-output.md | 24 +++++++++- docs/scripts.md | 1 + tests/fm-pdf-output.test.sh | 75 ++++++++++++++++++++++++++----- 7 files changed, 251 insertions(+), 76 deletions(-) create mode 100755 bin/fm-pdf-lib.sh 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/bin/fm-pdf-finish.sh b/bin/fm-pdf-finish.sh index f3422edfeb..80341efd71 100755 --- a/bin/fm-pdf-finish.sh +++ b/bin/fm-pdf-finish.sh @@ -36,6 +36,13 @@ 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' @@ -52,27 +59,16 @@ Exit codes: 0 published, 1 rejected, 2 usage, 3 could not verify. EOF } -EXPECT_PAGES="" -QUIET=0 - -while [ "$#" -gt 0 ]; do - case "$1" in - --pages) - [ "$#" -ge 2 ] || { echo "fm-pdf-finish: --pages needs a value" >&2; usage; exit 2; } - EXPECT_PAGES=$2 - case "$EXPECT_PAGES" in - ''|*[!0-9]*) echo "fm-pdf-finish: --pages must be a positive integer, got '$EXPECT_PAGES'" >&2; exit 2 ;; - esac - [ "$EXPECT_PAGES" -gt 0 ] || { echo "fm-pdf-finish: --pages must be a positive integer" >&2; exit 2; } - shift 2 - ;; - --quiet) QUIET=1; shift ;; - -h|--help) usage; exit 0 ;; - --) shift; break ;; - -*) echo "fm-pdf-finish: unknown option '$1'" >&2; usage; exit 2 ;; - *) break ;; - esac -done +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; } @@ -86,7 +82,7 @@ done [ -x "$VERIFY" ] || { echo "fm-pdf-finish: CANNOT VERIFY - gate script missing at $VERIFY" >&2; exit 3; } -GS_BIN=${FM_PDF_GS:-gs} +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 @@ -131,13 +127,18 @@ fi VERIFY_ARGS=() [ -n "$EXPECT_PAGES" ] && VERIFY_ARGS+=(--pages "$EXPECT_PAGES") -VERIFY_ARGS+=(--quiet) -"$VERIFY" "${VERIFY_ARGS[@]}" "$TMP_OUT" +# 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 @@ -155,5 +156,15 @@ trap - EXIT chmod 644 "$OUT" 2>/dev/null || true if [ "$QUIET" -eq 0 ]; then - "$VERIFY" "$OUT" + # 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 index d58532ec48..13bee805e7 100755 --- a/bin/fm-pdf-verify.sh +++ b/bin/fm-pdf-verify.sh @@ -22,6 +22,14 @@ # 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 @@ -32,6 +40,15 @@ # 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] [...] @@ -46,31 +63,20 @@ Exit codes: 0 all conform, 1 rejected, 2 usage, 3 could not verify. EOF } -EXPECT_PAGES="" -QUIET=0 - -while [ "$#" -gt 0 ]; do - case "$1" in - --pages) - [ "$#" -ge 2 ] || { echo "fm-pdf-verify: --pages needs a value" >&2; usage; exit 2; } - EXPECT_PAGES=$2 - case "$EXPECT_PAGES" in - ''|*[!0-9]*) echo "fm-pdf-verify: --pages must be a positive integer, got '$EXPECT_PAGES'" >&2; exit 2 ;; - esac - [ "$EXPECT_PAGES" -gt 0 ] || { echo "fm-pdf-verify: --pages must be a positive integer" >&2; exit 2; } - shift 2 - ;; - --quiet) QUIET=1; shift ;; - -h|--help) usage; exit 0 ;; - --) shift; break ;; - -*) echo "fm-pdf-verify: unknown option '$1'" >&2; usage; exit 2 ;; - *) break ;; - esac -done +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:-gs} +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". @@ -82,7 +88,10 @@ fi REASON="" # read_pdf - interpret every page and echo the reader's combined output. -# Deliberately runs without -q so the conformance diagnostics survive. +# 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() { @@ -91,12 +100,12 @@ read_pdf() { /*) : ;; *) abs="$PWD/$abs" ;; esac - "$GS_BIN" -o /dev/null -sDEVICE=nullpage -dNOPAUSE -dBATCH "$abs" 2>&1 + "$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 + local file=$1 out rc pages last_page complaint if [ ! -f "$file" ]; then REASON="not a file" @@ -114,19 +123,12 @@ check_file() { out=$(read_pdf "$file") rc=$? - # A reader that failed to run at all is an unverifiable result, not a pass. - if [ "$rc" -ne 0 ]; then - REASON="the PDF reader failed (exit $rc) - file unreadable or reader broken" - printf '%s\n' "$out" >&2 - return 1 - fi - - # The verdict comes first, 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. + # 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" @@ -150,6 +152,17 @@ check_file() { ;; 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 diff --git a/docs/pdf-output.md b/docs/pdf-output.md index ca67a537e2..c35ab5c78f 100644 --- a/docs/pdf-output.md +++ b/docs/pdf-output.md @@ -5,6 +5,7 @@ On 2026-07-26 one of those documents reached the captain unopenable, and this pa 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 @@ -85,9 +86,12 @@ Assembly no longer goes through `pdfunite`. ``` $ bin/fm-pdf-finish.sh --pages 27 handover.pdf rendered.pdf -fm-pdf-verify: OK handover.pdf (27 pages, conforming) +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. @@ -123,16 +127,32 @@ The last one is the reason the script asserts that the reader announced its 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, which includes a non-PDF file and a truncated one, because the real reader names those. +`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 failing reader each refuse rather than pass, 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. +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. 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'`, and the suite itself hard-fails rather than skipping when `CI` is set. +A developer run without Ghostscript may still skip. + ## 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. diff --git a/docs/scripts.md b/docs/scripts.md index 128379b587..a998f33a64 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -64,6 +64,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `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 index f9a892f800..54d2edb2f7 100755 --- a/tests/fm-pdf-output.test.sh +++ b/tests/fm-pdf-output.test.sh @@ -22,7 +22,18 @@ VERIFY="$ROOT/bin/fm-pdf-verify.sh" FINISH="$ROOT/bin/fm-pdf-finish.sh" fm_test_tmproot TMP_ROOT fm-pdf-output -command -v gs >/dev/null 2>&1 || { echo "skip: ghostscript not found"; exit 0; } +# Ghostscript is this suite's only proof of the conformance gate, so CI must +# never report green without it. The portable-serial lane installs it, requires +# it, and passes --fail-on-gate-skip for this exact token; the hard failure here +# is the second lock, so a lane that lost those steps still cannot go quiet. A +# developer run without Ghostscript may still skip. +if ! command -v gs >/dev/null 2>&1; then + if [ -n "${CI:-}" ]; then + fail "ghostscript is required in CI: this suite is the only proof of the PDF conformance gate" + fi + echo "skip: ghostscript not found" + exit 0 +fi # --- fixtures --------------------------------------------------------------- @@ -64,11 +75,6 @@ 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" -# The reader's own diagnosis reaches the caller, so the failure is actionable -# rather than a bare exit code. -assert_contains "$out" "incorrect xref size" "the reader's diagnosis must be surfaced" -pass "reader diagnosis is surfaced to the caller" - # --- direction 2: a conforming file PASSES ---------------------------------- out=$("$VERIFY" "$GOOD" 2>&1); rc=$? @@ -107,12 +113,35 @@ 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 dies mid-document must not be mistaken for a clean read. -printf '#!/bin/sh\nexit 4\n' > "$STUB_BIN/gs" +# 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 1 "$rc" "a failing reader must not report success" -assert_contains "$out" "REJECTED" "a failing reader must reject the file" -pass "failing reader fails closed" +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=$? @@ -133,6 +162,15 @@ expect_code 1 "$rc" "a truncated file must be rejected" assert_contains "$out" "REJECTED" "a truncated file must be reported as rejected" pass "a truncated file is rejected, not called unverifiable" +# A file that is not a PDF at all makes the real reader exit non-zero while +# naming the failure, so it stays a rejection rather than sliding into the +# "could not check" class the broken-reader rule introduces. +printf 'this is not a PDF at all\n' > "$TMP_ROOT/not-a-pdf.pdf" +out=$("$VERIFY" "$TMP_ROOT/not-a-pdf.pdf" 2>&1); rc=$? +expect_code 1 "$rc" "a non-PDF file must be rejected" +assert_contains "$out" "REJECTED" "a non-PDF file must be reported as rejected" +pass "a non-PDF file is rejected, not called unverifiable" + # A batch is only as good as its worst file. out=$("$VERIFY" "$GOOD" "$BROKEN" 2>&1); rc=$? expect_code 1 "$rc" "a batch containing a bad file must fail" @@ -146,6 +184,21 @@ expect_code 0 "$rc" "finishing a broken input must produce a conforming document assert_present "$OUT" "the finished document must exist" pass "the generation step repairs the field defect" +# Exit 0 means published, and the success line must come from the step that +# published rather than from a second reader pass whose status would leak into +# the caller's - which would report "nothing published" about a live file. +assert_contains "$out" "fm-pdf-finish: published $OUT" "publication must be announced by the publishing step" +assert_contains "$out" "5 pages" "the success line must carry the page count the reader counted" +pass "a published document reports success and exits 0" + +# --quiet still publishes, still exits 0, and still says nothing on success. +QUIET_OUT="$TMP_ROOT/quiet.pdf" +out=$("$FINISH" --quiet --pages 5 "$QUIET_OUT" "$BROKEN" 2>&1); rc=$? +expect_code 0 "$rc" "--quiet must publish and exit 0" +[ -z "$out" ] || fail "--quiet must print nothing on success, got: $out" +assert_present "$QUIET_OUT" "--quiet must still publish the document" +pass "--quiet publishes silently and exits 0" + # And the repaired output really is conforming, checked independently. out=$("$VERIFY" --pages 5 "$OUT" 2>&1); rc=$? expect_code 0 "$rc" "the published document must pass the gate on its own" From bbc5e790e25cdfe5fd2f80b5144f1ccfe5d21989 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:11:44 +0000 Subject: [PATCH 3/4] no-mistakes(review): fix(pdf): map shared lib for changed-test selection, unpin refusal class --- docs/pdf-output.md | 7 ++-- tests/fm-pdf-output.test.sh | 68 ++++++++++++++++++++++--------------- 2 files changed, 46 insertions(+), 29 deletions(-) diff --git a/docs/pdf-output.md b/docs/pdf-output.md index c35ab5c78f..bd37356d83 100644 --- a/docs/pdf-output.md +++ b/docs/pdf-output.md @@ -146,12 +146,15 @@ When poppler is present it additionally asserts against genuine `pdfunite` outpu 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'`, and the suite itself hard-fails rather than skipping when `CI` is set. -A developer run without Ghostscript may still skip. +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 diff --git a/tests/fm-pdf-output.test.sh b/tests/fm-pdf-output.test.sh index 54d2edb2f7..d9793fd79b 100755 --- a/tests/fm-pdf-output.test.sh +++ b/tests/fm-pdf-output.test.sh @@ -23,17 +23,22 @@ 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 installs it, requires -# it, and passes --fail-on-gate-skip for this exact token; the hard failure here -# is the second lock, so a lane that lost those steps still cannot go quiet. A -# developer run without Ghostscript may still skip. -if ! command -v gs >/dev/null 2>&1; then - if [ -n "${CI:-}" ]; then - fail "ghostscript is required in CI: this suite is the only proof of the PDF conformance gate" - fi - echo "skip: ghostscript not found" - exit 0 -fi +# 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 --------------------------------------------------------------- @@ -152,24 +157,33 @@ 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" -# A file the reader positively condemns is REJECTED, not merely unverifiable, -# even when the damage stops it from reaching a page report. Truncation is the -# ordinary way that happens, and calling it "could not check" would understate a -# file that is definitely bad. +# 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