nigig-org/tools/check-pdf-external-readers.sh
andodeki d4e3e9a443
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Successful in 21s
doc-engine / coverage (push) Successful in 31s
doc-engine / consumer (push) Failing after 16m57s
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
nigig-map / test (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
feat(pdf): encryption on save — AES-128 and AES-256 (Phase 6, part one)
ADR 0024. This reverses ADR 0005's "never write encryption", and the
reason it is safe to reverse is that the facts changed underneath it.

A crate that only reads cannot produce weak ciphertext, so refusing to
write any was free. Now that Phase 4 creates documents and Phase 5 edits
them, the refusal does something worse than protect nobody: open a
password-protected file, change one annotation, save, and the output is
plaintext. No error, no warning — the protection is silently dropped. That
is this project's recurring failure mode in the one place where the
consequence is a breach.

The principle survives in a narrower form: no hand-rolled crypto, and no
weak cipher offered as an option. RC4 stays readable because files use it
and is not writable — EncryptionAlgorithm has no RC4 variant, so the
refusal is a type, not a runtime check someone can route around.

The encryptor is the literal inverse of the decryptor and imports its
primitives rather than restating them; two implementations of one algorithm
drift, and here they drift towards "decrypts to garbage". Every unit test
round-trips through the existing Decryptor.

Encryption sits at one choke point: PdfWriter holds the Encryptor and
write_object_at encrypts everything passing through. Not per call site —
there are twenty-two of those in PdfDocBuilder, and one stream written in
the clear inside an encrypted document is not a partial failure, it is a
leak that no reader will report because the file is otherwise valid. The
/Encrypt dictionary is the single deliberate exemption: it holds the salts
a reader needs before it has a key, so encrypting it bricks the file.

Verified against implementations we share no code with, now gated in CI:

  ok    qpdf opens it with the password
  ok    it really is AES-256
  ok    the wrong password is refused
  ok    poppler decrypts the content
  ok    no plaintext in the encrypted file

Four mutations, all killed — two only after the tests were strengthened,
and both misses are the interesting part:

  A fixed IV survived two_saves_of_one_document_are_not_byte_identical,
  because the AES-256 file key is fresh per save and that alone makes the
  output differ. The property actually needed is narrower: one encryptor,
  identical plaintext, different bytes. In CBC a repeated IV under one key
  leaks that two plaintexts are equal.

  A wrong /Length survived because our own reader recovers by scanning for
  endstream — a robustness fix from ADR 0023. An independent reader that
  trusts /Length reads a truncated stream and decrypts garbage. A lenient
  reader hides a broken writer, which is why the external gate exists.

The /Length test itself had a bug first: it searched a from_utf8_lossy view
and reported a stream declaring 80 bytes holding 156. Ciphertext is not
UTF-8; the replacement characters shifted every offset.

Unencrypted output stays byte-reproducible; encrypted output cannot be, and
a test asserts that loss rather than leaving it implicit.

pdf: 1220 passed (was 1187). pdf-ui: green. Coverage 88.21%,
encrypt_write.rs at 96.5%.

Signing is NOT started. It needs the trust-anchor decision ADR 0010
deferred: VerificationStatus::Valid is unreachable by construction, and
making sign -> verify pass is a policy change, not an implementation
detail. The plan's Phase 6 status now says so.
2026-08-18 07:27:45 +00:00

192 lines
7.2 KiB
Bash
Executable file

#!/usr/bin/env bash
# Phase 4 exit criterion: generated PDFs open cleanly in external viewers.
#
# No test inside this repository can assert that. Every reader here is an
# implementation nigig-pdf shares no code with — qpdf for structure, poppler
# for semantics — so a file that satisfies both is not merely self-consistent
# with our own parser, which is the failure this exists to rule out.
#
# The distinction matters. `pdf-document`'s round-trip tests prove we can
# read what we wrote; they cannot prove anyone *else* can. A writer and a
# reader that share a bug agree perfectly.
#
# Usage:
# ./tools/check-pdf-external-readers.sh
# KEEP_PDF=1 ./tools/check-pdf-external-readers.sh # keep the artefacts
#
# Requires qpdf and poppler-utils. Skips with a clear message when absent,
# rather than passing vacuously:
#
# sudo apt-get install -y qpdf poppler-utils
set -Eeuo pipefail
IFS=$'\n\t'
ROOT="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
KEEP_PDF="${KEEP_PDF:-0}"
missing=0
for tool in qpdf pdfinfo pdftotext pdffonts; do
command -v "$tool" >/dev/null 2>&1 || { echo "missing: $tool" >&2; missing=1; }
done
if [ "$missing" = 1 ]; then
echo "install qpdf and poppler-utils to run this check" >&2
exit 2
fi
WORK="$(mktemp -d "${TMPDIR:-/tmp}/pdf-external.XXXXXXXX")"
cleanup() {
local status=$?
if [ "$KEEP_PDF" = "1" ]; then
printf 'kept artefacts in %s\n' "$WORK" >&2
else
rm -rf -- "$WORK"
fi
exit "$status"
}
trap cleanup EXIT HUP INT TERM
PDF="$WORK/sample.pdf"
echo "generating the Phase 4 sample"
ENCRYPTED="$WORK/encrypted.pdf"
cargo run --quiet --manifest-path "$ROOT/crates/apps/pdf/pdf-graphics/Cargo.toml" \
--example generate_sample -- "$PDF" "$ENCRYPTED"
fail=0
note() { printf ' %s\n' "$1"; }
check() {
local label="$1" haystack="$2" needle="$3"
if printf '%s' "$haystack" | grep -qF -- "$needle"; then
note "ok $label"
else
note "FAIL $label (expected to find: $needle)"
fail=1
fi
}
echo
echo "== qpdf: structural validity =="
# `--check` reports syntax and stream-encoding errors. A warning is not a
# pass: qpdf warns where it had to recover, and recovering is exactly what a
# stricter viewer will refuse to do.
if qpdf_out="$(qpdf --check "$PDF" 2>&1)"; then
if printf '%s' "$qpdf_out" | grep -q 'WARNING'; then
note "FAIL qpdf reported warnings:"
printf '%s\n' "$qpdf_out" | sed 's/^/ /'
fail=1
else
note "ok no syntax or stream encoding errors"
fi
else
note "FAIL qpdf --check rejected the file:"
printf '%s\n' "$qpdf_out" | sed 's/^/ /'
fail=1
fi
echo
echo "== poppler: document semantics =="
info="$(pdfinfo "$PDF" 2>&1)"
check "title survives" "$info" "Nigig PDF Phase 4 sample"
check "author survives" "$info" "nigig"
check "keywords survive" "$info" "phase4"
# Three pages: text, the form, and the CFF sample. The count is asserted
# exactly rather than as "more than one" — a page silently dropped by a
# writer bug is precisely the kind of thing this catches, and it caught the
# CFF page being added.
check "all three pages are present" "$info" "Pages: 3"
check "the AcroForm is recognised" "$info" "AcroForm"
echo
echo "== poppler: text extraction =="
# Text extraction is the strongest single signal available here: it only
# works if the font, its encoding and the content stream all agree. The
# em-dash is deliberate — it is outside ASCII, so it exercises the embedded
# subset's cmap rather than a lucky byte-for-byte match.
text="$(pdftotext "$PDF" - 2>&1)"
check "embedded-subset heading extracts" "$text" "Nigig PDF — Phase 4"
check "base-14 text extracts" "$text" "base-14 Helvetica"
check "form field values extract" "$text" "Ada Lovelace"
# Text set in the whole-embedded CFF font. This only extracts if the CFF
# program loaded, /Identity-H addressed its glyphs, and /ToUnicode mapped
# them back — the whole embedding path in one assertion.
check "CFF-set text extracts" "$text" "Hello CFF 123"
echo
echo "== poppler: embedded fonts =="
# `pdffonts` is the only check here that inspects a font *program* rather
# than the file structure. It is what proves a CFF font was written with
# the right /FontFile key and descendant subtype: get either wrong and the
# font either fails to load or loads as the wrong type, both of which show
# up in this table rather than in qpdf --check.
fonts="$(pdffonts "$PDF" 2>&1)"
check "the subset TrueType font is embedded" "$fonts" "CID TrueType"
check "the whole CFF font is embedded" "$fonts" "CID Type 0C"
# `emb yes` for both, and `sub` distinguishing them: the TrueType one is a
# real subset, the CFF one is the whole program. That column is the visible
# consequence of `SubsetFont::subsetted`.
if printf '%s' "$fonts" | grep -qE 'CID Type 0C.*[[:space:]]yes[[:space:]]+no[[:space:]]'; then
note "ok the CFF font is embedded whole, not claiming to be subset"
else
note "FAIL the CFF font's embedded/subset columns are wrong"
printf '%s\n' "$fonts" | sed 's/^/ /'
fail=1
fi
echo
echo "== qpdf: attachments =="
attach="$(qpdf --list-attachments --verbose "$PDF" 2>&1)"
check "the attachment is listed" "$attach" "readme.txt"
check "its description survives" "$attach" "About this file"
echo
echo "== catalogue features =="
# Read as bytes: these are structural keys, and their presence is what a
# viewer keys off. `/Metadata` is checked by pdfinfo above where set.
for key in /Outlines /Names /EmbeddedFiles /PageLabels /Dests /PageMode \
/ViewerPreferences /AcroForm; do
if grep -qa -- "$key" "$PDF"; then
note "ok $key present"
else
note "FAIL $key missing"
fail=1
fi
done
echo
echo "== encrypted document (ADR 0024) =="
# The point of these three: a file we encrypt must be openable by an
# implementation sharing no code with ours, must refuse the wrong
# password, and must not carry its plaintext on disk.
enc_check="$(qpdf --password=hunter2 --check "$ENCRYPTED" 2>&1 || true)"
check "qpdf opens it with the password" "$enc_check" "No syntax or stream encoding errors"
check "it really is AES-256" "$enc_check" "AESv3"
if qpdf --password=WRONGPASSWORD --check "$ENCRYPTED" >/dev/null 2>&1; then
note "FAIL the wrong password opened the document"
fail=1
else
note "ok the wrong password is refused"
fi
enc_text="$(pdftotext -upw hunter2 "$ENCRYPTED" - 2>/dev/null || true)"
check "poppler decrypts the content" "$enc_text" "ENCRYPTEDSAMPLE"
# The plaintext must not be in the bytes. grep -a because the file is
# binary; a match here is a leak, so the sense of the test is inverted.
if grep -qa "ENCRYPTEDSAMPLE" "$ENCRYPTED"; then
note "FAIL plaintext found in the encrypted file"
fail=1
else
note "ok no plaintext in the encrypted file"
fi
echo
if [ "$fail" = 0 ]; then
echo "all external-reader checks passed"
else
echo "external-reader checks FAILED" >&2
echo "The file may still be structurally valid: a PDF that qpdf accepts" >&2
echo "and poppler renders incorrectly is the interesting case, and it is" >&2
echo "what these semantic checks are for." >&2
exit 1
fi