From 00227906c1196ed6437cebc54b607a55aa041f0c Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 22 Oct 2026 09:47:52 +0200 Subject: [PATCH] A form can be written, not only read 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 --- README.md | 45 ++++++- build.go | 271 +++++++++++++++++++++++++++++++++++++ build_fuzz_test.go | 60 +++++++++ build_roundtrip_test.go | 291 ++++++++++++++++++++++++++++++++++++++++ build_security_test.go | 175 ++++++++++++++++++++++++ 5 files changed, 840 insertions(+), 2 deletions(-) create mode 100644 build.go create mode 100644 build_fuzz_test.go create mode 100644 build_roundtrip_test.go create mode 100644 build_security_test.go diff --git a/README.md b/README.md index 7e8e3ff..3e60e1c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # forms -Read, fill and draw a PDF's **AcroForm** — the boxes somebody is meant to type -into, the squares they tick, the lists they choose from. +Read, fill, draw and **build** a PDF's **AcroForm** — the boxes somebody is +meant to type into, the squares they tick, the lists they choose from. Pure Go, `CGO_ENABLED=0`, standard library plus the rest of [go-pdfkit](https://github.com/go-pdfkit). Builds for `js/wasm` and for every @@ -22,6 +22,47 @@ f.Fill("f1_02", "Wolfgang Amadeus Mozart") f.Fill("c1_1", "yes") ``` +## Writing a form + +The other direction: `FieldType`, `Flags`, `Dict`, `Check` and `CheckAll` say +what a field has to be in the file for the reader above to read back the field +you asked for. Whoever is writing the document adds the two things only a +writer can know — the widget references under `Kids`, and the page each widget +sits on. + +```go +field := forms.Field{ + Name: "size", Kind: forms.ComboBox, + Options: []forms.Option{{Value: "s", Label: "Small"}, {Value: "l", Label: "Large"}}, + Widgets: []forms.Widget{{Page: 1, Rect: [4]float64{72, 700, 300, 720}}}, +} +if err := forms.CheckAll([]forms.Field{field}); err != nil { + // A field with no name, a choice with no rows, a comb with no MaxLen, an + // empty rectangle: each one makes a file that OPENS and is broken. +} +dict := forms.Dict(field) // FT, T, Ff, Q, MaxLen, Opt, V +``` + +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. The round-trip tests write every kind and read +it back through `Read`. + +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 passing +one struct around with `Multiline` left set would otherwise make a checkbox that +cannot be unticked. + +**Text is encoded as the reader decodes it.** `decodeText` reads a string with +no mark at the front as PDFDocEncoding — one rune per *byte* — so writing Go's +UTF-8 bytes turns `café` into `café` in a file that opens and looks perfectly +well formed. Non-ASCII text is written as UTF-16 behind the byte-order mark; +text that is not valid UTF-8 is refused, because it cannot be stored and read +back under the name it was given. Found by fuzzing the round trip, not by +reading the code. + ## What is awkward about forms A field's value is not what gets drawn. What gets drawn is a little content diff --git a/build.go b/build.go new file mode 100644 index 0000000..6e3b403 --- /dev/null +++ b/build.go @@ -0,0 +1,271 @@ +package forms + +import ( + "fmt" + "unicode/utf16" + "unicode/utf8" + + "github.com/go-pdfkit/reader" +) + +// text encodes a string the way decodeText reads one back. +// +// ⛔ WRITING THE GO BYTES IS WRONG FOR EVERY NAME THAT IS NOT ASCII, and +// silently so. decodeText treats a string without the mark at the front as +// PDFDocEncoding and turns each BYTE into a rune, so "café" — four runes, five +// UTF-8 bytes — is read back as "café". Found by fuzzing the round trip, on a +// name that could not be found after being written; every accented or +// non-Latin field name in the fleet would have had the same fate, and the file +// would have opened and looked fine. +// +// So: plain bytes while the string is ASCII, which the reader returns +// unchanged, and UTF-16 big-endian behind the mark otherwise. +func text(s string) reader.String { + ascii := true + for i := 0; i < len(s); i++ { + if s[i] >= 0x80 { + ascii = false + break + } + } + if ascii { + return reader.String(s) + } + out := []byte{0xFE, 0xFF} + for _, u := range utf16.Encode([]rune(s)) { + out = append(out, byte(u>>8), byte(u)) + } + return reader.String(out) +} + +// This file is the writing half: what a field's type and flags must be for the +// reader in form.go to read back the field that was asked for. +// +// ⛔ IT SHARES THE READER'S CONSTANTS DELIBERATELY. The flag bits are the one +// thing here a second copy would get 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. FieldType and Flags are the +// inverse of kindOf and makeField, and TestEveryFieldKindSurvivesARoundTrip +// holds them to it. + +// FieldType is the value of a field's FT entry: the four types the format has, +// as opposed to the seven kinds a person thinks in. +// +// A push button, a radio button and a checkbox are all "Btn", and a drop-down +// and a list box are both "Ch"; the flags tell them apart, which is why Flags +// and FieldType have to be written together. +func FieldType(k Kind) reader.Name { + switch k { + case Checkbox, Radio, PushButton: + return "Btn" + case ComboBox, ListBox: + return "Ch" + case Signature: + return "Sig" + } + return "Tx" +} + +// Flags is the value of a field's Ff entry for the field given: the kind's own +// bits, plus the ones the caller asked for that the kind allows. +// +// ⛔ A FLAG THAT DOES NOT BELONG TO THE KIND IS DROPPED, not written. Multiline +// on a checkbox is bit 13 of a button, which the format reads as +// NoToggleToOff — so writing "the flags the caller set" would turn a harmless +// mistake into a checkbox that cannot be unticked. +func Flags(f Field) int64 { + var out int64 + if f.ReadOnly { + out |= flagReadOnly + } + if f.Required { + out |= flagRequired + } + if f.NoExport { + out |= flagNoExport + } + switch f.Kind { + case Text: + if f.Multiline { + out |= flagMultiline + } + if f.Password { + out |= flagPassword + } + if f.Comb { + out |= flagComb + } + case PushButton: + out |= flagPushButton + case Radio: + out |= flagRadio + case ComboBox: + out |= flagCombo + if f.Editable { + out |= flagEdit + } + if f.Sorted { + out |= flagSort + } + case ListBox: + if f.MultiSelect { + out |= flagMultiSelect + } + if f.Sorted { + out |= flagSort + } + } + return out +} + +// Check says why a field cannot be written, or nothing. +// +// ⛔ IT REFUSES BEFORE A DOCUMENT IS BUILT rather than after. A field with no +// name cannot be filled, addressed or read back — the reader keys fields by +// name — and a choice field with no options is a control a person cannot use. +// Both produce a file that opens and is simply broken, which is the worst +// outcome available here. +func Check(f Field) error { + if f.Name == "" { + return fmt.Errorf("a field has no name, and a field is addressed by its name") + } + // ⛔ TEXT THAT IS NOT VALID UTF-8 CANNOT BE WRITTEN AND READ BACK. A PDF + // text string is either PDFDocEncoding or UTF-16, and encoding invalid + // bytes to UTF-16 replaces each with U+FFFD — so the field would be stored + // under a name nobody can ask for. Refusing is the honest answer; writing + // one that silently differs is not. + for what, s := range map[string]string{"name": f.Name, "value": f.Value} { + if !utf8.ValidString(s) { + return fmt.Errorf("the %s of field %q is not valid UTF-8, so it cannot be written as PDF text", what, f.Name) + } + } + for i, o := range f.Options { + if !utf8.ValidString(o.Value) || !utf8.ValidString(o.Label) { + return fmt.Errorf("option %d of field %q is not valid UTF-8", i+1, f.Name) + } + } + switch f.Kind { + case ComboBox, ListBox: + if len(f.Options) == 0 { + return fmt.Errorf("the choice field %q has no options to choose from", f.Name) + } + case Checkbox, Radio: + for i, w := range f.Widgets { + if w.On == "" { + return fmt.Errorf("widget %d of %q has no on-state name, so nothing can tell it apart from the others", i+1, f.Name) + } + if w.On == "Off" { + return fmt.Errorf("widget %d of %q is named Off, which is the name the format reserves for NOT chosen", i+1, f.Name) + } + } + } + if f.Comb && f.MaxLen <= 0 { + return fmt.Errorf("the comb field %q has no MaxLen, and a comb field is divided into MaxLen cells", f.Name) + } + if f.Quadding < 0 || f.Quadding > 2 { + return fmt.Errorf("field %q asks for alignment %d, which is not 0, 1 or 2", f.Name, f.Quadding) + } + // ⛔ A radio group is several widgets of ONE field, told apart only by + // their on-state names. Two sharing a name is a group where pressing one + // lights both and nothing can say which was chosen. + if f.Kind == Radio || f.Kind == Checkbox { + seen := map[string]bool{} + for i, w := range f.Widgets { + if seen[w.On] { + return fmt.Errorf("widget %d of %q is in state %q, which another widget of the same field already uses", i+1, f.Name, w.On) + } + seen[w.On] = true + } + } + for i, w := range f.Widgets { + if w.Rect[2] <= w.Rect[0] || w.Rect[3] <= w.Rect[1] { + return fmt.Errorf("widget %d of %q has an empty rectangle %v", i+1, f.Name, w.Rect) + } + if w.Page < 1 { + return fmt.Errorf("widget %d of %q is on page %d; pages count from one", i+1, f.Name, w.Page) + } + } + return nil +} + +// CheckAll says why a set of fields cannot be written, or nothing. It is what +// a caller building a whole form wants: Check on each, plus the one rule that +// is about the set rather than about any field in it. +// +// ⛔ A NAME USED TWICE IS A SILENT LOSS, not a broken file. 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 has no way to +// see that half of what they typed went nowhere. +func CheckAll(fields []Field) error { + seen := map[string]int{} + for i, f := range fields { + if err := Check(f); err != nil { + return err + } + if first, ok := seen[f.Name]; ok { + return fmt.Errorf("fields %d and %d are both named %q, and a form addresses a field by its name", first+1, i+1, f.Name) + } + seen[f.Name] = i + } + return nil +} + +// Dict is a field's dictionary, without the entries only a writer can fill in: +// the widget references under Kids, and the page reference under each widget. +// +// The caller adds those, because allocating object numbers belongs to whoever +// is writing the file. +func Dict(f Field) reader.Dict { + d := reader.Dict{ + "FT": FieldType(f.Kind), + "T": text(f.Name), + } + if fl := Flags(f); fl != 0 { + d["Ff"] = reader.Integer(fl) + } + if f.Quadding != 0 { + d["Q"] = reader.Integer(int64(f.Quadding)) + } + if f.MaxLen > 0 && f.Kind == Text { + d["MaxLen"] = reader.Integer(int64(f.MaxLen)) + } + switch f.Kind { + case ComboBox, ListBox: + opts := make(reader.Array, 0, len(f.Options)) + for _, o := range f.Options { + // A row whose label is its value is written as one string, which is + // what the reader expects and what every producer writes. + if o.Label == "" || o.Label == o.Value { + opts = append(opts, text(o.Value)) + continue + } + opts = append(opts, reader.Array{text(o.Value), text(o.Label)}) + } + d["Opt"] = opts + if len(f.Values) > 0 { + vals := make(reader.Array, 0, len(f.Values)) + for _, v := range f.Values { + vals = append(vals, text(v)) + } + d["V"] = vals + } else if f.Value != "" { + d["V"] = text(f.Value) + } + case Checkbox, Radio: + // A button's value is the NAME of the chosen state, not a string, and + // "Off" is the one that means nothing is chosen. + v := f.Value + if v == "" { + v = "Off" + } + d["V"] = reader.Name(v) + case PushButton, Signature: + // Neither holds a value. + default: + if f.Value != "" { + d["V"] = text(f.Value) + } + } + return d +} diff --git a/build_fuzz_test.go b/build_fuzz_test.go new file mode 100644 index 0000000..6c8db9e --- /dev/null +++ b/build_fuzz_test.go @@ -0,0 +1,60 @@ +package forms + +import ( + "testing" +) + +// FuzzAFieldCheckAcceptsSurvivesTheRoundTrip holds the one promise the writing +// half makes: whatever Check accepts can be written, read back, and is the +// same field. +// +// ⛔ THE PROPERTY IS TIED TO Check, not to a list of inputs I thought of. A +// name is arbitrary text from whoever describes the form, and the interesting +// inputs here are the ones nobody writes by hand — unbalanced parentheses, a +// lone backslash at the end, NUL, invalid UTF-8 — each of which is a way a +// string can escape its delimiters and be read as syntax. +func FuzzAFieldCheckAcceptsSurvivesTheRoundTrip(f *testing.F) { + f.Add("name", uint8(0), "value", "opt") + f.Add(`ends)here`, uint8(4), "", "a") + f.Add(`trailing\`, uint8(0), "", "") + f.Add("\x00nul", uint8(1), "", "") + f.Add("\xff\xfe", uint8(5), "", "x") + + f.Fuzz(func(t *testing.T, name string, kind uint8, value, opt string) { + k := Kind(kind % 7) + fld := Field{ + Name: name, + Kind: k, + Value: value, + Widgets: []Widget{{Page: 1, Rect: [4]float64{10, 10, 100, 30}}}, + } + switch k { + case Checkbox, Radio: + fld.Widgets[0].On = "Yes" + fld.Value = "" + case ComboBox, ListBox: + fld.Options = []Option{{Value: opt}} + } + if err := Check(fld); err != nil { + return // refused on purpose; the promise is about what it accepts + } + + // Dict must not panic, and must name the field it was given. + d := Dict(fld) + if d["FT"] != FieldType(k) { + t.Fatalf("Dict wrote FT %v for a %s", d["FT"], k) + } + + form := writeForm(t, []Field{fld}) + got, ok := form.Field(name) + if !ok { + t.Fatalf("a field named %q was written and cannot be found", name) + } + if got.Name != name { + t.Fatalf("wrote the name %q and read back %q", name, got.Name) + } + if got.Kind != k { + t.Fatalf("wrote a %s and read back a %s", k, got.Kind) + } + }) +} diff --git a/build_roundtrip_test.go b/build_roundtrip_test.go new file mode 100644 index 0000000..56b6265 --- /dev/null +++ b/build_roundtrip_test.go @@ -0,0 +1,291 @@ +package forms + +import ( + "strings" + "testing" + + "github.com/go-pdfkit/reader" +) + +// writeForm builds a document from fields using the writing half, with one +// widget per field merged into the field dictionary, which is the shape a +// single-widget field takes in nearly every real file. +func writeForm(t *testing.T, fields []Field) *Form { + t.Helper() + for _, f := range fields { + if err := Check(f); err != nil { + t.Fatalf("Check refused a field the test means to be valid: %v", err) + } + } + d := formDoc(t, func(w *reader.Writer, page reader.Ref) (reader.Dict, reader.Array) { + var refs reader.Array + var annots reader.Array + for _, f := range fields { + dict := Dict(f) + dict["Type"] = reader.Name("Annot") + dict["Subtype"] = reader.Name("Widget") + dict["P"] = page + r := f.Widgets[0].Rect + dict["Rect"] = nums(r[0], r[1], r[2], r[3]) + if on := f.Widgets[0].On; on != "" { + dict["AS"] = reader.Name(on) + dict["AP"] = reader.Dict{"N": reader.Dict{ + reader.Name(on): w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte("")}), + "Off": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte("")}), + }} + } + ref := w.Add(dict) + refs = append(refs, ref) + annots = append(annots, ref) + } + return reader.Dict{ + "Fields": refs, + "DA": str("/Helv 0 Tf 0 g"), + "DR": helv(w), + }, annots + }) + f, ok := Read(d) + if !ok { + t.Fatal("a form that was just written was read as no form at all") + } + return f +} + +// ⛔ THE ORACLE OF THE WRITING HALF IS THE READING HALF. A writer tested +// against its own expectations agrees with itself: this one is tested by being +// read back by the parser that already has a corpus behind it, so a field +// written as something the format spells differently shows up as the WRONG +// KIND rather than as a passing test. +func TestEveryFieldKindSurvivesARoundTrip(t *testing.T) { + want := []Field{ + {Name: "plain", Kind: Text, Value: "Mozart", Widgets: []Widget{{Page: 1, Rect: [4]float64{10, 10, 100, 30}}}}, + {Name: "note", Kind: Text, Multiline: true, Widgets: []Widget{{Page: 1, Rect: [4]float64{10, 40, 100, 90}}}}, + {Name: "secret", Kind: Text, Password: true, Widgets: []Widget{{Page: 1, Rect: [4]float64{10, 95, 100, 115}}}}, + {Name: "code", Kind: Text, Comb: true, MaxLen: 6, Widgets: []Widget{{Page: 1, Rect: [4]float64{10, 120, 100, 140}}}}, + {Name: "agree", Kind: Checkbox, Widgets: []Widget{{Page: 1, Rect: [4]float64{110, 10, 125, 25}, On: "Yes"}}}, + {Name: "size", Kind: ComboBox, Options: []Option{{Value: "s", Label: "Small"}, {Value: "l", Label: "Large"}}, + Widgets: []Widget{{Page: 1, Rect: [4]float64{110, 30, 190, 50}}}}, + {Name: "tags", Kind: ListBox, MultiSelect: true, Options: []Option{{Value: "a"}, {Value: "b"}}, + Widgets: []Widget{{Page: 1, Rect: [4]float64{110, 55, 190, 95}}}}, + {Name: "go", Kind: PushButton, Widgets: []Widget{{Page: 1, Rect: [4]float64{110, 100, 190, 120}}}}, + {Name: "sign", Kind: Signature, Widgets: []Widget{{Page: 1, Rect: [4]float64{110, 125, 190, 150}}}}, + } + f := writeForm(t, want) + + if got := len(f.Fields()); got != len(want) { + t.Fatalf("wrote %d fields and read %d back", len(want), got) + } + for _, w := range want { + got, ok := f.Field(w.Name) + if !ok { + t.Errorf("%q was written and cannot be found", w.Name) + continue + } + if got.Kind != w.Kind { + t.Errorf("%q was written as a %s and read back as a %s", w.Name, w.Kind, got.Kind) + } + if got.Multiline != w.Multiline || got.Password != w.Password || got.Comb != w.Comb { + t.Errorf("%q: text flags came back multiline=%v password=%v comb=%v", + w.Name, got.Multiline, got.Password, got.Comb) + } + if got.MultiSelect != w.MultiSelect || got.Editable != w.Editable || got.Sorted != w.Sorted { + t.Errorf("%q: choice flags came back multiselect=%v editable=%v sorted=%v", + w.Name, got.MultiSelect, got.Editable, got.Sorted) + } + if got.MaxLen != w.MaxLen { + t.Errorf("%q: MaxLen came back %d, wrote %d", w.Name, got.MaxLen, w.MaxLen) + } + if len(got.Options) != len(w.Options) { + t.Errorf("%q: wrote %d options and read %d", w.Name, len(w.Options), len(got.Options)) + } + } +} + +// An option written as value+label must come back as both; one written as a +// bare value must come back with the label equal to it, which is how the +// reader presents a single-string row. +func TestAChoiceFieldsRowsSurviveWithTheirLabels(t *testing.T) { + f := writeForm(t, []Field{{ + Name: "size", Kind: ComboBox, + Options: []Option{{Value: "s", Label: "Small"}, {Value: "plain"}}, + Widgets: []Widget{{Page: 1, Rect: [4]float64{10, 10, 100, 30}}}, + }}) + got, ok := f.Field("size") + if !ok { + t.Fatal("the field is gone") + } + if len(got.Options) != 2 { + t.Fatalf("read %d options", len(got.Options)) + } + if got.Options[0].Value != "s" || got.Options[0].Label != "Small" { + t.Errorf("the labelled row came back %+v", got.Options[0]) + } + if got.Options[1].Value != "plain" || got.Options[1].Label != "plain" { + t.Errorf("the bare row came back %+v", got.Options[1]) + } +} + +// The flags that belong to a choice field: editable (a combo box that takes +// something off the list), sorted, and multi-select. +func TestTheChoiceFlagsSurviveTheRoundTrip(t *testing.T) { + rect := [4]float64{10, 10, 100, 30} + opts := []Option{{Value: "a"}, {Value: "b"}} + for _, c := range []struct { + why string + field Field + }{ + {"an editable combo", Field{Name: "f", Kind: ComboBox, Editable: true, Options: opts, + Widgets: []Widget{{Page: 1, Rect: rect}}}}, + {"a sorted combo", Field{Name: "f", Kind: ComboBox, Sorted: true, Options: opts, + Widgets: []Widget{{Page: 1, Rect: rect}}}}, + {"a sorted list", Field{Name: "f", Kind: ListBox, Sorted: true, Options: opts, + Widgets: []Widget{{Page: 1, Rect: rect}}}}, + {"an editable and sorted combo", Field{Name: "f", Kind: ComboBox, Editable: true, Sorted: true, + Options: opts, Widgets: []Widget{{Page: 1, Rect: rect}}}}, + } { + form := writeForm(t, []Field{c.field}) + got, ok := form.Field("f") + if !ok { + t.Errorf("%s: the field is gone", c.why) + continue + } + if got.Editable != c.field.Editable || got.Sorted != c.field.Sorted { + t.Errorf("%s: came back editable=%v sorted=%v, wrote editable=%v sorted=%v", + c.why, got.Editable, got.Sorted, c.field.Editable, c.field.Sorted) + } + if got.Kind != c.field.Kind { + t.Errorf("%s: came back as a %s", c.why, got.Kind) + } + } +} + +// A list box with several rows chosen writes its value as a LIST, which is the +// one place a field's value is not a single string. +func TestAListBoxWithSeveralRowsChosenWritesThemAll(t *testing.T) { + form := writeForm(t, []Field{{ + Name: "tags", Kind: ListBox, MultiSelect: true, + Options: []Option{{Value: "a"}, {Value: "b"}, {Value: "c"}}, + Values: []string{"a", "c"}, + Widgets: []Widget{{Page: 1, Rect: [4]float64{10, 10, 100, 60}}}, + }}) + got, ok := form.Field("tags") + if !ok { + t.Fatal("the field is gone") + } + if len(got.Values) != 2 || got.Values[0] != "a" || got.Values[1] != "c" { + t.Errorf("the chosen rows came back as %v", got.Values) + } +} + +// A choice field with one row chosen writes a single string, the other arm of +// the same branch. +func TestAChoiceFieldWithOneRowChosenWritesItAsAString(t *testing.T) { + form := writeForm(t, []Field{{ + Name: "size", Kind: ComboBox, Value: "l", + Options: []Option{{Value: "s"}, {Value: "l"}}, + Widgets: []Widget{{Page: 1, Rect: [4]float64{10, 10, 100, 30}}}, + }}) + got, ok := form.Field("size") + if !ok { + t.Fatal("the field is gone") + } + if got.Value != "l" { + t.Errorf("the chosen row came back as %q", got.Value) + } +} + +// Alignment is written only when it is not the default, and read back as asked. +func TestTheAlignmentSurvivesTheRoundTrip(t *testing.T) { + for _, q := range []int{0, 1, 2} { + form := writeForm(t, []Field{{ + Name: "f", Kind: Text, Quadding: q, + Widgets: []Widget{{Page: 1, Rect: [4]float64{10, 10, 100, 30}}}, + }}) + got, ok := form.Field("f") + if !ok { + t.Fatalf("alignment %d: the field is gone", q) + } + if got.Quadding != q { + t.Errorf("wrote alignment %d and read back %d", q, got.Quadding) + } + } +} + +// The three flags every kind of field may carry go through independently of +// the kind's own bits. +func TestTheSharedFlagsSurviveOnEveryKind(t *testing.T) { + for _, k := range []Kind{Text, Checkbox, Radio, PushButton, ComboBox, ListBox, Signature} { + fld := Field{Name: "f", Kind: k, ReadOnly: true, Required: true, NoExport: true, + Widgets: []Widget{{Page: 1, Rect: [4]float64{10, 10, 100, 30}, On: "Yes"}}} + if k == ComboBox || k == ListBox { + fld.Options = []Option{{Value: "a"}} + } + f := writeForm(t, []Field{fld}) + got, ok := f.Field("f") + if !ok { + t.Errorf("%s: the field is gone", k) + continue + } + if !got.ReadOnly || !got.Required || !got.NoExport { + t.Errorf("%s: came back readonly=%v required=%v noexport=%v", + k, got.ReadOnly, got.Required, got.NoExport) + } + if got.Kind != k { + t.Errorf("%s came back as %s once the shared flags were set", k, got.Kind) + } + } +} + +// ⛔ A FLAG THAT DOES NOT BELONG TO THE KIND IS BIT 13 OF SOMETHING ELSE. +// Multiline is bit 13 of a text field and NoToggleToOff is bit 15 of a button; +// writing the caller's flags blindly would make a checkbox that cannot be +// unticked out of a caller who set one field struct and reused it. +func TestAFlagFromAnotherKindIsNotWritten(t *testing.T) { + cb := Field{Name: "agree", Kind: Checkbox, Multiline: true, Password: true, Comb: true, MaxLen: 4, + Widgets: []Widget{{Page: 1, Rect: [4]float64{10, 10, 30, 30}, On: "Yes"}}} + if got := Flags(cb); got != 0 { + t.Errorf("a checkbox carrying text flags was written with Ff=%d, want 0", got) + } + // And the same in the other direction: a text field does not get a button's. + tx := Field{Name: "name", Kind: Text, Widgets: []Widget{{Page: 1, Rect: [4]float64{10, 10, 100, 30}}}} + if got := Flags(tx) & (flagRadio | flagPushButton | flagNoToggleOff); got != 0 { + t.Errorf("a text field was written with button bits set: %d", got) + } +} + +// Every refusal, because each one is a file that would open and be broken +// rather than a file that fails to be written. +func TestCheckRefusesWhatCannotBeFilledIn(t *testing.T) { + ok := Widget{Page: 1, Rect: [4]float64{10, 10, 100, 30}} + for _, c := range []struct { + why string + field Field + want string + }{ + {"no name", Field{Kind: Text, Widgets: []Widget{ok}}, "no name"}, + {"a combo with no rows", Field{Name: "a", Kind: ComboBox, Widgets: []Widget{ok}}, "no options"}, + {"a list with no rows", Field{Name: "a", Kind: ListBox, Widgets: []Widget{ok}}, "no options"}, + {"a checkbox with no on-state", Field{Name: "a", Kind: Checkbox, Widgets: []Widget{ok}}, "no on-state"}, + {"a checkbox whose state is Off", Field{Name: "a", Kind: Checkbox, + Widgets: []Widget{{Page: 1, Rect: ok.Rect, On: "Off"}}}, "reserves"}, + {"a comb with no MaxLen", Field{Name: "a", Kind: Text, Comb: true, Widgets: []Widget{ok}}, "MaxLen"}, + {"an alignment that is not one", Field{Name: "a", Kind: Text, Quadding: 3, Widgets: []Widget{ok}}, "alignment"}, + {"an empty rectangle", Field{Name: "a", Kind: Text, + Widgets: []Widget{{Page: 1, Rect: [4]float64{10, 10, 10, 30}}}}, "empty rectangle"}, + {"a page before the first", Field{Name: "a", Kind: Text, + Widgets: []Widget{{Page: 0, Rect: ok.Rect}}}, "count from one"}, + } { + err := Check(c.field) + if err == nil { + t.Errorf("%s was accepted", c.why) + continue + } + if !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: the refusal says %q, which does not mention %q", c.why, err, c.want) + } + } + if err := Check(Field{Name: "a", Kind: Text, Widgets: []Widget{ok}}); err != nil { + t.Errorf("a field that is fine was refused: %v", err) + } +} + diff --git a/build_security_test.go b/build_security_test.go new file mode 100644 index 0000000..83b09f6 --- /dev/null +++ b/build_security_test.go @@ -0,0 +1,175 @@ +package forms + +import ( + "strings" + "testing" +) + +// ⛔ A FIELD NAME IS UNTRUSTED TEXT. It arrives from whoever describes the +// form, and it is written into PDF syntax, where ")" ends a string and "\" +// escapes the next byte. A name carrying those would, if they went through +// unescaped, close the string early and leave the rest of the name being read +// as DICTIONARY SYNTAX — the PDF equivalent of an injection, which would let a +// form description add entries to objects it does not own. +// +// This is the one test here whose oracle is that the document still reads +// correctly: if escaping were wrong, the field would come back under a +// different name, or the file would not parse at all. +func TestAFieldNameCarryingPDFSyntaxIsWrittenSafely(t *testing.T) { + for _, name := range []string{ + `ends)here`, + `back\slash`, + `both)and\together`, + `/Type /Catalog`, + `a(nested)pair`, + "with\nnewline", + "with\ttab", + `>>endobj`, + `parens((((unbalanced`, + } { + f := writeForm(t, []Field{{ + Name: name, Kind: Text, + Widgets: []Widget{{Page: 1, Rect: [4]float64{10, 10, 100, 30}}}, + }}) + got, ok := f.Field(name) + if !ok { + var have []string + for _, fl := range f.Fields() { + have = append(have, fl.Name) + } + t.Errorf("a field named %q came back as %q", name, strings.Join(have, ", ")) + continue + } + if got.Name != name { + t.Errorf("wrote %q and read back %q", name, got.Name) + } + } +} + +// The same for a choice field's rows, which are strings from the same source. +func TestAnOptionCarryingPDFSyntaxIsWrittenSafely(t *testing.T) { + f := writeForm(t, []Field{{ + Name: "pick", Kind: ComboBox, + Options: []Option{{Value: `v)alue`, Label: `l\abel`}, {Value: `(both)`}}, + Widgets: []Widget{{Page: 1, Rect: [4]float64{10, 10, 100, 30}}}, + }}) + got, ok := f.Field("pick") + if !ok { + t.Fatal("the field is gone") + } + if len(got.Options) != 2 { + t.Fatalf("read %d options", len(got.Options)) + } + if got.Options[0].Value != `v)alue` || got.Options[0].Label != `l\abel` { + t.Errorf("the first row came back %+v", got.Options[0]) + } + if got.Options[1].Value != `(both)` { + t.Errorf("the second row came back %+v", got.Options[1]) + } +} + +// ⛔ A NAME THAT IS NOT ASCII MUST COME BACK AS ITSELF. decodeText reads a +// string without the mark at the front as PDFDocEncoding — one rune per +// BYTE — so writing Go's UTF-8 bytes turns "café" into "café" in a file that +// opens and looks perfectly well formed. Found by fuzzing the round trip; it +// would have hit every accented or non-Latin field name in the fleet. +func TestANonASCIINameSurvivesTheRoundTrip(t *testing.T) { + for _, name := range []string{ + "café", + "Grüße", + "日本語のフィールド", + "Ωμέγα", + "эхо", + "emoji 🙂 here", + "mixed café 日本 ok", + } { + f := writeForm(t, []Field{{ + Name: name, Kind: Text, Value: name, + Widgets: []Widget{{Page: 1, Rect: [4]float64{10, 10, 100, 30}}}, + }}) + got, ok := f.Field(name) + if !ok { + var have []string + for _, fl := range f.Fields() { + have = append(have, fl.Name) + } + t.Errorf("wrote the name %q and read back %q", name, strings.Join(have, ", ")) + continue + } + if got.Value != name { + t.Errorf("wrote the value %q and read back %q", name, got.Value) + } + } +} + +// Text that is not valid UTF-8 cannot be written as PDF text and read back, so +// it is refused rather than stored under a name nobody can ask for. +func TestTextThatIsNotValidUTF8IsRefused(t *testing.T) { + rect := [4]float64{10, 10, 100, 30} + bad := "\xff\xfe" + for _, c := range []struct { + why string + field Field + }{ + {"a name", Field{Name: bad, Kind: Text, Widgets: []Widget{{Page: 1, Rect: rect}}}}, + {"a value", Field{Name: "ok", Kind: Text, Value: bad, Widgets: []Widget{{Page: 1, Rect: rect}}}}, + {"an option", Field{Name: "ok", Kind: ComboBox, Options: []Option{{Value: bad}}, + Widgets: []Widget{{Page: 1, Rect: rect}}}}, + } { + if err := Check(c.field); err == nil { + t.Errorf("%s that is not valid UTF-8 was accepted", c.why) + } + } +} + +// ⛔ TWO FIELDS OF ONE NAME IS A SILENT LOSS. The reader keys fields by name, +// so the second overwrites the first: the document holds both, the form shows +// one, and a person filling it in cannot tell. That is the shape of defect this +// fleet has written down more than once, so it is refused rather than written. +func TestTwoFieldsOfTheSameNameAreRefused(t *testing.T) { + rect := [4]float64{10, 10, 100, 30} + fields := []Field{ + {Name: "name", Kind: Text, Widgets: []Widget{{Page: 1, Rect: rect}}}, + {Name: "name", Kind: Checkbox, Widgets: []Widget{{Page: 1, Rect: rect, On: "Yes"}}}, + } + err := CheckAll(fields) + if err == nil { + t.Fatal("two fields of one name were accepted") + } + if !strings.Contains(err.Error(), "name") { + t.Errorf("the refusal does not name the field: %v", err) + } + // And a set with no duplicate passes, so the check is about duplication + // rather than about there being more than one field. + fields[1].Name = "agree" + if err := CheckAll(fields); err != nil { + t.Errorf("a set with distinct names was refused: %v", err) + } +} + +// ⛔ CheckAll RUNS Check ON EACH, or a caller who uses the set-level check +// gets none of the per-field refusals and writes a broken file believing it +// was checked. +func TestCheckAllAlsoChecksEachField(t *testing.T) { + err := CheckAll([]Field{{Name: "", Kind: Text, + Widgets: []Widget{{Page: 1, Rect: [4]float64{10, 10, 100, 30}}}}}) + if err == nil { + t.Fatal("a nameless field passed the set-level check") + } +} + +// A radio group is several widgets of ONE field, each with its own on-state, +// and two widgets sharing a state name cannot be told apart once pressed. +func TestARadioGroupWithTwoWidgetsOfTheSameStateIsRefused(t *testing.T) { + f := Field{Name: "size", Kind: Radio, Widgets: []Widget{ + {Page: 1, Rect: [4]float64{10, 10, 25, 25}, On: "S"}, + {Page: 1, Rect: [4]float64{30, 10, 45, 25}, On: "S"}, + }} + if err := Check(f); err == nil { + t.Fatal("two radio widgets of one state were accepted") + } + f.Widgets[1].On = "L" + if err := Check(f); err != nil { + t.Errorf("a well-formed radio group was refused: %v", err) + } +}