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
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.
192 lines
7.2 KiB
Bash
Executable file
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
|