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 | } | ||