Skip to content

A form can be written, not only read - #19

Merged
tannevaled merged 1 commit into
mainfrom
a-form-can-be-written-not-only-read
Oct 22, 2026
Merged

tannevaled merged 1 commit into
mainfrom
a-form-can-be-written-not-only-read

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

The writing half of the package: FieldType, Flags, Dict, Check and CheckAll say what a field has to be in the file for Read to read back the field that was asked for.

This is what BentoPDF's form-creator needs. The fleet had every other piece already — ops fills forms, forms draws appearances, render draws widgets — and only the direction that creates a field was missing.

One definition of each flag

It shares the reader's constants rather than restating them. The bits are the one thing a second copy gets subtly wrong — bit 22 (MultiSelect) and bit 25 (Comb) are the two the tables make easy to miscount — and a writer that disagreed with the reader would produce a form this package itself reads as something else, which no test of either half alone could see.

A flag that does not belong to the kind is dropped, not written. Multiline is bit 13 of a text field and NoToggleToOff is bit 15 of a button, so writing the caller's flags blindly turns one reused struct into a checkbox that cannot be unticked.

What Check refuses

Each of these makes a file that opens and is broken, which is the worst outcome available:

  • a field with no name — a field is addressed by its name;
  • a choice field with no rows;
  • a checkbox whose on-state is Off, the name the format reserves for not chosen;
  • two widgets of one radio group in the same state, so nothing can say which was pressed;
  • a comb field with no MaxLen — a comb field is MaxLen cells;
  • an empty rectangle, a page before the first, an alignment that is not 0/1/2.

CheckAll adds the one rule about the set: two fields of one name. The reader keys fields by name, so the second shadows the first — the document carries both widgets, the form offers one field, and whoever fills it in cannot see that half of what they typed went nowhere.

Fuzzing found a real defect in its first run

decodeText reads a string with no mark at the front as PDFDocEncoding — one rune per byte. So writing Go's UTF-8 bytes turned café into café, in a file that opens and looks perfectly well formed. Every accented or non-Latin field name in the fleet would have had the same fate.

Non-ASCII text is now written as UTF-16 behind the byte-order mark, which is what the reader decodes. Text that is not valid UTF-8 is refused, because it cannot be stored and read back under the name it was given — refusing is honest, writing something that silently differs is not.

Controls

The oracle throughout is the reading half, which has a 69-form, 13 854-field corpus behind it — not this half's own expectations. A field written as something the format spells differently comes back as the wrong kind rather than as a passing test.

coverage 100% of statements, 0 uncovered blocks — counted on the profile, since -func rounds
mutations caught a combo box written as a text field; MultiSelect written as bit 25 instead of bit 22; Required dropped
fuzz 4 542 273 executions after the fix, clean

govulncheck reports 4 standard-library findings, all net/http fixed in go1.27.2, against a local toolchain of go1.27.1. CI builds with 1.27.2, so the published artefact does not carry them — the finding is the toolchain the scan ran under, not this code.

Next

ops gains AddFields and pdfops a verb, which is where the widget references and page links get allocated; then form-creator can be claimed in the parity table.

🤖 Generated with Claude Code

This is the writing half of the package: FieldType, Flags, Dict, Check and
CheckAll say what a field has to be in the file for Read to read back the
field that was asked for. It is what BentoPDF's form-creator needs, and the
fleet had every other piece of it already.

It shares the reader's flag constants rather than restating them. The bits are
the one thing a second copy gets subtly wrong -- bit 22 and bit 25 are the two
the tables make easy to miscount -- and a writer that disagreed with the
reader would produce a form this package itself reads as something else, which
no test of either half alone could see.

A flag that does not belong to the kind is dropped rather than written.
Multiline is bit 13 of a text field and NoToggleToOff is bit 15 of a button,
so writing the caller's flags blindly turns one reused struct into a checkbox
that cannot be unticked.

Check refuses what makes a file that OPENS and is broken: a field with no
name, a choice field with no rows, a checkbox whose on-state is the reserved
name Off, two widgets of one radio group in the same state, a comb field with
no MaxLen, an empty rectangle, a page before the first. CheckAll adds the one
rule about the set: two fields of one name, which the reader keys by name, so
the second silently shadows the first.

Fuzzing the round trip found a real defect in the first run. decodeText reads
a string with no mark at the front as PDFDocEncoding -- one rune per BYTE --
so writing Go's UTF-8 bytes turned "cafe" with an acute accent into mojibake,
in a file that opens and looks well formed. Every accented or non-Latin field
name in the fleet would have had the same fate. Text is now written as UTF-16
behind the byte-order mark when it is not ASCII, and text that is not valid
UTF-8 is refused, since it cannot be stored under the name it was given.

The oracle throughout is the reading half, which has a 69-form corpus behind
it, rather than this half's own expectations.

Measured: 100% of statements and 0 uncovered blocks, counted on the profile
rather than the rounded percentage. Three mutations caught (a combo box
written as a text field; MultiSelect written as bit 25 instead of bit 22;
Required dropped). 4.5 million fuzz executions after the fix, clean.

govulncheck reports 4 standard-library findings, all of them net/http fixed
in go1.27.2 against a local toolchain of go1.27.1; CI builds with 1.27.2, so
the published artefact does not carry them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 1397913 into main Oct 22, 2026
1 check passed
@tannevaled
tannevaled deleted the a-form-can-be-written-not-only-read branch October 22, 2026 07:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant