Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Fuzz the metadata and workflow parsers

d9f5a930d9e6aa20b5dbfb00d154afd46df901ab · Keenan Nemetz · 10d ago · parent 16bf0ebf

Fuzz the metadata and workflow parsers

A push decides how much of this code runs: frontmatter becomes the object every
page renders, and workflow YAML becomes the job the runner executes. Neither was
fuzzed, and both accept bytes from anyone allowed to push.

Four targets, each asserting the property that makes the pair of reader and
writer safe rather than waiting for a panic: an accepted issue or comment must
render and re-parse identically, and a second write must not change a byte; an
accepted workflow must be one the runner can act on; an accepted image
definition must not add a line to the Dockerfile it renders.

FuzzParseComment found one asymmetry in under a second: a hand-written file
without a trailing newline is normalized on the first write. That is the format
working as designed, since every writer trims and nothing compares raw bodies, so
the property now says so and the input is committed as a seed.

The YAML bomb is pinned by a test with two assertions, because the parser refuses
it on the unknown keys before expansion is reached, and it is yaml.v3's own
aliasing limit that covers a bomb inside a field the schema knows.

Closes #97.

metadata/fuzz_test.go +145

@@ -0,0 +1,145 @@
1 package metadata
2
3 import (
4 "strings"
5 "testing"
6 )
7
8 // The paths a fuzzed document arrives at. They have to satisfy the tree shape the
9 // loader expects, so only the file contents move.
10 const (
11 fuzzIssuePath = IssuesDir + "/01M5XCJ4VYKJXTBJXC387WE922/" + issueName
12 fuzzCommentPath = IssuesDir + "/01M5XCJ4VYKJXTBJXC387WE922/comments/" + "01M5XCHRQFT0FTM0CJYRYWVD3V" + ".md"
13 )
14
15 // FuzzParseIssue drives the reader that turns repository bytes into an issue. A
16 // push decides what it sees, so a panic here is an outage and a disagreement
17 // between reading and writing is worse: the server rewrites the file on every
18 // edit, and an issue it can read but not write back loses whatever it could not
19 // express.
20 //
21 // The property is therefore a round trip on the accepted inputs only. Refusing is
22 // always allowed; accepting and then failing to write is not.
23 func FuzzParseIssue(f *testing.F) {
24 for _, seed := range []string{
25 "---\ntitle: An example\nauthor: keenan\ncreated: 2026-10-30T00:00:00Z\nstate: open\n---\nA body.\n",
26 "---\ntitle: An example\nauthor: keenan\ncreated: 2026-10-30T00:00:00Z\nlabels:\n - bug\n---\n",
27 // A body that starts like frontmatter is the shape that breaks a naive
28 // reader, and a title holding a newline is the shape that breaks a writer.
29 "---\ntitle: T\nauthor: a\ncreated: 2026-10-30T00:00:00Z\n---\n---\ntitle: inner\n---\n",
30 "---\ntitle: 'line one\n line two'\nauthor: a\ncreated: 2026-10-30T00:00:00Z\n---\n",
31 "---\ntitle: T\nauthor: a\ncreated: 2026-10-30T00:00:00Z\n---\r\nbody\r\n",
32 "---\ntitle: T\nauthor: a\ncreated: 2026-10-30T00:00:00Z\nstate: wedged\n---\n",
33 "---\ntitle: T\ntitle: again\nauthor: a\ncreated: 2026-10-30T00:00:00Z\n---\n",
34 "---\ntitle: T\nauthor: a\ncreated: not-a-time\n---\n",
35 "---\n---\n",
36 "",
37 "no frontmatter at all\n",
38 "---\ntitle: T\nauthor: a\ncreated: 2026-10-30T00:00:00Z\nlabels: [a, a, a]\n---\nbody\x00with a nul\n",
39 "---\ntitle: \xff\xfe\nauthor: a\ncreated: 2026-10-30T00:00:00Z\n---\n",
40 } {
41 f.Add(seed)
42 }
43 f.Fuzz(func(t *testing.T, raw string) {
44 issues, err := ParseIssues(map[string]string{fuzzIssuePath: raw})
45 if err != nil {
46 return
47 }
48 if len(issues) != 1 {
49 t.Fatalf("one issue file gave %d issues", len(issues))
50 }
51 iss := issues[0]
52 rendered, err := renderIssue(iss)
53 if err != nil {
54 t.Fatalf("an accepted issue does not render: %v", err)
55 }
56 again, err := ParseIssues(map[string]string{fuzzIssuePath: string(rendered)})
57 if err != nil {
58 t.Fatalf("the issue written by renderIssue is refused: %v\n%s", err, rendered)
59 }
60 if len(again) != 1 {
61 t.Fatalf("the issue written by renderIssue loaded as %d issues", len(again))
62 }
63 got := again[0]
64 if got.Title != iss.Title || got.State != iss.State || got.Author != iss.Author {
65 t.Fatalf("%q: the round trip changed the header: %+v then %+v", raw, iss, got)
66 }
67 if !got.Created.Equal(iss.Created) {
68 t.Fatalf("%q: created became %s, was %s", raw, got.Created, iss.Created)
69 }
70 // The writer trims the trailing newlines of a body, so the comparison is on
71 // the trimmed form: what matters is that no other byte moves.
72 if body := strings.TrimRight(got.Description, "\n"); body != strings.TrimRight(iss.Description, "\n") {
73 t.Fatalf("%q: the round trip changed the body: %q then %q", raw, iss.Description, got.Description)
74 }
75 if strings.Join(got.Labels, ",") != strings.Join(iss.Labels, ",") {
76 t.Fatalf("%q: labels became %v, were %v", raw, got.Labels, iss.Labels)
77 }
78 // Writing an issue twice has to be a fixed point, or every edit rewrites the
79 // file and the diff shows work nobody did.
80 renderedAgain, err := renderIssue(got)
81 if err != nil {
82 t.Fatalf("the second write failed: %v", err)
83 }
84 if string(renderedAgain) != string(rendered) {
85 t.Fatalf("%q: writing twice differed:\nfirst:\n%s\nsecond:\n%s", raw, rendered, renderedAgain)
86 }
87 })
88 }
89
90 // FuzzParseComment is the same property for a comment, which is edited far more
91 // often than an issue body and holds code blocks, quoted frontmatter, and
92 // whatever a person pastes.
93 func FuzzParseComment(f *testing.F) {
94 for _, seed := range []string{
95 "---\nauthor: keenan\ncreated: 2026-10-30T00:00:00Z\n---\nA reply.\n",
96 "---\nauthor: keenan\ncreated: 2026-10-30T00:00:00Z\nreply_to: 01M5XCHRQFT0FTM0CJYRYWVD3V\n---\nA reply.\n",
97 "---\nauthor: keenan\ncreated: 2026-10-30T00:00:00Z\ndeleted: true\n---\n",
98 "---\nauthor: keenan\ncreated: 2026-10-30T00:00:00Z\npath: a/b.go\nside: right\nline: 12\nblob: 6beaa53\n---\nx\n",
99 // A body that looks like frontmatter, and one that ends where the writer
100 // puts its delimiter.
101 "---\nauthor: a\ncreated: 2026-10-30T00:00:00Z\n---\n---\nauthor: b\ncreated: 2026-10-30T00:00:00Z\n---\n",
102 "---\nauthor: a\ncreated: 2026-10-30T00:00:00Z\n---\n```\n---\n```\n",
103 "---\nauthor: a\ncreated: 2026-10-30T00:00:00Z\n---\n",
104 "---\nauthor: \nauthor: a\ncreated: 2026-10-30T00:00:00Z\n---\n",
105 "---\nauthor: a\ncreated: 2026-10-30T00:00:00Z\nline: not a number\n---\n",
106 "",
107 "---\n---\n",
108 } {
109 f.Add(seed)
110 }
111 f.Fuzz(func(t *testing.T, raw string) {
112 c, problem := ParseComment("01M5XCHRQFT0FTM0CJYRYWVD3V", raw, fuzzCommentPath)
113 if problem != "" {
114 return
115 }
116 rendered, err := RenderComment(&c)
117 if err != nil {
118 t.Fatalf("an accepted comment does not render: %v", err)
119 }
120 again, problem := ParseComment(c.ID, string(rendered), fuzzCommentPath)
121 if problem != "" {
122 t.Fatalf("the comment written by RenderComment is refused: %s\n%s", problem, rendered)
123 }
124 if again.Author != c.Author || again.ReplyTo != c.ReplyTo || again.Deleted != c.Deleted {
125 t.Fatalf("%q: the round trip changed the header: %+v then %+v", raw, c, again)
126 }
127 // A file the server wrote ends with a newline, and Split keeps it in the body.
128 // A hand-written file that ends without one is therefore normalized on the
129 // first write: the body gains nothing but that newline, which is why the
130 // comparison is on the trimmed form. Every caller trims before it writes, so
131 // this is the shape of the format, not a difference anyone sees.
132 if body := strings.TrimRight(again.Body, "\n"); body != strings.TrimRight(c.Body, "\n") {
133 t.Fatalf("%q: the body became %q, was %q", raw, again.Body, c.Body)
134 }
135 // The fixed point is the real invariant: once the file has been written by the
136 // server, reading and writing it again must not change a byte.
137 renderedAgain, err := RenderComment(&again)
138 if err != nil {
139 t.Fatalf("the second write failed: %v", err)
140 }
141 if string(renderedAgain) != string(rendered) {
142 t.Fatalf("%q: writing twice differed:\nfirst:\n%s\nsecond:\n%s", raw, rendered, renderedAgain)
143 }
144 })
145 }