Commit 382be3c705

382be3c7059140362c060e3ecb5928020d20f379

parent: 3d2795d9c2

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-06 18:45 UTC

ci: image: per job, validated as a reference not a command line

Ref #144

Layout: unified · split

internal/ci/ci.go +17 −1
@@ -30,6 +30,14 @@ const (
30 30
31var jobName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,39}$`) 31var jobName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,39}$`)
32 32
33// imageRef matches an OCI image reference conservatively: registry path
34// segments, an optional :tag and an optional @sha256: digest. This string
35// becomes an argument to `podman run`, and a repository's config file must
36// not be able to turn it into anything else — so the pattern allows only
37// what a reference needs and refuses whitespace and every shell character
38// rather than trying to escape them (#144).
39var imageRef = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._\-]*(:[0-9]+)?(/[a-zA-Z0-9][a-zA-Z0-9._\-]*)*(:[a-zA-Z0-9][a-zA-Z0-9._\-]{0,127})?(@sha256:[a-f0-9]{64})?$`)
40
33type Job struct { 41type Job struct {
34 Name string 42 Name string
35 Steps []string 43 Steps []string
@@ -39,6 +47,9 @@ type Job struct {
39 // or tag job ignores them. 47 // or tag job ignores them.
40 Paths []string // globs; the job runs only when a changed file matches one 48 Paths []string // globs; the job runs only when a changed file matches one
41 PathsIgnore []string // globs; the job is skipped when every changed file matches one 49 PathsIgnore []string // globs; the job is skipped when every changed file matches one
50 // Image is the container image the job's steps run in. Empty means
51 // the runner's configured default (#144).
52 Image string
42} 53}
43 54
44// Parse returns the jobs in name order, or an error describing the first 55// Parse returns the jobs in name order, or an error describing the first
@@ -51,6 +62,7 @@ func Parse(raw []byte) ([]Job, error) {
51 Tags string `yaml:"tags"` 62 Tags string `yaml:"tags"`
52 Paths []string `yaml:"paths"` 63 Paths []string `yaml:"paths"`
53 PathsIgnore []string `yaml:"paths-ignore"` 64 PathsIgnore []string `yaml:"paths-ignore"`
65 Image string `yaml:"image"`
54 } `yaml:"jobs"` 66 } `yaml:"jobs"`
55 } 67 }
56 if err := yaml.Unmarshal(raw, &doc); err != nil { 68 if err := yaml.Unmarshal(raw, &doc); err != nil {
@@ -107,9 +119,13 @@ func Parse(raw []byte) ([]Job, error) {
107 return nil, fmt.Errorf("job %q: bad paths-ignore pattern %q", name, p) 119 return nil, fmt.Errorf("job %q: bad paths-ignore pattern %q", name, p)
108 } 120 }
109 } 121 }
122 if j.Image != "" && !imageRef.MatchString(j.Image) {
123 return nil, fmt.Errorf("job %q: bad image %q: a reference like "+
124 "docker.io/library/alpine:3.20, not a command line", name, j.Image)
125 }
110 jobs = append(jobs, Job{ 126 jobs = append(jobs, Job{
111 Name: name, Steps: j.Steps, Schedule: j.Schedule, Tags: j.Tags, 127 Name: name, Steps: j.Steps, Schedule: j.Schedule, Tags: j.Tags,
112 Paths: j.Paths, PathsIgnore: j.PathsIgnore, 128 Paths: j.Paths, PathsIgnore: j.PathsIgnore, Image: j.Image,
113 }) 129 })
114 } 130 }
115 sort.Slice(jobs, func(i, k int) bool { return jobs[i].Name < jobs[k].Name }) 131 sort.Slice(jobs, func(i, k int) bool { return jobs[i].Name < jobs[k].Name })
internal/ci/image_test.go added +60
@@ -0,0 +1,60 @@
1package ci
2
3import "strings"
4
5import "testing"
6
7// An image reference reaches `podman run` as an argument, so ci.Parse is
8// where a repository's config file is stopped from turning it into
9// something else (#144).
10func TestParseImageValidation(t *testing.T) {
11 good := []string{
12 "alpine",
13 "alpine:3.20",
14 "docker.io/library/alpine:3.20",
15 "ghcr.io/krz/builder:v1.2.3",
16 "registry.example.test:5000/team/img:tag",
17 "alpine@sha256:" + strings.Repeat("a", 64),
18 }
19 for _, img := range good {
20 if _, err := Parse([]byte("jobs:\n t:\n image: " + img + "\n steps:\n - echo ok\n")); err != nil {
21 t.Errorf("Parse rejected a valid image %q: %v", img, err)
22 }
23 }
24 bad := []string{
25 "alpine; rm -rf /",
26 "alpine && curl evil.test",
27 "alpine $(whoami)",
28 "alpine `id`",
29 "--privileged",
30 "-v /:/host",
31 "alpine --volume=/etc:/etc",
32 "alpine\nrm -rf /",
33 "alpine image with spaces",
34 "'alpine'",
35 "$IMAGE",
36 }
37 for _, img := range bad {
38 _, err := Parse([]byte("jobs:\n t:\n image: " + quoteYAML(img) + "\n steps:\n - echo ok\n"))
39 if err == nil {
40 t.Errorf("Parse accepted %q as an image", img)
41 continue
42 }
43 if !strings.Contains(err.Error(), "bad image") {
44 t.Errorf("image %q refused for the wrong reason: %v", img, err)
45 }
46 }
47}
48
49// No image means the runner's default, not an error.
50func TestParseImageOptional(t *testing.T) {
51 jobs, err := Parse([]byte("jobs:\n t:\n steps:\n - echo ok\n"))
52 if err != nil {
53 t.Fatal(err)
54 }
55 if jobs[0].Image != "" {
56 t.Errorf("Image = %q, want empty", jobs[0].Image)
57 }
58}
59
60func quoteYAML(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }