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/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.

Bound what a CI job can consume

6bfc42949be4d40b3b74de8cc17c6d903cfe531e · Keenan Nemetz · 13d ago · parent f5456470

Bound what a CI job can consume

A job runs untrusted build code, and everything it produces is either
stored by the instance or kept on the runner. None of those channels had
a bound of its own.

The attempt log was a strings.Builder: the whole run in memory, then
written to a temp file, then read twice by the LFS transfer (once to hash,
once to send). It is now a spool file on disk from the first byte, capped
at 32 MiB, uploaded once at the end as an ordinary LFS object. A capped
log reports the whole write as taken so the producing process is not
killed, and gets a line saying it was truncated. Step offsets are
computed from the spool's length, which never exceeds the cap, so a
truncated log still has valid ranges. Nothing is streamed to the server:
a log's content-addressed id cannot be known before its last byte, so
streaming would only buy a second protocol.

A run may publish at most 256 artifact files totalling 4 GiB. The
workflow limit is on declarations, and a declaration is a pattern that
can match a directory tree, so the bound on what it expands to has to be
where the tree is walked.

Contributor caches are now namespaced per account, not one shared
"contrib" namespace: one contributor's untrusted branch must not be able
to seed another contributor's cache. A contributor keeps their cache
across their own branches.

Container volume paths are checked before the argument list is built: a
colon is the field separator, and a NUL or newline would split the
argument, so a path carrying one is refused rather than passed to the
runtime. RunArgs is unchanged; the refusal is in Start, and the closed-set
test now also pins --cap-add, --device, --pid/--ipc host and the podman
socket.

Image builds run through the configured runtime, so a rootless Podman
runner builds rootless. The definition is pinned by digest already; the
docs now say how the build gets its network and recommend pinning the
base image in from: by digest for a reproducible build.

Tests: the spool keeps what fits, truncates and says so, and removes its
file; artifact count and byte caps (the byte cap uses a sparse file so it
does not write 4 GiB); contributor caches are separated by account while
one account's branches share; volume paths with a colon, NUL or newline
are refused.

ci/agent/spool_test.go +120

@@ -0,0 +1,120 @@
1 package agent
2
3 import (
4 "os"
5 "strings"
6 "testing"
7 )
8
9 func TestSpoolKeepsWhatFits(t *testing.T) {
10 t.Parallel()
11 s, err := newSpool(1024)
12 if err != nil {
13 t.Fatal(err)
14 }
15 defer s.Close()
16 if _, err := s.Write([]byte("hello\n")); err != nil {
17 t.Fatal(err)
18 }
19 if s.Len() != 6 {
20 t.Fatalf("Len = %d, want 6", s.Len())
21 }
22 if err := s.seal(); err != nil {
23 t.Fatal(err)
24 }
25 raw, err := os.ReadFile(s.Path())
26 if err != nil {
27 t.Fatal(err)
28 }
29 if string(raw) != "hello\n" {
30 t.Fatalf("log = %q", raw)
31 }
32 }
33
34 // A capped log reports the whole write as taken, because a short write would kill
35 // the process producing it; the excess is dropped and the file says so.
36 func TestSpoolTruncatesAndSaysSo(t *testing.T) {
37 t.Parallel()
38 s, err := newSpool(16)
39 if err != nil {
40 t.Fatal(err)
41 }
42 defer s.Close()
43 if n, err := s.Write(make([]byte, 100)); err != nil || n != 100 {
44 t.Fatalf("Write = %d, %v; want 100, nil", n, err)
45 }
46 if s.Len() != 16 {
47 t.Fatalf("Len = %d, want the cap", s.Len())
48 }
49 if err := s.seal(); err != nil {
50 t.Fatal(err)
51 }
52 raw, err := os.ReadFile(s.Path())
53 if err != nil {
54 t.Fatal(err)
55 }
56 if len(raw) <= 16 {
57 t.Fatalf("the truncation note was not appended: %d bytes", len(raw))
58 }
59 if !strings.Contains(string(raw), "log truncated") {
60 t.Fatalf("the log does not say it was truncated: %q", raw)
61 }
62 // Step ranges are computed from Len, which never exceeds the cap, so a range can
63 // never name bytes the note overwrote.
64 if s.Len() > 16 {
65 t.Fatalf("Len = %d after sealing, want at most the cap", s.Len())
66 }
67 }
68
69 func TestSpoolCloseRemovesTheFile(t *testing.T) {
70 t.Parallel()
71 s, err := newSpool(1024)
72 if err != nil {
73 t.Fatal(err)
74 }
75 if _, err := s.Write([]byte("x")); err != nil {
76 t.Fatal(err)
77 }
78 name := s.Path()
79 if err := s.Close(); err != nil {
80 t.Fatal(err)
81 }
82 if _, err := os.Stat(name); !os.IsNotExist(err) {
83 t.Fatalf("the log file survived Close: %v", err)
84 }
85 }
86
87 func TestCheckArtifactLimits(t *testing.T) {
88 t.Parallel()
89 work := t.TempDir()
90 write := func(name, body string) {
91 if err := os.WriteFile(work+"/"+name, []byte(body), 0o644); err != nil {
92 t.Fatal(err)
93 }
94 }
95 write("a", "12345")
96 files := []artifactFile{{Path: "a", Name: "a"}}
97 if err := checkArtifactLimits(work, files); err != nil {
98 t.Fatalf("a small artifact set was refused: %v", err)
99 }
100 many := make([]artifactFile, MaxRunArtifactFiles+1)
101 for i := range many {
102 many[i] = artifactFile{Path: "a", Name: "a"}
103 }
104 if err := checkArtifactLimits(work, many); err == nil {
105 t.Fatal("too many artifact files were accepted")
106 }
107 // A sparse file is a 4 GiB stat result that costs no disk, which is how the byte
108 // limit can be tested without writing 4 GiB.
109 bigPath := work + "/big"
110 if err := os.WriteFile(bigPath, nil, 0o644); err != nil {
111 t.Fatal(err)
112 }
113 if err := os.Truncate(bigPath, MaxRunArtifactBytes+1); err != nil {
114 t.Skipf("this filesystem does not support a sparse file: %v", err)
115 }
116 big := []artifactFile{{Path: "big", Name: "big"}}
117 if err := checkArtifactLimits(work, big); err == nil {
118 t.Fatal("an oversized artifact set was accepted")
119 }
120 }