vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
| 1 | package ci |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | |
| 7 | "gopkg.in/yaml.v3" |
| 8 | ) |
| 9 | |
| 10 | // FuzzParseWorkflow drives the decoder that turns a pushed workflow file into the |
| 11 | // job the runner is handed. The file arrives from whoever can push, so a panic is |
| 12 | // an outage and a document that half-validates is worse: the runner executes what |
| 13 | // the parser produced, not what the author meant. |
| 14 | // |
| 15 | // Parse is all-or-nothing by design — a problem anywhere means no workflow — so |
| 16 | // the properties are about that contract holding for every input. |
| 17 | func FuzzParseWorkflow(f *testing.F) { |
| 18 | for _, seed := range []string{ |
| 19 | "version: 1\non:\n push:\n branches: ['*']\njobs:\n verify:\n image: golang:1.27-bookworm\n steps:\n - name: build\n run: go build ./...\n", |
| 20 | "version: 1\non:\n push:\n tags: ['v*']\njobs:\n release:\n image-file: release\n steps:\n - run: make dist\n", |
| 21 | "version: 1\njobs:\n a:\n image: alpine\n steps:\n - run: echo\n", |
| 22 | "version: 1\njobs:\n a:\n steps:\n - run: echo\n", |
| 23 | // Unknown fields, wrong types, an unsupported version, and a job with no |
| 24 | // steps are the shapes a first attempt produces. |
| 25 | "version: 1\njobs:\n a:\n image: alpine\n steps: []\n srcdir: /x\n", |
| 26 | "version: 2\njobs:\n a:\n image: alpine\n steps:\n - run: echo\n", |
| 27 | "version: 1\njobs: []\n", |
| 28 | "version: 1\njobs:\n a:\n image: alpine\n steps: not-a-list\n", |
| 29 | "version: 1\njobs: {}\n", |
| 30 | // Tabs are not YAML indentation, and the message a person gets for it is the |
| 31 | // first thing they read. |
| 32 | "version: 1\njobs:\n\ta:\n\t\timage: alpine\n", |
| 33 | "", |
| 34 | "---\n---\n", |
| 35 | "version: 1\non: {push: {branches: &a ['*'], tags: [*a]}}\njobs:\n a:\n image: x\n steps: [{run: y}]\n", |
| 36 | } { |
| 37 | f.Add(seed) |
| 38 | } |
| 39 | f.Fuzz(func(t *testing.T, content string) { |
| 40 | wf, problems := Parse(".vole/workflows/fuzz.yaml", content) |
| 41 | if wf == nil { |
| 42 | if len(problems) == 0 { |
| 43 | t.Fatalf("%q was refused without saying why", content) |
| 44 | } |
| 45 | return |
| 46 | } |
| 47 | // An accepted workflow becomes a job the runner executes, so it must be one |
| 48 | // the runner can act on at all. |
| 49 | if len(problems) > 0 { |
| 50 | t.Fatalf("%q was accepted with problems: %v", content, problems) |
| 51 | } |
| 52 | if wf.Version != Version { |
| 53 | t.Fatalf("%q was accepted as version %d", content, wf.Version) |
| 54 | } |
| 55 | if len(wf.Jobs) == 0 { |
| 56 | t.Fatalf("%q was accepted with no jobs", content) |
| 57 | } |
| 58 | for _, job := range wf.Jobs { |
| 59 | // A job that names neither an image nor a definition has nothing to run |
| 60 | // in; the docs say that fails the push. |
| 61 | if job.Image == "" && job.ImageFile == "" { |
| 62 | t.Fatalf("%q was accepted with a job that names no image", content) |
| 63 | } |
| 64 | } |
| 65 | // The same file has to validate the same way twice: a push that is refused |
| 66 | // on a retry, or whose error list shuffles between runs, is a bug report |
| 67 | // nobody can reproduce. |
| 68 | wf2, problems2 := Parse(".vole/workflows/fuzz.yaml", content) |
| 69 | if (wf2 == nil) != (wf == nil) || len(problems2) != len(problems) { |
| 70 | t.Fatalf("%q validated two different ways", content) |
| 71 | } |
| 72 | // Glob matching runs on patterns this file supplied. |
| 73 | wf.Applies("main") |
| 74 | wf.AppliesToTag("v1.0.0") |
| 75 | }) |
| 76 | } |
| 77 | |
| 78 | // FuzzParseImage is the same for a workflow image definition, which ends up as a |
| 79 | // Dockerfile the runner builds. The property that matters is that the definition |
| 80 | // cannot add an instruction: the env and run values go through quoting, and a |
| 81 | // value that broke out of it would be one line of a Dockerfile the author did not |
| 82 | // write. |
| 83 | func FuzzParseImage(f *testing.F) { |
| 84 | for _, seed := range []string{ |
| 85 | "version: 1\nfrom: golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5\n", |
| 86 | "version: 1\nfrom: alpine\nenv:\n CGO_ENABLED: '1'\nrun:\n - apt-get update\n - useradd ci\n", |
| 87 | "version: 1\nfrom: alpine\nenv:\n EVIL: 'x\"\\nRUN curl http://example.invalid'\n", |
| 88 | "version: 1\nfrom: alpine\nrun:\n - 'x\"] && curl http://example.invalid'\n", |
| 89 | "version: 1\nfrom: alpine\nenv:\n ONE_LINE: 'a\nb'\n", |
| 90 | "version: 1\nfrom: ''\n", |
| 91 | "version: 1\nfrom: alpine\nenv:\n not a name: x\n", |
| 92 | "version: 2\nfrom: alpine\n", |
| 93 | "from: alpine\n", |
| 94 | "", |
| 95 | "version: 1\nfrom: [a, b]\n", |
| 96 | } { |
| 97 | f.Add(seed) |
| 98 | } |
| 99 | f.Fuzz(func(t *testing.T, content string) { |
| 100 | def, problems := ParseImage(".vole/images/fuzz.yaml", content) |
| 101 | if def == nil { |
| 102 | if len(problems) == 0 { |
| 103 | t.Fatalf("%q was refused without saying why", content) |
| 104 | } |
| 105 | return |
| 106 | } |
| 107 | if len(problems) > 0 { |
| 108 | t.Fatalf("%q was accepted with problems: %v", content, problems) |
| 109 | } |
| 110 | if def.Version != 1 { |
| 111 | t.Fatalf("%q was accepted as version %d", content, def.Version) |
| 112 | } |
| 113 | if err := ValidImageRef(def.From); err != nil { |
| 114 | t.Fatalf("%q was accepted with from %q: %v", content, def.From, err) |
| 115 | } |
| 116 | rendered, err := RenderImage(content) |
| 117 | if err != nil { |
| 118 | t.Fatalf("an accepted definition does not render: %v", err) |
| 119 | } |
| 120 | // One header, one FROM, then one line for each env entry and each run |
| 121 | // command. A value that broke out of its quoting would add a line, which is |
| 122 | // exactly the instruction nobody wrote. |
| 123 | wantLines := 2 + len(def.Env) + len(def.Run) |
| 124 | if lines := strings.Count(rendered, "\n"); lines != wantLines { |
| 125 | t.Fatalf("%q rendered %d lines, want %d:\n%s", content, lines, wantLines, rendered) |
| 126 | } |
| 127 | }) |
| 128 | } |
| 129 | |
| 130 | // TestParseRefusesUnboundedAliasing pins the reason a YAML bomb is not a problem |
| 131 | // here, because the protection is not visible in this file: yaml.v3 refuses a |
| 132 | // document whose aliases expand beyond a ratio it can afford. |
| 133 | // |
| 134 | // Two things are checked, and they fail for different reasons, so both are |
| 135 | // asserted rather than one standing in for the other. |
| 136 | func TestParseRefusesUnboundedAliasing(t *testing.T) { |
| 137 | t.Parallel() |
| 138 | // Nine levels of nine-fold expansion: about 387 million nodes if it were |
| 139 | // expanded, from a few hundred bytes. |
| 140 | bomb := "version: 1\n" + |
| 141 | "a: &a [x, x, x, x, x, x, x, x, x]\n" + |
| 142 | "b: &b [*a, *a, *a, *a, *a, *a, *a, *a, *a]\n" + |
| 143 | "c: &c [*b, *b, *b, *b, *b, *b, *b, *b, *b]\n" + |
| 144 | "d: &d [*c, *c, *c, *c, *c, *c, *c, *c, *c]\n" + |
| 145 | "e: [*d, *d, *d, *d, *d, *d, *d, *d, *d]\n" |
| 146 | if wf, problems := Parse(".vole/workflows/bomb.yaml", bomb); wf != nil || len(problems) == 0 { |
| 147 | t.Fatalf("the parser accepted a document with unknown keys: %v", wf) |
| 148 | } |
| 149 | // The parser above refuses on the unknown keys before expansion is reached, so |
| 150 | // the decoder's own limit is what protects a bomb inside a field the schema |
| 151 | // does know. That limit is why this code can decode repository YAML at all. |
| 152 | var v any |
| 153 | err := yaml.NewDecoder(strings.NewReader(bomb)).Decode(&v) |
| 154 | if err == nil { |
| 155 | t.Fatal("the decoder expanded a bomb without complaint") |
| 156 | } |
| 157 | if !strings.Contains(err.Error(), "excessive aliasing") { |
| 158 | t.Fatalf("the decoder refused the bomb for another reason: %v", err) |
| 159 | } |
| 160 | } |
| 161 |