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 · 24m ago 4b8c9e1d
history blame raw 8.0 KiB
1package agent
2
3// A leased job is a payload from the network. These are the fields the runner
4// would otherwise turn into a path, a container argument or a shell script, so
5// each one is pinned as refused when it is the wrong shape.
6
7import (
8 "strings"
9 "testing"
10 "time"
11
12 "rodent.software/vole/ci"
13 "rodent.software/vole/core/id"
14)
15
16func goodJob(t *testing.T) *runnerJob {
17 t.Helper()
18 runID, err := id.New(time.Now().UTC())
19 if err != nil {
20 t.Fatal(err)
21 }
22 return &runnerJob{
23 ID: runID,
24 Repo: "demo",
25 SHA: "0123456789abcdef0123456789abcdef01234567",
26 Ref: "refs/heads/main",
27 Branch: "main",
28 Workflow: "ci",
29 Job: "test",
30 WorkflowSHA: "0123456789abcdef0123456789abcdef01234567",
31 StepsSHA: strings.Repeat("ab", 32),
32 Attempt: 1,
33 Image: "alpine:3.22",
34 Steps: []runnerStep{{Name: "build", Run: "make", Timeout: "10m"}},
35 }
36}
37
38func TestAValidJobPasses(t *testing.T) {
39 t.Parallel()
40 if err := validateJob(goodJob(t)); err != nil {
41 t.Fatalf("a valid job was refused: %v", err)
42 }
43}
44
45func TestJobFieldsAreRevalidated(t *testing.T) {
46 t.Parallel()
47 cases := []struct {
48 name string
49 mutate func(*runnerJob)
50 field string
51 }{
52 {"repository climbs out", func(j *runnerJob) { j.Repo = "../../etc" }, "repository"},
53 {"repository has a slash", func(j *runnerJob) { j.Repo = "a/b" }, "repository"},
54 {"sha is not a commit", func(j *runnerJob) { j.SHA = "HEAD" }, "commit"},
55 {"sha is uppercase", func(j *runnerJob) { j.SHA = strings.ToUpper(j.SHA) }, "commit"},
56 {"ref is a path", func(j *runnerJob) { j.Ref = "refs/heads/../../etc/passwd" }, "ref"},
57 {"ref is not a namespace", func(j *runnerJob) { j.Ref = "main" }, "ref"},
58 {"branch has a colon", func(j *runnerJob) { j.Branch = "main:x" }, "branch"},
59 {"tag has a newline", func(j *runnerJob) { j.Tag = "v1\nv2" }, "tag"},
60 {"pages climbs out", func(j *runnerJob) { j.Pages = &runnerPages{Path: "../../etc"} }, "pages"},
61 {"pages is a glob", func(j *runnerJob) { j.Pages = &runnerPages{Path: "dist/*"} }, "pages"},
62 {"artifact climbs out", func(j *runnerJob) { j.Artifacts = []string{"../secret"} }, "artifacts"},
63 {"artifact is absolute", func(j *runnerJob) { j.Artifacts = []string{"/etc/passwd"} }, "artifacts"},
64 {"release artifact climbs out", func(j *runnerJob) { j.ReleaseArtifacts = []string{"../../x"} }, "release_artifacts"},
65 {"image file climbs out", func(j *runnerJob) { j.Image = "file:../x"; j.ImageFile = "../x"; j.ImageSHA = strings.Repeat("ab", 32) }, "image"},
66 {"image file has no digest", func(j *runnerJob) { j.Image = "file:go"; j.ImageFile = "go" }, "digest"},
67 {"image is a flag", func(j *runnerJob) { j.Image = "--privileged" }, "image"},
68 {"run id is not a run id", func(j *runnerJob) { j.ID = "run-1" }, "run id"},
69 {"attempt is zero", func(j *runnerJob) { j.Attempt = 0 }, "attempt"},
70 {"step has no script", func(j *runnerJob) { j.Steps = []runnerStep{{Name: "x"}} }, "script"},
71 {"step times out for a week", func(j *runnerJob) {
72 j.Steps = []runnerStep{{Name: "x", Run: "true", Timeout: "168h"}}
73 }, "timeout"},
74 {"job budget is not a duration", func(j *runnerJob) { j.Timeout = "2 hours" }, "timeout"},
75 {"job budget is negative", func(j *runnerJob) { j.Timeout = "-5m" }, "timeout"},
76 {"job budget is a week", func(j *runnerJob) { j.Timeout = "168h" }, "timeout"},
77 {"env value is two lines", func(j *runnerJob) { j.Env = map[string]string{"A": "1\n2"} }, "env.A"},
78 {"env name is not a name", func(j *runnerJob) { j.Env = map[string]string{"A B": "1"} }, "variable name"},
79 {"workflow name is empty", func(j *runnerJob) { j.Workflow = "" }, "workflow"},
80 }
81 for _, c := range cases {
82 job := goodJob(t)
83 c.mutate(job)
84 err := validateJob(job)
85 if err == nil {
86 t.Fatalf("%s: accepted", c.name)
87 }
88 if !strings.Contains(err.Error(), c.field) {
89 t.Fatalf("%s: error does not mention %q: %v", c.name, c.field, err)
90 }
91 }
92}
93
94func TestJobWithTooManyStepsIsRefused(t *testing.T) {
95 t.Parallel()
96 job := goodJob(t)
97 job.Steps = make([]runnerStep, ci.MaxSteps+1)
98 for i := range job.Steps {
99 job.Steps[i] = runnerStep{Name: "s", Run: "true"}
100 }
101 if err := validateJob(job); err == nil {
102 t.Fatal("a job with too many steps was accepted")
103 }
104}
105
106func TestLeasedSecretsAreRevalidated(t *testing.T) {
107 t.Parallel()
108 cases := []struct {
109 name string
110 mutate func(*runnerJob)
111 want string
112 }{
113 {"not a name", func(j *runnerJob) {
114 j.Secrets = map[string]runnerSecret{"lower": {Version: 1, Value: "x"}}
115 }, "not a secret name"},
116 {"would replace a runner variable", func(j *runnerJob) {
117 j.Secrets = map[string]runnerSecret{"HOME": {Version: 1, Value: "/tmp"}}
118 }, "variable the runner sets"},
119 {"no version", func(j *runnerJob) {
120 j.Secrets = map[string]runnerSecret{"KEY": {Value: "x"}}
121 }, "no version"},
122 {"nul byte", func(j *runnerJob) {
123 j.Secrets = map[string]runnerSecret{"KEY": {Version: 1, Value: "a\x00b"}}
124 }, "NUL"},
125 {"larger than one variable", func(j *runnerJob) {
126 j.Secrets = map[string]runnerSecret{"KEY": {Version: 1, Value: strings.Repeat("x", ci.MaxSecretBytes)}}
127 }, "larger than one environment variable"},
128 {"missing name is not a name", func(j *runnerJob) {
129 j.Missing = []runnerSecretIssue{{Name: "not a name"}}
130 }, "the runner can report"},
131 {"missing reason is two lines", func(j *runnerJob) {
132 j.Missing = []runnerSecretIssue{{Name: "KEY", Reason: "one\ntwo"}}
133 }, "the runner can report"},
134 }
135 for _, c := range cases {
136 t.Run(c.name, func(t *testing.T) {
137 t.Parallel()
138 job := goodJob(t)
139 c.mutate(job)
140 err := validateJob(job)
141 if err == nil {
142 t.Fatal("the job was accepted")
143 }
144 if !strings.Contains(err.Error(), c.want) {
145 t.Fatalf("error = %v; want one mentioning %q", err, c.want)
146 }
147 })
148 }
149
150 // A delivered value and a missing name are both normal.
151 job := goodJob(t)
152 job.Secrets = map[string]runnerSecret{"DEPLOY_KEY": {Version: 2, Value: "s3cret"}}
153 job.Missing = []runnerSecretIssue{{Name: "NOT_SET"}}
154 if err := validateJob(job); err != nil {
155 t.Fatalf("a valid job with secrets was refused: %v", err)
156 }
157}
158
159// Services arrive in the lease, so the runner checks the names, images, and
160// environment it is about to turn into container arguments.
161func TestServicesAreRevalidated(t *testing.T) {
162 t.Parallel()
163 valid := func() *runnerJob {
164 j := goodJob(t)
165 j.Network = ci.NetworkBridge
166 j.Services = []runnerService{{
167 ID: "db",
168 Image: "postgres:17",
169 Env: map[string]string{"POSTGRES_PASSWORD": "test"},
170 }}
171 return j
172 }
173 if err := validateJob(valid()); err != nil {
174 t.Fatalf("a valid service job was refused: %v", err)
175 }
176 cases := []struct {
177 name string
178 mutate func(*runnerJob)
179 want string
180 }{
181 {"no bridge", func(j *runnerJob) { j.Network = "" }, "network: bridge"},
182 {"none", func(j *runnerJob) { j.Network = ci.NetworkNone }, "network: bridge"},
183 {"bad name", func(j *runnerJob) { j.Services[0].ID = "DB" }, "service name"},
184 {"duplicate name", func(j *runnerJob) {
185 j.Services = append(j.Services, j.Services[0])
186 }, "listed twice"},
187 {"no image", func(j *runnerJob) { j.Services[0].Image = "" }, "image"},
188 {"image file", func(j *runnerJob) { j.Services[0].Image = "file:db" }, "reserved for image-file"},
189 {"bad env", func(j *runnerJob) { j.Services[0].Env = map[string]string{"A B": "1"} }, "variable name"},
190 {"multiline env", func(j *runnerJob) { j.Services[0].Env = map[string]string{"A": "1\n2"} }, "one line"},
191 }
192 for _, c := range cases {
193 job := valid()
194 c.mutate(job)
195 err := validateJob(job)
196 if err == nil {
197 t.Fatalf("%s: accepted", c.name)
198 }
199 if !strings.Contains(err.Error(), c.want) {
200 t.Fatalf("%s: error does not mention %q: %v", c.name, c.want, err)
201 }
202 }
203}
204
205func TestTooManyServicesAreRefused(t *testing.T) {
206 t.Parallel()
207 j := goodJob(t)
208 j.Network = ci.NetworkBridge
209 for i := 0; i <= ci.MaxServices; i++ {
210 j.Services = append(j.Services, runnerService{ID: "s" + string(rune('a'+i)), Image: "alpine:3.22"})
211 }
212 if err := validateJob(j); err == nil || !strings.Contains(err.Error(), "services, limit") {
213 t.Fatalf("too many services: %v", err)
214 }
215}
216