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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 22m ago 4b8c9e1d
history blame raw 6.6 KiB
1package ci
2
3import (
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.
17func 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.
83func 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.
136func 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