fix(host): bound action resolution and send archive tokens only to the origin the host names
- commit
- 0a7462c42f8014ee70da3a6c1d0ede6fb9605734
- author
- brice · · 2026-10-14 02:47 UTC
- parent
- dd71bec
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +590 -53
M internal/execstream/exec.proto +5 -0
| @@ -176,4 +176,9 @@ message ResolvedAction { | |||
| 176 | 176 | string token = 6; | |
| 177 | 177 | string source = 7; | |
| 178 | 178 | string error = 8; | |
| 179 | + | // auth_origin (scheme://host[:port], as the sandbox reaches it) is the only | |
| 180 | + | // origin the token may be sent to; empty means the token is never sent. The | |
| 181 | + | // host sets it for archives served by the ermite server, from its own | |
| 182 | + | // configuration, never from the URL alone. | |
| 183 | + | string auth_origin = 9; | |
| 179 | 184 | } | |
| @@ -176,4 +176,9 @@ message ResolvedAction { | |||||
| 176 | string token = 6; | 176 | string token = 6; | ||
| 177 | string source = 7; | 177 | string source = 7; | ||
| 178 | string error = 8; | 178 | string error = 8; | ||
| 179 | + | // auth_origin (scheme://host[:port], as the sandbox reaches it) is the only | |||
| 180 | + | // origin the token may be sent to; empty means the token is never sent. The | |||
| 181 | + | // host sets it for archives served by the ermite server, from its own | |||
| 182 | + | // configuration, never from the URL alone. | |||
| 183 | + | string auth_origin = 9; | |||
| 179 | } | 184 | } | ||
M internal/execstream/execpb/exec.pb.go +16 -2 generated file: collapsed
M internal/execstream/frame.go +45 -12
| @@ -13,6 +13,7 @@ package execstream | |||
| 13 | 13 | ||
| 14 | 14 | import ( | |
| 15 | 15 | "bufio" | |
| 16 | + | "context" | |
| 16 | 17 | "encoding/binary" | |
| 17 | 18 | "errors" | |
| 18 | 19 | "fmt" | |
| @@ -105,6 +106,13 @@ func Drive(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, onOu | |||
| 105 | 106 | return DriveJob(rw, run, cancel, JobHandler{Output: onOutput}) | |
| 106 | 107 | } | |
| 107 | 108 | ||
| 109 | + | // resolveQueue is how many ResolveRequest frames may wait for the worker. | |
| 110 | + | const resolveQueue = 2 | |
| 111 | + | ||
| 112 | + | func resolveFrame(r *execpb.ResolveResponse) *execpb.Frame { | |
| 113 | + | return &execpb.Frame{Body: &execpb.Frame_ResolveResponse{ResolveResponse: r}} | |
| 114 | + | } | |
| 115 | + | ||
| 108 | 116 | // JobHandler receives the frames of a session. Nil functions ignore their | |
| 109 | 117 | // frames. The functions are called from the goroutine of DriveJob, in the | |
| 110 | 118 | // order of the stream. | |
| @@ -115,10 +123,13 @@ type JobHandler struct { | |||
| 115 | 123 | StepCompleted func(*execpb.StepCompleted) | |
| 116 | 124 | Log func(*execpb.Log) | |
| 117 | 125 | JobResult func(*execpb.JobResult) | |
| 118 | - | // Resolve answers the executor's ResolveRequest frames. It is called in its | |
| 119 | - | // own goroutine (the call goes to the server), the answer is sent as a | |
| 120 | - | // ResolveResponse frame carrying the id of the request. | |
| 121 | - | Resolve func(*execpb.ResolveRequest) *execpb.ResolveResponse | |
| 126 | + | // Resolve answers the executor's ResolveRequest frames, one at a time and in | |
| 127 | + | // order, from a single worker (the call goes to the server); the answer is | |
| 128 | + | // sent as a ResolveResponse frame carrying the id of the request. ctx ends | |
| 129 | + | // with the session: a call still running is cancelled and DriveJob waits for | |
| 130 | + | // it before it returns. At most resolveQueue requests wait; the next ones are | |
| 131 | + | // answered with an error at once, without a call. | |
| 132 | + | Resolve func(ctx context.Context, r *execpb.ResolveRequest) *execpb.ResolveResponse | |
| 122 | 133 | } | |
| 123 | 134 | ||
| 124 | 135 | // DriveJob is Drive for a session that carries a job: besides Output frames it | |
| @@ -142,6 +153,27 @@ func DriveJob(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, h | |||
| 142 | 153 | ||
| 143 | 154 | done := make(chan struct{}) | |
| 144 | 155 | defer close(done) | |
| 156 | + | ||
| 157 | + | // One worker answers the resolutions; it ends, and is waited for, with the | |
| 158 | + | // session. | |
| 159 | + | resolveCh := make(chan *execpb.ResolveRequest, resolveQueue) | |
| 160 | + | rctx, rcancel := context.WithCancel(context.Background()) | |
| 161 | + | var workers sync.WaitGroup | |
| 162 | + | workers.Add(1) | |
| 163 | + | go func() { | |
| 164 | + | defer workers.Done() | |
| 165 | + | for { | |
| 166 | + | select { | |
| 167 | + | case req := <-resolveCh: | |
| 168 | + | resp := h.Resolve(rctx, req) | |
| 169 | + | resp.Id = req.Id | |
| 170 | + | _ = c.Send(resolveFrame(resp)) | |
| 171 | + | case <-rctx.Done(): | |
| 172 | + | return | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | }() | |
| 176 | + | defer func() { rcancel(); workers.Wait() }() | |
| 145 | 177 | if cancel != nil { | |
| 146 | 178 | go func() { | |
| 147 | 179 | select { | |
| @@ -185,14 +217,15 @@ func DriveJob(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, h | |||
| 185 | 217 | } | |
| 186 | 218 | case *execpb.Frame_ResolveRequest: | |
| 187 | 219 | req := b.ResolveRequest | |
| 188 | - | go func() { | |
| 189 | - | resp := &execpb.ResolveResponse{Id: req.Id, Error: "the host cannot resolve actions"} | |
| 190 | - | if h.Resolve != nil { | |
| 191 | - | resp = h.Resolve(req) | |
| 192 | - | resp.Id = req.Id | |
| 193 | - | } | |
| 194 | - | _ = c.Send(&execpb.Frame{Body: &execpb.Frame_ResolveResponse{ResolveResponse: resp}}) | |
| 195 | - | }() | |
| 220 | + | if h.Resolve == nil { | |
| 221 | + | _ = c.Send(resolveFrame(&execpb.ResolveResponse{Id: req.Id, Error: "the host cannot resolve actions"})) | |
| 222 | + | break | |
| 223 | + | } | |
| 224 | + | select { | |
| 225 | + | case resolveCh <- req: | |
| 226 | + | default: | |
| 227 | + | _ = c.Send(resolveFrame(&execpb.ResolveResponse{Id: req.Id, Error: "too many action resolutions in flight"})) | |
| 228 | + | } | |
| 196 | 229 | case *execpb.Frame_Exit: | |
| 197 | 230 | return hello, b.Exit, nil | |
| 198 | 231 | } | |
| @@ -13,6 +13,7 @@ package execstream | |||||
| 13 | 13 | ||||
| 14 | import ( | 14 | import ( | ||
| 15 | "bufio" | 15 | "bufio" | ||
| 16 | + | "context" | |||
| 16 | "encoding/binary" | 17 | "encoding/binary" | ||
| 17 | "errors" | 18 | "errors" | ||
| 18 | "fmt" | 19 | "fmt" | ||
| @@ -105,6 +106,13 @@ func Drive(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, onOu | |||||
| 105 | return DriveJob(rw, run, cancel, JobHandler{Output: onOutput}) | 106 | return DriveJob(rw, run, cancel, JobHandler{Output: onOutput}) | ||
| 106 | } | 107 | } | ||
| 107 | 108 | ||||
| 109 | + | // resolveQueue is how many ResolveRequest frames may wait for the worker. | |||
| 110 | + | const resolveQueue = 2 | |||
| 111 | + | ||||
| 112 | + | func resolveFrame(r *execpb.ResolveResponse) *execpb.Frame { | |||
| 113 | + | return &execpb.Frame{Body: &execpb.Frame_ResolveResponse{ResolveResponse: r}} | |||
| 114 | + | } | |||
| 115 | + | ||||
| 108 | // JobHandler receives the frames of a session. Nil functions ignore their | 116 | // JobHandler receives the frames of a session. Nil functions ignore their | ||
| 109 | // frames. The functions are called from the goroutine of DriveJob, in the | 117 | // frames. The functions are called from the goroutine of DriveJob, in the | ||
| 110 | // order of the stream. | 118 | // order of the stream. | ||
| @@ -115,10 +123,13 @@ type JobHandler struct { | |||||
| 115 | StepCompleted func(*execpb.StepCompleted) | 123 | StepCompleted func(*execpb.StepCompleted) | ||
| 116 | Log func(*execpb.Log) | 124 | Log func(*execpb.Log) | ||
| 117 | JobResult func(*execpb.JobResult) | 125 | JobResult func(*execpb.JobResult) | ||
| 118 | - | // Resolve answers the executor's ResolveRequest frames. It is called in its | 126 | + | // Resolve answers the executor's ResolveRequest frames, one at a time and in |
| 119 | - | // own goroutine (the call goes to the server), the answer is sent as a | 127 | + | // order, from a single worker (the call goes to the server); the answer is |
| 120 | - | // ResolveResponse frame carrying the id of the request. | 128 | + | // sent as a ResolveResponse frame carrying the id of the request. ctx ends |
| 121 | - | Resolve func(*execpb.ResolveRequest) *execpb.ResolveResponse | 129 | + | // with the session: a call still running is cancelled and DriveJob waits for |
| 130 | + | // it before it returns. At most resolveQueue requests wait; the next ones are | |||
| 131 | + | // answered with an error at once, without a call. | |||
| 132 | + | Resolve func(ctx context.Context, r *execpb.ResolveRequest) *execpb.ResolveResponse | |||
| 122 | } | 133 | } | ||
| 123 | 134 | ||||
| 124 | // DriveJob is Drive for a session that carries a job: besides Output frames it | 135 | // DriveJob is Drive for a session that carries a job: besides Output frames it | ||
| @@ -142,6 +153,27 @@ func DriveJob(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, h | |||||
| 142 | 153 | ||||
| 143 | done := make(chan struct{}) | 154 | done := make(chan struct{}) | ||
| 144 | defer close(done) | 155 | defer close(done) | ||
| 156 | + | ||||
| 157 | + | // One worker answers the resolutions; it ends, and is waited for, with the | |||
| 158 | + | // session. | |||
| 159 | + | resolveCh := make(chan *execpb.ResolveRequest, resolveQueue) | |||
| 160 | + | rctx, rcancel := context.WithCancel(context.Background()) | |||
| 161 | + | var workers sync.WaitGroup | |||
| 162 | + | workers.Add(1) | |||
| 163 | + | go func() { | |||
| 164 | + | defer workers.Done() | |||
| 165 | + | for { | |||
| 166 | + | select { | |||
| 167 | + | case req := <-resolveCh: | |||
| 168 | + | resp := h.Resolve(rctx, req) | |||
| 169 | + | resp.Id = req.Id | |||
| 170 | + | _ = c.Send(resolveFrame(resp)) | |||
| 171 | + | case <-rctx.Done(): | |||
| 172 | + | return | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | }() | |||
| 176 | + | defer func() { rcancel(); workers.Wait() }() | |||
| 145 | if cancel != nil { | 177 | if cancel != nil { | ||
| 146 | go func() { | 178 | go func() { | ||
| 147 | select { | 179 | select { | ||
| @@ -185,14 +217,15 @@ func DriveJob(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, h | |||||
| 185 | } | 217 | } | ||
| 186 | case *execpb.Frame_ResolveRequest: | 218 | case *execpb.Frame_ResolveRequest: | ||
| 187 | req := b.ResolveRequest | 219 | req := b.ResolveRequest | ||
| 188 | - | go func() { | 220 | + | if h.Resolve == nil { |
| 189 | - | resp := &execpb.ResolveResponse{Id: req.Id, Error: "the host cannot resolve actions"} | 221 | + | _ = c.Send(resolveFrame(&execpb.ResolveResponse{Id: req.Id, Error: "the host cannot resolve actions"})) |
| 190 | - | if h.Resolve != nil { | 222 | + | break |
| 191 | - | resp = h.Resolve(req) | 223 | + | } |
| 192 | - | resp.Id = req.Id | 224 | + | select { |
| 193 | - | } | 225 | + | case resolveCh <- req: |
| 194 | - | _ = c.Send(&execpb.Frame{Body: &execpb.Frame_ResolveResponse{ResolveResponse: resp}}) | 226 | + | default: |
| 195 | - | }() | 227 | + | _ = c.Send(resolveFrame(&execpb.ResolveResponse{Id: req.Id, Error: "too many action resolutions in flight"})) |
| 228 | + | } | |||
| 196 | case *execpb.Frame_Exit: | 229 | case *execpb.Frame_Exit: | ||
| 197 | return hello, b.Exit, nil | 230 | return hello, b.Exit, nil | ||
| 198 | } | 231 | } | ||
M internal/execstream/frame_test.go +88 -0
| @@ -4,11 +4,13 @@ package execstream | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| 7 | + | "context" | |
| 7 | 8 | "encoding/binary" | |
| 8 | 9 | "errors" | |
| 9 | 10 | "io" | |
| 10 | 11 | "net" | |
| 11 | 12 | "strings" | |
| 13 | + | "sync/atomic" | |
| 12 | 14 | "testing" | |
| 13 | 15 | "testing/iotest" | |
| 14 | 16 | "time" | |
| @@ -172,3 +174,89 @@ func TestDriveJobDeliversJobFrames(t *testing.T) { | |||
| 172 | 174 | t.Fatalf("%v", got) | |
| 173 | 175 | } | |
| 174 | 176 | } | |
| 177 | + | ||
| 178 | + | // GO-003: a flood of ResolveRequest frames is answered by one worker, the ones | |
| 179 | + | // beyond the queue with an error and no call, and DriveJob does not return | |
| 180 | + | // before the worker is done (a call in flight is cancelled). | |
| 181 | + | func TestDriveJobResolveBurst(t *testing.T) { | |
| 182 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |
| 183 | + | if err != nil { | |
| 184 | + | t.Skip(err) | |
| 185 | + | } | |
| 186 | + | defer func() { _ = ln.Close() }() | |
| 187 | + | g, err := net.Dial("tcp", ln.Addr().String()) | |
| 188 | + | if err != nil { | |
| 189 | + | t.Fatal(err) | |
| 190 | + | } | |
| 191 | + | h, err := ln.Accept() | |
| 192 | + | if err != nil { | |
| 193 | + | t.Fatal(err) | |
| 194 | + | } | |
| 195 | + | defer func() { _ = g.Close() }() | |
| 196 | + | gc := NewConn(g) | |
| 197 | + | if err := gc.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{}}}); err != nil { | |
| 198 | + | t.Fatal(err) | |
| 199 | + | } | |
| 200 | + | var running, maxRunning, started atomic.Int32 | |
| 201 | + | release := make(chan struct{}) | |
| 202 | + | var exited atomic.Bool | |
| 203 | + | done := make(chan struct{}) | |
| 204 | + | go func() { | |
| 205 | + | defer close(done) | |
| 206 | + | _, _, _ = DriveJob(h, &execpb.Run{}, nil, JobHandler{Resolve: func(ctx context.Context, r *execpb.ResolveRequest) *execpb.ResolveResponse { | |
| 207 | + | n := running.Add(1) | |
| 208 | + | defer running.Add(-1) | |
| 209 | + | for { | |
| 210 | + | m := maxRunning.Load() | |
| 211 | + | if n <= m || maxRunning.CompareAndSwap(m, n) { | |
| 212 | + | break | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | started.Add(1) | |
| 216 | + | select { | |
| 217 | + | case <-release: | |
| 218 | + | case <-ctx.Done(): | |
| 219 | + | if exited.Load() { | |
| 220 | + | t.Error("the worker outlived DriveJob") | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | return &execpb.ResolveResponse{} | |
| 224 | + | }}) | |
| 225 | + | exited.Store(true) | |
| 226 | + | }() | |
| 227 | + | // 50 requests at once while the first call is held. | |
| 228 | + | for i := int64(1); i <= 50; i++ { | |
| 229 | + | if err := gc.Send(&execpb.Frame{Body: &execpb.Frame_ResolveRequest{ResolveRequest: &execpb.ResolveRequest{Id: i, Uses: []string{"a/b@v1"}}}}); err != nil { | |
| 230 | + | t.Fatal(err) | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | // Collect the refusals, which are immediate: until the stream is quiet. The | |
| 234 | + | // worker may or may not have taken the first request before the queue filled, | |
| 235 | + | // so the queue and the running call hold 2 or 3 of the 50. | |
| 236 | + | refused := 0 | |
| 237 | + | for { | |
| 238 | + | _ = g.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) | |
| 239 | + | f, err := gc.Recv() | |
| 240 | + | if err != nil { | |
| 241 | + | break | |
| 242 | + | } | |
| 243 | + | if r := f.GetResolveResponse(); r != nil && r.Error != "" { | |
| 244 | + | refused++ | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | _ = g.SetReadDeadline(time.Time{}) | |
| 248 | + | if refused < 50-1-resolveQueue || refused > 50-resolveQueue { | |
| 249 | + | t.Fatalf("%d refused of 50, want %d or %d", refused, 50-1-resolveQueue, 50-resolveQueue) | |
| 250 | + | } | |
| 251 | + | if maxRunning.Load() != 1 || started.Load() != 1 { | |
| 252 | + | t.Errorf("running %d, started %d: resolutions are serial", maxRunning.Load(), started.Load()) | |
| 253 | + | } | |
| 254 | + | // End the session with a call still in flight: it is cancelled and joined. | |
| 255 | + | _ = g.Close() | |
| 256 | + | select { | |
| 257 | + | case <-done: | |
| 258 | + | case <-time.After(5 * time.Second): | |
| 259 | + | t.Fatal("DriveJob did not return") | |
| 260 | + | } | |
| 261 | + | close(release) | |
| 262 | + | } | |
| @@ -4,11 +4,13 @@ package execstream | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "bytes" | 6 | "bytes" | ||
| 7 | + | "context" | |||
| 7 | "encoding/binary" | 8 | "encoding/binary" | ||
| 8 | "errors" | 9 | "errors" | ||
| 9 | "io" | 10 | "io" | ||
| 10 | "net" | 11 | "net" | ||
| 11 | "strings" | 12 | "strings" | ||
| 13 | + | "sync/atomic" | |||
| 12 | "testing" | 14 | "testing" | ||
| 13 | "testing/iotest" | 15 | "testing/iotest" | ||
| 14 | "time" | 16 | "time" | ||
| @@ -172,3 +174,89 @@ func TestDriveJobDeliversJobFrames(t *testing.T) { | |||||
| 172 | t.Fatalf("%v", got) | 174 | t.Fatalf("%v", got) | ||
| 173 | } | 175 | } | ||
| 174 | } | 176 | } | ||
| 177 | + | ||||
| 178 | + | // GO-003: a flood of ResolveRequest frames is answered by one worker, the ones | |||
| 179 | + | // beyond the queue with an error and no call, and DriveJob does not return | |||
| 180 | + | // before the worker is done (a call in flight is cancelled). | |||
| 181 | + | func TestDriveJobResolveBurst(t *testing.T) { | |||
| 182 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |||
| 183 | + | if err != nil { | |||
| 184 | + | t.Skip(err) | |||
| 185 | + | } | |||
| 186 | + | defer func() { _ = ln.Close() }() | |||
| 187 | + | g, err := net.Dial("tcp", ln.Addr().String()) | |||
| 188 | + | if err != nil { | |||
| 189 | + | t.Fatal(err) | |||
| 190 | + | } | |||
| 191 | + | h, err := ln.Accept() | |||
| 192 | + | if err != nil { | |||
| 193 | + | t.Fatal(err) | |||
| 194 | + | } | |||
| 195 | + | defer func() { _ = g.Close() }() | |||
| 196 | + | gc := NewConn(g) | |||
| 197 | + | if err := gc.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{}}}); err != nil { | |||
| 198 | + | t.Fatal(err) | |||
| 199 | + | } | |||
| 200 | + | var running, maxRunning, started atomic.Int32 | |||
| 201 | + | release := make(chan struct{}) | |||
| 202 | + | var exited atomic.Bool | |||
| 203 | + | done := make(chan struct{}) | |||
| 204 | + | go func() { | |||
| 205 | + | defer close(done) | |||
| 206 | + | _, _, _ = DriveJob(h, &execpb.Run{}, nil, JobHandler{Resolve: func(ctx context.Context, r *execpb.ResolveRequest) *execpb.ResolveResponse { | |||
| 207 | + | n := running.Add(1) | |||
| 208 | + | defer running.Add(-1) | |||
| 209 | + | for { | |||
| 210 | + | m := maxRunning.Load() | |||
| 211 | + | if n <= m || maxRunning.CompareAndSwap(m, n) { | |||
| 212 | + | break | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | started.Add(1) | |||
| 216 | + | select { | |||
| 217 | + | case <-release: | |||
| 218 | + | case <-ctx.Done(): | |||
| 219 | + | if exited.Load() { | |||
| 220 | + | t.Error("the worker outlived DriveJob") | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | return &execpb.ResolveResponse{} | |||
| 224 | + | }}) | |||
| 225 | + | exited.Store(true) | |||
| 226 | + | }() | |||
| 227 | + | // 50 requests at once while the first call is held. | |||
| 228 | + | for i := int64(1); i <= 50; i++ { | |||
| 229 | + | if err := gc.Send(&execpb.Frame{Body: &execpb.Frame_ResolveRequest{ResolveRequest: &execpb.ResolveRequest{Id: i, Uses: []string{"a/b@v1"}}}}); err != nil { | |||
| 230 | + | t.Fatal(err) | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | // Collect the refusals, which are immediate: until the stream is quiet. The | |||
| 234 | + | // worker may or may not have taken the first request before the queue filled, | |||
| 235 | + | // so the queue and the running call hold 2 or 3 of the 50. | |||
| 236 | + | refused := 0 | |||
| 237 | + | for { | |||
| 238 | + | _ = g.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) | |||
| 239 | + | f, err := gc.Recv() | |||
| 240 | + | if err != nil { | |||
| 241 | + | break | |||
| 242 | + | } | |||
| 243 | + | if r := f.GetResolveResponse(); r != nil && r.Error != "" { | |||
| 244 | + | refused++ | |||
| 245 | + | } | |||
| 246 | + | } | |||
| 247 | + | _ = g.SetReadDeadline(time.Time{}) | |||
| 248 | + | if refused < 50-1-resolveQueue || refused > 50-resolveQueue { | |||
| 249 | + | t.Fatalf("%d refused of 50, want %d or %d", refused, 50-1-resolveQueue, 50-resolveQueue) | |||
| 250 | + | } | |||
| 251 | + | if maxRunning.Load() != 1 || started.Load() != 1 { | |||
| 252 | + | t.Errorf("running %d, started %d: resolutions are serial", maxRunning.Load(), started.Load()) | |||
| 253 | + | } | |||
| 254 | + | // End the session with a call still in flight: it is cancelled and joined. | |||
| 255 | + | _ = g.Close() | |||
| 256 | + | select { | |||
| 257 | + | case <-done: | |||
| 258 | + | case <-time.After(5 * time.Second): | |||
| 259 | + | t.Fatal("DriveJob did not return") | |||
| 260 | + | } | |||
| 261 | + | close(release) | |||
| 262 | + | } | |||
M internal/executor/actions_download.go +53 -9
| @@ -10,8 +10,10 @@ import ( | |||
| 10 | 10 | "io" | |
| 11 | 11 | "math/rand/v2" | |
| 12 | 12 | "net/http" | |
| 13 | + | "net/url" | |
| 13 | 14 | "os" | |
| 14 | 15 | "path/filepath" | |
| 16 | + | "strings" | |
| 15 | 17 | "time" | |
| 16 | 18 | ||
| 17 | 19 | "github.com/google/uuid" | |
| @@ -41,11 +43,47 @@ type errAccessDenied struct{ url string } | |||
| 41 | 43 | ||
| 42 | 44 | func (e errAccessDenied) Error() string { return fmt.Sprintf("Access denied to '%s' ()", e.url) } | |
| 43 | 45 | ||
| 44 | - | func (j *job) httpClient() *http.Client { | |
| 45 | - | if c := j.cfg.Actions.HTTPClient; c != nil { | |
| 46 | - | return c | |
| 46 | + | // originOf is scheme://host[:port] of an absolute http(s) URL, lower case and | |
| 47 | + | // without the default port, "" for anything else. | |
| 48 | + | func originOf(raw string) string { | |
| 49 | + | u, err := url.Parse(raw) | |
| 50 | + | if err != nil || u.Host == "" || u.User != nil || (u.Scheme != "http" && u.Scheme != "https") { | |
| 51 | + | return "" | |
| 47 | 52 | } | |
| 48 | - | return &http.Client{} | |
| 53 | + | host, port := strings.ToLower(u.Hostname()), u.Port() | |
| 54 | + | if port == "" || (u.Scheme == "http" && port == "80") || (u.Scheme == "https" && port == "443") { | |
| 55 | + | return u.Scheme + "://" + host | |
| 56 | + | } | |
| 57 | + | return u.Scheme + "://" + host + ":" + port | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // archiveClient returns the client that downloads the archives. It follows | |
| 61 | + | // redirects (GitHub's codeload does redirect) but never carries credentials | |
| 62 | + | // across an origin: the Authorization header is dropped as soon as the scheme, | |
| 63 | + | // the host or the port changes, and a redirect from https to http is refused. | |
| 64 | + | // (net/http alone would keep the header for subdomains and ignore the scheme and | |
| 65 | + | // the port.) Tests may supply their own transport through Actions.HTTPClient; | |
| 66 | + | // its redirect policy is replaced. | |
| 67 | + | func (j *job) archiveClient() *http.Client { | |
| 68 | + | c := &http.Client{} | |
| 69 | + | if base := j.cfg.Actions.HTTPClient; base != nil { | |
| 70 | + | cp := *base | |
| 71 | + | c = &cp | |
| 72 | + | } | |
| 73 | + | c.CheckRedirect = func(req *http.Request, via []*http.Request) error { | |
| 74 | + | if len(via) >= 10 { | |
| 75 | + | return errors.New("stopped after 10 redirects") | |
| 76 | + | } | |
| 77 | + | prev := via[len(via)-1] | |
| 78 | + | if prev.URL.Scheme == "https" && req.URL.Scheme != "https" { | |
| 79 | + | return fmt.Errorf("refusing to follow a redirect from https to %s", req.URL.Scheme) | |
| 80 | + | } | |
| 81 | + | if originOf(prev.URL.String()) != originOf(req.URL.String()) { | |
| 82 | + | req.Header.Del("Authorization") | |
| 83 | + | } | |
| 84 | + | return nil | |
| 85 | + | } | |
| 86 | + | return c | |
| 49 | 87 | } | |
| 50 | 88 | ||
| 51 | 89 | // sleepBackoff waits a random 10 to 30 seconds (BackoffTimerHelper. | |
| @@ -62,10 +100,11 @@ func (j *job) sleepBackoff(ctx context.Context) time.Duration { | |||
| 62 | 100 | // The final error is the one of the last attempt, which is what the runner | |
| 63 | 101 | // shows (PrepareActionsAsync prints the inner exception of | |
| 64 | 102 | // FailedToDownloadActionException). | |
| 65 | - | func (j *job) downloadArchive(ctx context.Context, num int, url, token, file string) error { | |
| 103 | + | func (j *job) downloadArchive(ctx context.Context, num int, ra ResolvedAction, file string) error { | |
| 104 | + | url := ra.TarballURL | |
| 66 | 105 | var last error | |
| 67 | 106 | for attempt := 1; attempt <= downloadAttempts; attempt++ { | |
| 68 | - | err := j.fetch(ctx, url, token, file) | |
| 107 | + | err := j.fetch(ctx, ra, file) | |
| 69 | 108 | if err == nil { | |
| 70 | 109 | return nil | |
| 71 | 110 | } | |
| @@ -95,7 +134,12 @@ func (j *job) downloadArchive(ctx context.Context, num int, url, token, file str | |||
| 95 | 134 | return last | |
| 96 | 135 | } | |
| 97 | 136 | ||
| 98 | - | func (j *job) fetch(ctx context.Context, url, token, file string) error { | |
| 137 | + | func (j *job) fetch(ctx context.Context, ra ResolvedAction, file string) error { | |
| 138 | + | url, token := ra.TarballURL, ra.Token | |
| 139 | + | // The token goes only to the origin the host named, never by the URL alone. | |
| 140 | + | if token != "" && (ra.AuthOrigin == "" || ra.AuthOrigin != originOf(url)) { | |
| 141 | + | token = "" | |
| 142 | + | } | |
| 99 | 143 | ctx, cancel := context.WithTimeout(ctx, downloadTimeout) | |
| 100 | 144 | defer cancel() | |
| 101 | 145 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | |
| @@ -106,7 +150,7 @@ func (j *job) fetch(ctx context.Context, url, token, file string) error { | |||
| 106 | 150 | req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token))) | |
| 107 | 151 | } | |
| 108 | 152 | req.Header.Set("User-Agent", "ermite-runner") | |
| 109 | - | resp, err := j.httpClient().Do(req) | |
| 153 | + | resp, err := j.archiveClient().Do(req) | |
| 110 | 154 | if err != nil { | |
| 111 | 155 | return err | |
| 112 | 156 | } | |
| @@ -156,7 +200,7 @@ func (j *job) downloadAction(ctx context.Context, num int, ra ResolvedAction, na | |||
| 156 | 200 | } | |
| 157 | 201 | defer func() { _ = os.RemoveAll(tmp) }() | |
| 158 | 202 | archive := filepath.Join(tmp, uuid.NewString()+".tar.gz") | |
| 159 | - | if err := j.downloadArchive(ctx, num, ra.TarballURL, ra.Token, archive); err != nil { | |
| 203 | + | if err := j.downloadArchive(ctx, num, ra, archive); err != nil { | |
| 160 | 204 | return err | |
| 161 | 205 | } | |
| 162 | 206 | staging := filepath.Join(tmp, "_staging") | |
| @@ -10,8 +10,10 @@ import ( | |||||
| 10 | "io" | 10 | "io" | ||
| 11 | "math/rand/v2" | 11 | "math/rand/v2" | ||
| 12 | "net/http" | 12 | "net/http" | ||
| 13 | + | "net/url" | |||
| 13 | "os" | 14 | "os" | ||
| 14 | "path/filepath" | 15 | "path/filepath" | ||
| 16 | + | "strings" | |||
| 15 | "time" | 17 | "time" | ||
| 16 | 18 | ||||
| 17 | "github.com/google/uuid" | 19 | "github.com/google/uuid" | ||
| @@ -41,11 +43,47 @@ type errAccessDenied struct{ url string } | |||||
| 41 | 43 | ||||
| 42 | func (e errAccessDenied) Error() string { return fmt.Sprintf("Access denied to '%s' ()", e.url) } | 44 | func (e errAccessDenied) Error() string { return fmt.Sprintf("Access denied to '%s' ()", e.url) } | ||
| 43 | 45 | ||||
| 44 | - | func (j *job) httpClient() *http.Client { | 46 | + | // originOf is scheme://host[:port] of an absolute http(s) URL, lower case and |
| 45 | - | if c := j.cfg.Actions.HTTPClient; c != nil { | 47 | + | // without the default port, "" for anything else. |
| 46 | - | return c | 48 | + | func originOf(raw string) string { |
| 49 | + | u, err := url.Parse(raw) | |||
| 50 | + | if err != nil || u.Host == "" || u.User != nil || (u.Scheme != "http" && u.Scheme != "https") { | |||
| 51 | + | return "" | |||
| 47 | } | 52 | } | ||
| 48 | - | return &http.Client{} | 53 | + | host, port := strings.ToLower(u.Hostname()), u.Port() |
| 54 | + | if port == "" || (u.Scheme == "http" && port == "80") || (u.Scheme == "https" && port == "443") { | |||
| 55 | + | return u.Scheme + "://" + host | |||
| 56 | + | } | |||
| 57 | + | return u.Scheme + "://" + host + ":" + port | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // archiveClient returns the client that downloads the archives. It follows | |||
| 61 | + | // redirects (GitHub's codeload does redirect) but never carries credentials | |||
| 62 | + | // across an origin: the Authorization header is dropped as soon as the scheme, | |||
| 63 | + | // the host or the port changes, and a redirect from https to http is refused. | |||
| 64 | + | // (net/http alone would keep the header for subdomains and ignore the scheme and | |||
| 65 | + | // the port.) Tests may supply their own transport through Actions.HTTPClient; | |||
| 66 | + | // its redirect policy is replaced. | |||
| 67 | + | func (j *job) archiveClient() *http.Client { | |||
| 68 | + | c := &http.Client{} | |||
| 69 | + | if base := j.cfg.Actions.HTTPClient; base != nil { | |||
| 70 | + | cp := *base | |||
| 71 | + | c = &cp | |||
| 72 | + | } | |||
| 73 | + | c.CheckRedirect = func(req *http.Request, via []*http.Request) error { | |||
| 74 | + | if len(via) >= 10 { | |||
| 75 | + | return errors.New("stopped after 10 redirects") | |||
| 76 | + | } | |||
| 77 | + | prev := via[len(via)-1] | |||
| 78 | + | if prev.URL.Scheme == "https" && req.URL.Scheme != "https" { | |||
| 79 | + | return fmt.Errorf("refusing to follow a redirect from https to %s", req.URL.Scheme) | |||
| 80 | + | } | |||
| 81 | + | if originOf(prev.URL.String()) != originOf(req.URL.String()) { | |||
| 82 | + | req.Header.Del("Authorization") | |||
| 83 | + | } | |||
| 84 | + | return nil | |||
| 85 | + | } | |||
| 86 | + | return c | |||
| 49 | } | 87 | } | ||
| 50 | 88 | ||||
| 51 | // sleepBackoff waits a random 10 to 30 seconds (BackoffTimerHelper. | 89 | // sleepBackoff waits a random 10 to 30 seconds (BackoffTimerHelper. | ||
| @@ -62,10 +100,11 @@ func (j *job) sleepBackoff(ctx context.Context) time.Duration { | |||||
| 62 | // The final error is the one of the last attempt, which is what the runner | 100 | // The final error is the one of the last attempt, which is what the runner | ||
| 63 | // shows (PrepareActionsAsync prints the inner exception of | 101 | // shows (PrepareActionsAsync prints the inner exception of | ||
| 64 | // FailedToDownloadActionException). | 102 | // FailedToDownloadActionException). | ||
| 65 | - | func (j *job) downloadArchive(ctx context.Context, num int, url, token, file string) error { | 103 | + | func (j *job) downloadArchive(ctx context.Context, num int, ra ResolvedAction, file string) error { |
| 104 | + | url := ra.TarballURL | |||
| 66 | var last error | 105 | var last error | ||
| 67 | for attempt := 1; attempt <= downloadAttempts; attempt++ { | 106 | for attempt := 1; attempt <= downloadAttempts; attempt++ { | ||
| 68 | - | err := j.fetch(ctx, url, token, file) | 107 | + | err := j.fetch(ctx, ra, file) |
| 69 | if err == nil { | 108 | if err == nil { | ||
| 70 | return nil | 109 | return nil | ||
| 71 | } | 110 | } | ||
| @@ -95,7 +134,12 @@ func (j *job) downloadArchive(ctx context.Context, num int, url, token, file str | |||||
| 95 | return last | 134 | return last | ||
| 96 | } | 135 | } | ||
| 97 | 136 | ||||
| 98 | - | func (j *job) fetch(ctx context.Context, url, token, file string) error { | 137 | + | func (j *job) fetch(ctx context.Context, ra ResolvedAction, file string) error { |
| 138 | + | url, token := ra.TarballURL, ra.Token | |||
| 139 | + | // The token goes only to the origin the host named, never by the URL alone. | |||
| 140 | + | if token != "" && (ra.AuthOrigin == "" || ra.AuthOrigin != originOf(url)) { | |||
| 141 | + | token = "" | |||
| 142 | + | } | |||
| 99 | ctx, cancel := context.WithTimeout(ctx, downloadTimeout) | 143 | ctx, cancel := context.WithTimeout(ctx, downloadTimeout) | ||
| 100 | defer cancel() | 144 | defer cancel() | ||
| 101 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | 145 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | ||
| @@ -106,7 +150,7 @@ func (j *job) fetch(ctx context.Context, url, token, file string) error { | |||||
| 106 | req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token))) | 150 | req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token))) | ||
| 107 | } | 151 | } | ||
| 108 | req.Header.Set("User-Agent", "ermite-runner") | 152 | req.Header.Set("User-Agent", "ermite-runner") | ||
| 109 | - | resp, err := j.httpClient().Do(req) | 153 | + | resp, err := j.archiveClient().Do(req) |
| 110 | if err != nil { | 154 | if err != nil { | ||
| 111 | return err | 155 | return err | ||
| 112 | } | 156 | } | ||
| @@ -156,7 +200,7 @@ func (j *job) downloadAction(ctx context.Context, num int, ra ResolvedAction, na | |||||
| 156 | } | 200 | } | ||
| 157 | defer func() { _ = os.RemoveAll(tmp) }() | 201 | defer func() { _ = os.RemoveAll(tmp) }() | ||
| 158 | archive := filepath.Join(tmp, uuid.NewString()+".tar.gz") | 202 | archive := filepath.Join(tmp, uuid.NewString()+".tar.gz") | ||
| 159 | - | if err := j.downloadArchive(ctx, num, ra.TarballURL, ra.Token, archive); err != nil { | 203 | + | if err := j.downloadArchive(ctx, num, ra, archive); err != nil { |
| 160 | return err | 204 | return err | ||
| 161 | } | 205 | } | ||
| 162 | staging := filepath.Join(tmp, "_staging") | 206 | staging := filepath.Join(tmp, "_staging") | ||
M internal/executor/actions_test.go +102 -1
| @@ -495,7 +495,7 @@ func (f *fakeForge) resolver(token string) Resolver { | |||
| 495 | 495 | continue | |
| 496 | 496 | } | |
| 497 | 497 | out = append(out, ResolvedAction{Ref: u, NameWithOwner: name, ResolvedSHA: strings.Repeat("a", 40), | |
| 498 | - | TarballURL: f.srv.URL + "/tarball/" + name + "/" + ref, Token: token}) | |
| 498 | + | TarballURL: f.srv.URL + "/tarball/" + name + "/" + ref, Token: token, AuthOrigin: tokenOrigin(token, f.srv.URL)}) | |
| 499 | 499 | } | |
| 500 | 500 | return out, nil | |
| 501 | 501 | } | |
| @@ -657,3 +657,104 @@ func TestRepositoryActionInputsAreMaskedAndTokenNotInEnv(t *testing.T) { | |||
| 657 | 657 | t.Errorf("the archive token is not in the environment of actions:\n%s", got) | |
| 658 | 658 | } | |
| 659 | 659 | } | |
| 660 | + | ||
| 661 | + | func tokenOrigin(token, u string) string { | |
| 662 | + | if token == "" { | |
| 663 | + | return "" | |
| 664 | + | } | |
| 665 | + | return originOf(u) | |
| 666 | + | } | |
| 667 | + | ||
| 668 | + | // GO-002: the token goes to the origin the host named and nowhere else, and | |
| 669 | + | // a redirect never carries it to another origin. | |
| 670 | + | func TestArchiveTokenOnlyToTheNamedOrigin(t *testing.T) { | |
| 671 | + | var gotAuth atomic.Value | |
| 672 | + | other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 673 | + | gotAuth.Store(r.Header.Get("Authorization")) | |
| 674 | + | _, _ = w.Write(tarOf(t, "o-x-1", files{"action.yml": nodeAction, "main.sh": "echo ok\n"})) | |
| 675 | + | })) | |
| 676 | + | defer other.Close() | |
| 677 | + | var firstAuth atomic.Value | |
| 678 | + | redir := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 679 | + | firstAuth.Store(r.Header.Get("Authorization")) | |
| 680 | + | http.Redirect(w, r, other.URL+"/t", http.StatusFound) | |
| 681 | + | })) | |
| 682 | + | defer redir.Close() | |
| 683 | + | mk := func(u, token, origin string) Resolver { | |
| 684 | + | return func(context.Context, []string) ([]ResolvedAction, error) { | |
| 685 | + | return []ResolvedAction{{Ref: "o/x@v1", NameWithOwner: "o/x", ResolvedSHA: strings.Repeat("d", 40), TarballURL: u, Token: token, AuthOrigin: origin}}, nil | |
| 686 | + | } | |
| 687 | + | } | |
| 688 | + | run := func(r Resolver) Result { | |
| 689 | + | _, res := actionRun(t, files{}, " - uses: o/x@v1\n", func(c *Config) { | |
| 690 | + | c.Actions.Resolver = r | |
| 691 | + | c.Actions.Backoff = func() time.Duration { return time.Millisecond } | |
| 692 | + | }) | |
| 693 | + | return res | |
| 694 | + | } | |
| 695 | + | // Redirect to another origin (another port on the same host): the header is | |
| 696 | + | // sent to the first hop and dropped for the second. | |
| 697 | + | if res := run(mk(redir.URL+"/t", "tok", originOf(redir.URL))); res.Conclusion != ConclusionSuccess { | |
| 698 | + | t.Fatalf("%+v", res) | |
| 699 | + | } | |
| 700 | + | if firstAuth.Load() == "" || gotAuth.Load() != "" { | |
| 701 | + | t.Errorf("first hop %q, second hop %q", firstAuth.Load(), gotAuth.Load()) | |
| 702 | + | } | |
| 703 | + | // A token without an AuthOrigin, or for another origin, is never sent. | |
| 704 | + | gotAuth.Store("unset") | |
| 705 | + | if res := run(mk(other.URL+"/t", "tok", "")); res.Conclusion != ConclusionSuccess { | |
| 706 | + | t.Fatalf("%+v", res) | |
| 707 | + | } | |
| 708 | + | if gotAuth.Load() != "" { | |
| 709 | + | t.Errorf("no AuthOrigin: sent %q", gotAuth.Load()) | |
| 710 | + | } | |
| 711 | + | gotAuth.Store("unset") | |
| 712 | + | _ = run(mk(other.URL+"/t", "tok", "https://other.example")) | |
| 713 | + | if gotAuth.Load() != "" { | |
| 714 | + | t.Errorf("another origin: sent %q", gotAuth.Load()) | |
| 715 | + | } | |
| 716 | + | // Without a token (github.com) nothing is sent either. | |
| 717 | + | gotAuth.Store("unset") | |
| 718 | + | _ = run(mk(other.URL+"/t", "", "")) | |
| 719 | + | if gotAuth.Load() != "" { | |
| 720 | + | t.Errorf("anonymous: sent %q", gotAuth.Load()) | |
| 721 | + | } | |
| 722 | + | // The named origin, with the token. | |
| 723 | + | gotAuth.Store("unset") | |
| 724 | + | _ = run(mk(other.URL+"/t", "tok", originOf(other.URL))) | |
| 725 | + | if got, _ := gotAuth.Load().(string); !strings.HasPrefix(got, "Basic ") { | |
| 726 | + | t.Errorf("named origin: %q", got) | |
| 727 | + | } | |
| 728 | + | } | |
| 729 | + | ||
| 730 | + | func TestArchiveRedirectPolicy(t *testing.T) { | |
| 731 | + | j := &job{} | |
| 732 | + | c := j.archiveClient() | |
| 733 | + | mkreq := func(u string) *http.Request { | |
| 734 | + | r, _ := http.NewRequest(http.MethodGet, u, nil) | |
| 735 | + | r.Header.Set("Authorization", "Basic x") | |
| 736 | + | return r | |
| 737 | + | } | |
| 738 | + | for _, tc := range []struct { | |
| 739 | + | from, to string | |
| 740 | + | keep bool | |
| 741 | + | refused bool | |
| 742 | + | }{ | |
| 743 | + | {"https://a.example/x", "https://a.example/y", true, false}, | |
| 744 | + | {"https://a.example/x", "https://cdn.a.example/y", false, false}, // subdomain | |
| 745 | + | {"https://a.example/x", "https://a.example:8443/y", false, false}, // port | |
| 746 | + | {"https://a.example/x", "http://a.example/y", false, true}, // downgrade | |
| 747 | + | {"http://a.example/x", "https://a.example/y", false, false}, // scheme change | |
| 748 | + | {"https://a.example:443/x", "https://a.example/y", true, false}, // same origin | |
| 749 | + | {"https://A.example/x", "https://a.example/y", true, false}, | |
| 750 | + | } { | |
| 751 | + | req := mkreq(tc.to) | |
| 752 | + | err := c.CheckRedirect(req, []*http.Request{mkreq(tc.from)}) | |
| 753 | + | if (err != nil) != tc.refused || (req.Header.Get("Authorization") != "") != tc.keep && !tc.refused { | |
| 754 | + | t.Errorf("%s -> %s: err=%v header=%q", tc.from, tc.to, err, req.Header.Get("Authorization")) | |
| 755 | + | } | |
| 756 | + | } | |
| 757 | + | if err := c.CheckRedirect(mkreq("https://a.example/"), make([]*http.Request, 10)); err == nil { | |
| 758 | + | t.Error("redirect loops must stop") | |
| 759 | + | } | |
| 760 | + | } | |
| @@ -495,7 +495,7 @@ func (f *fakeForge) resolver(token string) Resolver { | |||||
| 495 | continue | 495 | continue | ||
| 496 | } | 496 | } | ||
| 497 | out = append(out, ResolvedAction{Ref: u, NameWithOwner: name, ResolvedSHA: strings.Repeat("a", 40), | 497 | out = append(out, ResolvedAction{Ref: u, NameWithOwner: name, ResolvedSHA: strings.Repeat("a", 40), | ||
| 498 | - | TarballURL: f.srv.URL + "/tarball/" + name + "/" + ref, Token: token}) | 498 | + | TarballURL: f.srv.URL + "/tarball/" + name + "/" + ref, Token: token, AuthOrigin: tokenOrigin(token, f.srv.URL)}) |
| 499 | } | 499 | } | ||
| 500 | return out, nil | 500 | return out, nil | ||
| 501 | } | 501 | } | ||
| @@ -657,3 +657,104 @@ func TestRepositoryActionInputsAreMaskedAndTokenNotInEnv(t *testing.T) { | |||||
| 657 | t.Errorf("the archive token is not in the environment of actions:\n%s", got) | 657 | t.Errorf("the archive token is not in the environment of actions:\n%s", got) | ||
| 658 | } | 658 | } | ||
| 659 | } | 659 | } | ||
| 660 | + | ||||
| 661 | + | func tokenOrigin(token, u string) string { | |||
| 662 | + | if token == "" { | |||
| 663 | + | return "" | |||
| 664 | + | } | |||
| 665 | + | return originOf(u) | |||
| 666 | + | } | |||
| 667 | + | ||||
| 668 | + | // GO-002: the token goes to the origin the host named and nowhere else, and | |||
| 669 | + | // a redirect never carries it to another origin. | |||
| 670 | + | func TestArchiveTokenOnlyToTheNamedOrigin(t *testing.T) { | |||
| 671 | + | var gotAuth atomic.Value | |||
| 672 | + | other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 673 | + | gotAuth.Store(r.Header.Get("Authorization")) | |||
| 674 | + | _, _ = w.Write(tarOf(t, "o-x-1", files{"action.yml": nodeAction, "main.sh": "echo ok\n"})) | |||
| 675 | + | })) | |||
| 676 | + | defer other.Close() | |||
| 677 | + | var firstAuth atomic.Value | |||
| 678 | + | redir := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 679 | + | firstAuth.Store(r.Header.Get("Authorization")) | |||
| 680 | + | http.Redirect(w, r, other.URL+"/t", http.StatusFound) | |||
| 681 | + | })) | |||
| 682 | + | defer redir.Close() | |||
| 683 | + | mk := func(u, token, origin string) Resolver { | |||
| 684 | + | return func(context.Context, []string) ([]ResolvedAction, error) { | |||
| 685 | + | return []ResolvedAction{{Ref: "o/x@v1", NameWithOwner: "o/x", ResolvedSHA: strings.Repeat("d", 40), TarballURL: u, Token: token, AuthOrigin: origin}}, nil | |||
| 686 | + | } | |||
| 687 | + | } | |||
| 688 | + | run := func(r Resolver) Result { | |||
| 689 | + | _, res := actionRun(t, files{}, " - uses: o/x@v1\n", func(c *Config) { | |||
| 690 | + | c.Actions.Resolver = r | |||
| 691 | + | c.Actions.Backoff = func() time.Duration { return time.Millisecond } | |||
| 692 | + | }) | |||
| 693 | + | return res | |||
| 694 | + | } | |||
| 695 | + | // Redirect to another origin (another port on the same host): the header is | |||
| 696 | + | // sent to the first hop and dropped for the second. | |||
| 697 | + | if res := run(mk(redir.URL+"/t", "tok", originOf(redir.URL))); res.Conclusion != ConclusionSuccess { | |||
| 698 | + | t.Fatalf("%+v", res) | |||
| 699 | + | } | |||
| 700 | + | if firstAuth.Load() == "" || gotAuth.Load() != "" { | |||
| 701 | + | t.Errorf("first hop %q, second hop %q", firstAuth.Load(), gotAuth.Load()) | |||
| 702 | + | } | |||
| 703 | + | // A token without an AuthOrigin, or for another origin, is never sent. | |||
| 704 | + | gotAuth.Store("unset") | |||
| 705 | + | if res := run(mk(other.URL+"/t", "tok", "")); res.Conclusion != ConclusionSuccess { | |||
| 706 | + | t.Fatalf("%+v", res) | |||
| 707 | + | } | |||
| 708 | + | if gotAuth.Load() != "" { | |||
| 709 | + | t.Errorf("no AuthOrigin: sent %q", gotAuth.Load()) | |||
| 710 | + | } | |||
| 711 | + | gotAuth.Store("unset") | |||
| 712 | + | _ = run(mk(other.URL+"/t", "tok", "https://other.example")) | |||
| 713 | + | if gotAuth.Load() != "" { | |||
| 714 | + | t.Errorf("another origin: sent %q", gotAuth.Load()) | |||
| 715 | + | } | |||
| 716 | + | // Without a token (github.com) nothing is sent either. | |||
| 717 | + | gotAuth.Store("unset") | |||
| 718 | + | _ = run(mk(other.URL+"/t", "", "")) | |||
| 719 | + | if gotAuth.Load() != "" { | |||
| 720 | + | t.Errorf("anonymous: sent %q", gotAuth.Load()) | |||
| 721 | + | } | |||
| 722 | + | // The named origin, with the token. | |||
| 723 | + | gotAuth.Store("unset") | |||
| 724 | + | _ = run(mk(other.URL+"/t", "tok", originOf(other.URL))) | |||
| 725 | + | if got, _ := gotAuth.Load().(string); !strings.HasPrefix(got, "Basic ") { | |||
| 726 | + | t.Errorf("named origin: %q", got) | |||
| 727 | + | } | |||
| 728 | + | } | |||
| 729 | + | ||||
| 730 | + | func TestArchiveRedirectPolicy(t *testing.T) { | |||
| 731 | + | j := &job{} | |||
| 732 | + | c := j.archiveClient() | |||
| 733 | + | mkreq := func(u string) *http.Request { | |||
| 734 | + | r, _ := http.NewRequest(http.MethodGet, u, nil) | |||
| 735 | + | r.Header.Set("Authorization", "Basic x") | |||
| 736 | + | return r | |||
| 737 | + | } | |||
| 738 | + | for _, tc := range []struct { | |||
| 739 | + | from, to string | |||
| 740 | + | keep bool | |||
| 741 | + | refused bool | |||
| 742 | + | }{ | |||
| 743 | + | {"https://a.example/x", "https://a.example/y", true, false}, | |||
| 744 | + | {"https://a.example/x", "https://cdn.a.example/y", false, false}, // subdomain | |||
| 745 | + | {"https://a.example/x", "https://a.example:8443/y", false, false}, // port | |||
| 746 | + | {"https://a.example/x", "http://a.example/y", false, true}, // downgrade | |||
| 747 | + | {"http://a.example/x", "https://a.example/y", false, false}, // scheme change | |||
| 748 | + | {"https://a.example:443/x", "https://a.example/y", true, false}, // same origin | |||
| 749 | + | {"https://A.example/x", "https://a.example/y", true, false}, | |||
| 750 | + | } { | |||
| 751 | + | req := mkreq(tc.to) | |||
| 752 | + | err := c.CheckRedirect(req, []*http.Request{mkreq(tc.from)}) | |||
| 753 | + | if (err != nil) != tc.refused || (req.Header.Get("Authorization") != "") != tc.keep && !tc.refused { | |||
| 754 | + | t.Errorf("%s -> %s: err=%v header=%q", tc.from, tc.to, err, req.Header.Get("Authorization")) | |||
| 755 | + | } | |||
| 756 | + | } | |||
| 757 | + | if err := c.CheckRedirect(mkreq("https://a.example/"), make([]*http.Request, 10)); err == nil { | |||
| 758 | + | t.Error("redirect loops must stop") | |||
| 759 | + | } | |||
| 760 | + | } | |||
M internal/executor/actions_types.go +3 -0
| @@ -16,6 +16,9 @@ import ( | |||
| 16 | 16 | // the host). The Token is a secret: the executor masks it. | |
| 17 | 17 | type ResolvedAction struct { | |
| 18 | 18 | Ref, NameWithOwner, Path, ResolvedSHA, TarballURL, Token, Error string | |
| 19 | + | // AuthOrigin is the only origin (scheme://host[:port]) the Token is sent to; | |
| 20 | + | // the host sets it. Without it the token is not sent. | |
| 21 | + | AuthOrigin string | |
| 19 | 22 | } | |
| 20 | 23 | ||
| 21 | 24 | // Resolver resolves "owner/repo[/path]@ref" values of uses with the server. It | |
| @@ -16,6 +16,9 @@ import ( | |||||
| 16 | // the host). The Token is a secret: the executor masks it. | 16 | // the host). The Token is a secret: the executor masks it. | ||
| 17 | type ResolvedAction struct { | 17 | type ResolvedAction struct { | ||
| 18 | Ref, NameWithOwner, Path, ResolvedSHA, TarballURL, Token, Error string | 18 | Ref, NameWithOwner, Path, ResolvedSHA, TarballURL, Token, Error string | ||
| 19 | + | // AuthOrigin is the only origin (scheme://host[:port]) the Token is sent to; | |||
| 20 | + | // the host sets it. Without it the token is not sent. | |||
| 21 | + | AuthOrigin string | |||
| 19 | } | 22 | } | ||
| 20 | 23 | ||||
| 21 | // Resolver resolves "owner/repo[/path]@ref" values of uses with the server. It | 24 | // Resolver resolves "owner/repo[/path]@ref" values of uses with the server. It | ||
M internal/guest/resolver.go +1 -1
| @@ -58,7 +58,7 @@ func (r *resolver) resolve(ctx context.Context, uses []string) ([]executor.Resol | |||
| 58 | 58 | for i, a := range resp.Actions { | |
| 59 | 59 | out[i] = executor.ResolvedAction{ | |
| 60 | 60 | Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSHA: a.ResolvedSha, | |
| 61 | - | TarballURL: a.TarballUrl, Token: a.Token, Error: a.Error, | |
| 61 | + | TarballURL: a.TarballUrl, Token: a.Token, AuthOrigin: a.AuthOrigin, Error: a.Error, | |
| 62 | 62 | } | |
| 63 | 63 | } | |
| 64 | 64 | return out, nil | |
| @@ -58,7 +58,7 @@ func (r *resolver) resolve(ctx context.Context, uses []string) ([]executor.Resol | |||||
| 58 | for i, a := range resp.Actions { | 58 | for i, a := range resp.Actions { | ||
| 59 | out[i] = executor.ResolvedAction{ | 59 | out[i] = executor.ResolvedAction{ | ||
| 60 | Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSHA: a.ResolvedSha, | 60 | Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSHA: a.ResolvedSha, | ||
| 61 | - | TarballURL: a.TarballUrl, Token: a.Token, Error: a.Error, | 61 | + | TarballURL: a.TarballUrl, Token: a.Token, AuthOrigin: a.AuthOrigin, Error: a.Error, |
| 62 | } | 62 | } | ||
| 63 | } | 63 | } | ||
| 64 | return out, nil | 64 | return out, nil | ||
M internal/guest/resolver_test.go +1 -1
| @@ -52,7 +52,7 @@ func TestResolverRoundTrip(t *testing.T) { | |||
| 52 | 52 | go func() { | |
| 53 | 53 | defer close(done) | |
| 54 | 54 | _, _, _ = execstream.DriveJob(hostSide, &execpb.Run{}, nil, execstream.JobHandler{ | |
| 55 | - | Resolve: func(r *execpb.ResolveRequest) *execpb.ResolveResponse { | |
| 55 | + | Resolve: func(_ context.Context, r *execpb.ResolveRequest) *execpb.ResolveResponse { | |
| 56 | 56 | if len(r.Uses) == 1 && r.Uses[0] == "bad@v1" { | |
| 57 | 57 | return &execpb.ResolveResponse{Error: "no"} | |
| 58 | 58 | } | |
| @@ -52,7 +52,7 @@ func TestResolverRoundTrip(t *testing.T) { | |||||
| 52 | go func() { | 52 | go func() { | ||
| 53 | defer close(done) | 53 | defer close(done) | ||
| 54 | _, _, _ = execstream.DriveJob(hostSide, &execpb.Run{}, nil, execstream.JobHandler{ | 54 | _, _, _ = execstream.DriveJob(hostSide, &execpb.Run{}, nil, execstream.JobHandler{ | ||
| 55 | - | Resolve: func(r *execpb.ResolveRequest) *execpb.ResolveResponse { | 55 | + | Resolve: func(_ context.Context, r *execpb.ResolveRequest) *execpb.ResolveResponse { |
| 56 | if len(r.Uses) == 1 && r.Uses[0] == "bad@v1" { | 56 | if len(r.Uses) == 1 && r.Uses[0] == "bad@v1" { | ||
| 57 | return &execpb.ResolveResponse{Error: "no"} | 57 | return &execpb.ResolveResponse{Error: "no"} | ||
| 58 | } | 58 | } | ||
M internal/host/job.go +5 -0
| @@ -71,6 +71,10 @@ type jobRun struct { | |||
| 71 | 71 | // contexts is Job.Contexts as handed to the executor: with the server URLs | |
| 72 | 72 | // pointed at the runner's URL when guest forge access is on. | |
| 73 | 73 | contexts string | |
| 74 | + | // Origins that are ermite for the archive tokens (see resolveResponse), and | |
| 75 | + | // the budget of resolution requests of the job (see resolveActions). | |
| 76 | + | publicOrigin, serverOrigin, guestForge string | |
| 77 | + | resolveRounds, resolveRefs int | |
| 74 | 78 | // hard stops the sandbox at once; set before the session starts. | |
| 75 | 79 | hard func() | |
| 76 | 80 | result *execpb.JobResult | |
| @@ -567,6 +571,7 @@ func failure() outcome { return outcome{conclusion: ermite.CiConclusion_CI_CONCL | |||
| 567 | 571 | // execute starts the sandbox, runs the job in it and returns how it ended. | |
| 568 | 572 | func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct{}) outcome { | |
| 569 | 573 | var err error | |
| 574 | + | j.setOrigins(j.job.Contexts, rt.Cfg.URL, rt.Cfg.GuestForgeOrigin) | |
| 570 | 575 | if j.contexts, err = RewriteServerURLs(j.job.Contexts, rt.Cfg.GuestForgeOrigin); err != nil { | |
| 571 | 576 | j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err)) | |
| 572 | 577 | return failure() | |
| @@ -71,6 +71,10 @@ type jobRun struct { | |||||
| 71 | // contexts is Job.Contexts as handed to the executor: with the server URLs | 71 | // contexts is Job.Contexts as handed to the executor: with the server URLs | ||
| 72 | // pointed at the runner's URL when guest forge access is on. | 72 | // pointed at the runner's URL when guest forge access is on. | ||
| 73 | contexts string | 73 | contexts string | ||
| 74 | + | // Origins that are ermite for the archive tokens (see resolveResponse), and | |||
| 75 | + | // the budget of resolution requests of the job (see resolveActions). | |||
| 76 | + | publicOrigin, serverOrigin, guestForge string | |||
| 77 | + | resolveRounds, resolveRefs int | |||
| 74 | // hard stops the sandbox at once; set before the session starts. | 78 | // hard stops the sandbox at once; set before the session starts. | ||
| 75 | hard func() | 79 | hard func() | ||
| 76 | result *execpb.JobResult | 80 | result *execpb.JobResult | ||
| @@ -567,6 +571,7 @@ func failure() outcome { return outcome{conclusion: ermite.CiConclusion_CI_CONCL | |||||
| 567 | // execute starts the sandbox, runs the job in it and returns how it ended. | 571 | // execute starts the sandbox, runs the job in it and returns how it ended. | ||
| 568 | func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct{}) outcome { | 572 | func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct{}) outcome { | ||
| 569 | var err error | 573 | var err error | ||
| 574 | + | j.setOrigins(j.job.Contexts, rt.Cfg.URL, rt.Cfg.GuestForgeOrigin) | |||
| 570 | if j.contexts, err = RewriteServerURLs(j.job.Contexts, rt.Cfg.GuestForgeOrigin); err != nil { | 575 | if j.contexts, err = RewriteServerURLs(j.job.Contexts, rt.Cfg.GuestForgeOrigin); err != nil { | ||
| 571 | j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err)) | 576 | j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err)) | ||
| 572 | return failure() | 577 | return failure() | ||
M internal/host/resolve.go +165 -20
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "encoding/json" | |
| 8 | 8 | "fmt" | |
| 9 | + | "strings" | |
| 9 | 10 | ||
| 10 | 11 | "connectrpc.com/connect" | |
| 11 | 12 | ||
| @@ -14,62 +15,197 @@ import ( | |||
| 14 | 15 | "bnema.dev/ermite-runner/internal/execstream/execpb" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | - | // maxResolveRefs is the limit of ResolveActionsRequest.refs. | |
| 18 | - | const maxResolveRefs = 100 | |
| 18 | + | // Limits of what the sandbox may ask the server through the host. The sandbox | |
| 19 | + | // runs untrusted code, so the host does not trust its requests. | |
| 20 | + | // | |
| 21 | + | // A job asks once per level of the actions it uses (the runner's depth limit is | |
| 22 | + | // 9, so at most 10 rounds for the steps of the job and the repository | |
| 23 | + | // composites below them), and once more for every local composite action that | |
| 24 | + | // has repository actions in its steps, when its step runs: a workflow has at | |
| 25 | + | // most 1,000 steps. The budget below is generous for that and bounded: | |
| 26 | + | // maxResolveRounds requests and maxResolveRefsTotal references per job, whatever | |
| 27 | + | // they ask. (Local composites restart the depth at 1 as in the runner; if the | |
| 28 | + | // recording of ermite-conformance corpus/act-composite-depth shows that | |
| 29 | + | // github.com limits them, the executor will count them, and these limits stay | |
| 30 | + | // as the safety net.) | |
| 31 | + | const ( | |
| 32 | + | maxResolveRefs = 100 // per request: ResolveActionsRequest.refs | |
| 33 | + | maxResolveRounds = 128 // per job | |
| 34 | + | maxResolveRefsTotal = 2048 // per job | |
| 35 | + | maxRefLen = 512 | |
| 36 | + | maxSegmentLen = 100 | |
| 37 | + | maxRefNameLen = 255 | |
| 38 | + | ) | |
| 39 | + | ||
| 40 | + | // checkUses validates a "uses" value of a request: owner/repo[/path]@ref with | |
| 41 | + | // the characters repository names, paths and refs have. The server decides | |
| 42 | + | // whether the action exists; this keeps garbage and oversized strings from | |
| 43 | + | // reaching it. | |
| 44 | + | func checkUses(u string) error { | |
| 45 | + | if u == "" || len(u) > maxRefLen { | |
| 46 | + | return fmt.Errorf("%q is not a valid action reference", cut(u)) | |
| 47 | + | } | |
| 48 | + | name, ref, ok := strings.Cut(u, "@") | |
| 49 | + | if !ok || ref == "" || len(ref) > maxRefNameLen || strings.ContainsFunc(ref, badRefRune) { | |
| 50 | + | return fmt.Errorf("%q is not a valid action reference", cut(u)) | |
| 51 | + | } | |
| 52 | + | segs := strings.Split(name, "/") | |
| 53 | + | if len(segs) < 2 { | |
| 54 | + | return fmt.Errorf("%q is not a valid action reference", cut(u)) | |
| 55 | + | } | |
| 56 | + | for _, seg := range segs { | |
| 57 | + | if seg == "" || seg == "." || seg == ".." || len(seg) > maxSegmentLen || strings.ContainsFunc(seg, badSegmentRune) { | |
| 58 | + | return fmt.Errorf("%q is not a valid action reference", cut(u)) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | return nil | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func cut(s string) string { | |
| 65 | + | if len(s) > 80 { | |
| 66 | + | return s[:80] + "..." | |
| 67 | + | } | |
| 68 | + | return s | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func badSegmentRune(r rune) bool { | |
| 72 | + | return (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '-' && r != '_' && r != '.' | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // badRefRune: a ref is a branch, a tag or a sha; no control characters, spaces | |
| 76 | + | // or characters git refuses. | |
| 77 | + | func badRefRune(r rune) bool { | |
| 78 | + | return r <= ' ' || r == 0x7f || strings.ContainsRune("~^:?*[\\@", r) // "@": a "uses" has exactly one (action.ParseUses) | |
| 79 | + | } | |
| 19 | 80 | ||
| 20 | 81 | // resolveActions answers a ResolveRequest of the executor with | |
| 21 | 82 | // RunnerService.ResolveActions. The job token stays here: the executor only | |
| 22 | 83 | // gets what the server returns (the per-archive tokens included, which it | |
| 23 | - | // masks). A call that fails without a verdict is retried like the other job | |
| 24 | - | // calls; a failure of the call itself is reported to the executor, which fails | |
| 25 | - | // "Set up job" (ActionManager: FailedToResolveActionDownloadInfoException). | |
| 26 | - | func (j *jobRun) resolveActions(req *execpb.ResolveRequest) *execpb.ResolveResponse { | |
| 84 | + | // masks). Requests come one at a time (see execstream.JobHandler.Resolve). A | |
| 85 | + | // call that fails without a verdict is retried like the other job calls; a | |
| 86 | + | // failure of the call itself is reported to the executor, which fails "Set up | |
| 87 | + | // job" (ActionManager: FailedToResolveActionDownloadInfoException). ctx ends | |
| 88 | + | // with the session. | |
| 89 | + | func (j *jobRun) resolveActions(ctx context.Context, req *execpb.ResolveRequest) *execpb.ResolveResponse { | |
| 90 | + | fail := func(format string, a ...any) *execpb.ResolveResponse { | |
| 91 | + | return &execpb.ResolveResponse{Error: fmt.Sprintf(format, a...)} | |
| 92 | + | } | |
| 93 | + | if len(req.Uses) == 0 { | |
| 94 | + | return fail("an empty request for action resolution") | |
| 95 | + | } | |
| 27 | 96 | if len(req.Uses) > maxResolveRefs { | |
| 28 | - | return &execpb.ResolveResponse{Error: fmt.Sprintf("cannot resolve %d actions at once (the limit is %d)", len(req.Uses), maxResolveRefs)} | |
| 97 | + | return fail("cannot resolve %d actions at once (the limit is %d)", len(req.Uses), maxResolveRefs) | |
| 98 | + | } | |
| 99 | + | for _, u := range req.Uses { | |
| 100 | + | if err := checkUses(u); err != nil { | |
| 101 | + | return fail("%v", err) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | j.mu.Lock() | |
| 105 | + | j.resolveRounds++ | |
| 106 | + | j.resolveRefs += len(req.Uses) | |
| 107 | + | over := j.resolveRounds > maxResolveRounds || j.resolveRefs > maxResolveRefsTotal | |
| 108 | + | j.mu.Unlock() | |
| 109 | + | if over { | |
| 110 | + | return fail("this job asked to resolve too many actions (the limits are %d requests and %d references)", maxResolveRounds, maxResolveRefsTotal) | |
| 29 | 111 | } | |
| 30 | 112 | refs := make([]*ermite.ActionRef, len(req.Uses)) | |
| 31 | 113 | for i, u := range req.Uses { | |
| 32 | 114 | refs[i] = &ermite.ActionRef{Uses: u} | |
| 33 | 115 | } | |
| 116 | + | // The job ending, or the session, stops the call. | |
| 117 | + | ctx, cancel := context.WithCancel(ctx) | |
| 118 | + | defer cancel() | |
| 119 | + | stop := context.AfterFunc(j.ctx, cancel) | |
| 120 | + | defer stop() | |
| 34 | 121 | b := backoff{min: j.t.BackoffMin, max: j.t.BackoffMax} | |
| 35 | 122 | for attempt := 1; ; attempt++ { | |
| 36 | - | cctx, cancel := context.WithTimeout(j.ctx, j.t.CallTimeout) | |
| 123 | + | cctx, ccancel := context.WithTimeout(ctx, j.t.CallTimeout) | |
| 37 | 124 | resp, err := j.client.ResolveActions(cctx, connect.NewRequest(&ermite.ResolveActionsRequest{JobId: j.job.JobId, Refs: refs})) | |
| 38 | - | cancel() | |
| 125 | + | ccancel() | |
| 39 | 126 | if err == nil { | |
| 40 | - | return resolveResponse(resp.Msg, len(req.Uses)) | |
| 127 | + | return j.resolveResponse(resp.Msg, len(req.Uses)) | |
| 41 | 128 | } | |
| 42 | - | if j.ctx.Err() != nil { | |
| 43 | - | return &execpb.ResolveResponse{Error: "the job was cancelled while its actions were resolved"} | |
| 129 | + | if ctx.Err() != nil { | |
| 130 | + | return fail("the job was cancelled while its actions were resolved") | |
| 44 | 131 | } | |
| 45 | 132 | switch classify(err) { | |
| 46 | 133 | case kindEnded, kindAbandoned: | |
| 47 | 134 | j.abandon(err) | |
| 48 | - | return &execpb.ResolveResponse{Error: "the server ended the job"} | |
| 135 | + | return fail("the server ended the job") | |
| 49 | 136 | case kindPermanent: | |
| 50 | - | return &execpb.ResolveResponse{Error: fmt.Sprintf("resolving actions failed: %s", connect.CodeOf(err))} | |
| 137 | + | return fail("resolving actions failed: %s", connect.CodeOf(err)) | |
| 51 | 138 | } | |
| 52 | 139 | j.log.Warn().Err(err).Int("attempt", attempt).Msg("ResolveActions failed") | |
| 53 | - | if attempt >= 3 || !sleep(j.ctx, b.next()) { | |
| 54 | - | return &execpb.ResolveResponse{Error: fmt.Sprintf("resolving actions failed: %s", connect.CodeOf(err))} | |
| 140 | + | if attempt >= 3 || !sleep(ctx, b.next()) { | |
| 141 | + | return fail("resolving actions failed: %s", connect.CodeOf(err)) | |
| 55 | 142 | } | |
| 56 | 143 | } | |
| 57 | 144 | } | |
| 58 | 145 | ||
| 59 | - | func resolveResponse(m *ermite.ResolveActionsResponse, want int) *execpb.ResolveResponse { | |
| 146 | + | // resolveResponse converts the answer of the server. The token of an archive is | |
| 147 | + | // a secret that the sandbox sends in an Authorization header, so it only goes | |
| 148 | + | // with an archive of the ermite source whose tarball URL is on an origin this | |
| 149 | + | // host knows to be ermite: the public origin of the job (github.server_url) or | |
| 150 | + | // the origin of the runner's own URL. The origin the sandbox must send it to is | |
| 151 | + | // AuthOrigin; with guest forge access the tarball URL on the public origin is | |
| 152 | + | // rewritten to the runner's, like the URLs of the contexts. A token that does | |
| 153 | + | // not satisfy this is dropped (the download then fails like an unauthorised | |
| 154 | + | // one). | |
| 155 | + | func (j *jobRun) resolveResponse(m *ermite.ResolveActionsResponse, want int) *execpb.ResolveResponse { | |
| 60 | 156 | if len(m.Actions) != want { | |
| 61 | 157 | return &execpb.ResolveResponse{Error: fmt.Sprintf("the server resolved %d actions out of %d", len(m.Actions), want)} | |
| 62 | 158 | } | |
| 63 | 159 | out := &execpb.ResolveResponse{} | |
| 64 | 160 | for _, a := range m.Actions { | |
| 65 | - | out.Actions = append(out.Actions, &execpb.ResolvedAction{ | |
| 161 | + | ra := &execpb.ResolvedAction{ | |
| 66 | 162 | Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSha: a.ResolvedSha, | |
| 67 | - | TarballUrl: a.TarballUrl, Token: a.Token, Source: a.Source.String(), Error: a.Error, | |
| 68 | - | }) | |
| 163 | + | TarballUrl: a.TarballUrl, Source: a.Source.String(), Error: a.Error, | |
| 164 | + | } | |
| 165 | + | if a.Source == ermite.ActionSource_ACTION_SOURCE_ERMITE { | |
| 166 | + | if origin := originOf(a.TarballUrl); origin != "" { | |
| 167 | + | if j.guestForge != "" && origin == j.publicOrigin { | |
| 168 | + | ra.TarballUrl = j.guestForge + strings.TrimPrefix(a.TarballUrl, a.TarballUrl[:strings.Index(a.TarballUrl, "://")+3+hostLen(a.TarballUrl)]) | |
| 169 | + | origin = j.guestForge | |
| 170 | + | } | |
| 171 | + | if a.Token != "" && (origin == j.guestForge || origin == j.publicOrigin || origin == j.serverOrigin) { | |
| 172 | + | ra.Token, ra.AuthOrigin = a.Token, origin | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | out.Actions = append(out.Actions, ra) | |
| 69 | 177 | } | |
| 70 | 178 | return out | |
| 71 | 179 | } | |
| 72 | 180 | ||
| 181 | + | // setOrigins records the origins that are ermite for this job: the public one | |
| 182 | + | // (the origin of github.server_url as the server sent it), the runner's own | |
| 183 | + | // (ERMITE_RUNNER_URL) and the guest forge origin, "" when it is off. | |
| 184 | + | func (j *jobRun) setOrigins(contexts, serverURL, guestForge string) { | |
| 185 | + | j.serverOrigin = originOf(serverURL) | |
| 186 | + | j.guestForge = guestForge | |
| 187 | + | if guestForge != "" { | |
| 188 | + | j.guestForge = originOf(guestForge) | |
| 189 | + | } | |
| 190 | + | if top, err := parseObject([]byte(contexts)); err == nil { | |
| 191 | + | for _, m := range top { | |
| 192 | + | if m.key != "github" { | |
| 193 | + | continue | |
| 194 | + | } | |
| 195 | + | gh, err := parseObject(m.val) | |
| 196 | + | if err != nil { | |
| 197 | + | break | |
| 198 | + | } | |
| 199 | + | for _, g := range gh { | |
| 200 | + | var s string | |
| 201 | + | if g.key == "server_url" && json.Unmarshal(g.val, &s) == nil { | |
| 202 | + | j.publicOrigin = originOf(s) | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 73 | 209 | // SetGitHubToken returns the contexts JSON of a job with github.token set to | |
| 74 | 210 | // token (the job repository token, Job.github_token). The key is added first, | |
| 75 | 211 | // like the runner does (ExecutionContext.cs:1023: token, then what the server | |
| @@ -119,3 +255,12 @@ func SetGitHubToken(contexts, token string) (string, error) { | |||
| 119 | 255 | out, err := encodeObject(top) | |
| 120 | 256 | return string(out), err | |
| 121 | 257 | } | |
| 258 | + | ||
| 259 | + | // hostLen is the length of the authority of an absolute URL. | |
| 260 | + | func hostLen(u string) int { | |
| 261 | + | rest := u[strings.Index(u, "://")+3:] | |
| 262 | + | if i := strings.IndexAny(rest, "/?#"); i >= 0 { | |
| 263 | + | return i | |
| 264 | + | } | |
| 265 | + | return len(rest) | |
| 266 | + | } | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "context" | 6 | "context" | ||
| 7 | "encoding/json" | 7 | "encoding/json" | ||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | + | "strings" | |||
| 9 | 10 | ||||
| 10 | "connectrpc.com/connect" | 11 | "connectrpc.com/connect" | ||
| 11 | 12 | ||||
| @@ -14,62 +15,197 @@ import ( | |||||
| 14 | "bnema.dev/ermite-runner/internal/execstream/execpb" | 15 | "bnema.dev/ermite-runner/internal/execstream/execpb" | ||
| 15 | ) | 16 | ) | ||
| 16 | 17 | ||||
| 17 | - | // maxResolveRefs is the limit of ResolveActionsRequest.refs. | 18 | + | // Limits of what the sandbox may ask the server through the host. The sandbox |
| 18 | - | const maxResolveRefs = 100 | 19 | + | // runs untrusted code, so the host does not trust its requests. |
| 20 | + | // | |||
| 21 | + | // A job asks once per level of the actions it uses (the runner's depth limit is | |||
| 22 | + | // 9, so at most 10 rounds for the steps of the job and the repository | |||
| 23 | + | // composites below them), and once more for every local composite action that | |||
| 24 | + | // has repository actions in its steps, when its step runs: a workflow has at | |||
| 25 | + | // most 1,000 steps. The budget below is generous for that and bounded: | |||
| 26 | + | // maxResolveRounds requests and maxResolveRefsTotal references per job, whatever | |||
| 27 | + | // they ask. (Local composites restart the depth at 1 as in the runner; if the | |||
| 28 | + | // recording of ermite-conformance corpus/act-composite-depth shows that | |||
| 29 | + | // github.com limits them, the executor will count them, and these limits stay | |||
| 30 | + | // as the safety net.) | |||
| 31 | + | const ( | |||
| 32 | + | maxResolveRefs = 100 // per request: ResolveActionsRequest.refs | |||
| 33 | + | maxResolveRounds = 128 // per job | |||
| 34 | + | maxResolveRefsTotal = 2048 // per job | |||
| 35 | + | maxRefLen = 512 | |||
| 36 | + | maxSegmentLen = 100 | |||
| 37 | + | maxRefNameLen = 255 | |||
| 38 | + | ) | |||
| 39 | + | ||||
| 40 | + | // checkUses validates a "uses" value of a request: owner/repo[/path]@ref with | |||
| 41 | + | // the characters repository names, paths and refs have. The server decides | |||
| 42 | + | // whether the action exists; this keeps garbage and oversized strings from | |||
| 43 | + | // reaching it. | |||
| 44 | + | func checkUses(u string) error { | |||
| 45 | + | if u == "" || len(u) > maxRefLen { | |||
| 46 | + | return fmt.Errorf("%q is not a valid action reference", cut(u)) | |||
| 47 | + | } | |||
| 48 | + | name, ref, ok := strings.Cut(u, "@") | |||
| 49 | + | if !ok || ref == "" || len(ref) > maxRefNameLen || strings.ContainsFunc(ref, badRefRune) { | |||
| 50 | + | return fmt.Errorf("%q is not a valid action reference", cut(u)) | |||
| 51 | + | } | |||
| 52 | + | segs := strings.Split(name, "/") | |||
| 53 | + | if len(segs) < 2 { | |||
| 54 | + | return fmt.Errorf("%q is not a valid action reference", cut(u)) | |||
| 55 | + | } | |||
| 56 | + | for _, seg := range segs { | |||
| 57 | + | if seg == "" || seg == "." || seg == ".." || len(seg) > maxSegmentLen || strings.ContainsFunc(seg, badSegmentRune) { | |||
| 58 | + | return fmt.Errorf("%q is not a valid action reference", cut(u)) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | return nil | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func cut(s string) string { | |||
| 65 | + | if len(s) > 80 { | |||
| 66 | + | return s[:80] + "..." | |||
| 67 | + | } | |||
| 68 | + | return s | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func badSegmentRune(r rune) bool { | |||
| 72 | + | return (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '-' && r != '_' && r != '.' | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // badRefRune: a ref is a branch, a tag or a sha; no control characters, spaces | |||
| 76 | + | // or characters git refuses. | |||
| 77 | + | func badRefRune(r rune) bool { | |||
| 78 | + | return r <= ' ' || r == 0x7f || strings.ContainsRune("~^:?*[\\@", r) // "@": a "uses" has exactly one (action.ParseUses) | |||
| 79 | + | } | |||
| 19 | 80 | ||||
| 20 | // resolveActions answers a ResolveRequest of the executor with | 81 | // resolveActions answers a ResolveRequest of the executor with | ||
| 21 | // RunnerService.ResolveActions. The job token stays here: the executor only | 82 | // RunnerService.ResolveActions. The job token stays here: the executor only | ||
| 22 | // gets what the server returns (the per-archive tokens included, which it | 83 | // gets what the server returns (the per-archive tokens included, which it | ||
| 23 | - | // masks). A call that fails without a verdict is retried like the other job | 84 | + | // masks). Requests come one at a time (see execstream.JobHandler.Resolve). A |
| 24 | - | // calls; a failure of the call itself is reported to the executor, which fails | 85 | + | // call that fails without a verdict is retried like the other job calls; a |
| 25 | - | // "Set up job" (ActionManager: FailedToResolveActionDownloadInfoException). | 86 | + | // failure of the call itself is reported to the executor, which fails "Set up |
| 26 | - | func (j *jobRun) resolveActions(req *execpb.ResolveRequest) *execpb.ResolveResponse { | 87 | + | // job" (ActionManager: FailedToResolveActionDownloadInfoException). ctx ends |
| 88 | + | // with the session. | |||
| 89 | + | func (j *jobRun) resolveActions(ctx context.Context, req *execpb.ResolveRequest) *execpb.ResolveResponse { | |||
| 90 | + | fail := func(format string, a ...any) *execpb.ResolveResponse { | |||
| 91 | + | return &execpb.ResolveResponse{Error: fmt.Sprintf(format, a...)} | |||
| 92 | + | } | |||
| 93 | + | if len(req.Uses) == 0 { | |||
| 94 | + | return fail("an empty request for action resolution") | |||
| 95 | + | } | |||
| 27 | if len(req.Uses) > maxResolveRefs { | 96 | if len(req.Uses) > maxResolveRefs { | ||
| 28 | - | return &execpb.ResolveResponse{Error: fmt.Sprintf("cannot resolve %d actions at once (the limit is %d)", len(req.Uses), maxResolveRefs)} | 97 | + | return fail("cannot resolve %d actions at once (the limit is %d)", len(req.Uses), maxResolveRefs) |
| 98 | + | } | |||
| 99 | + | for _, u := range req.Uses { | |||
| 100 | + | if err := checkUses(u); err != nil { | |||
| 101 | + | return fail("%v", err) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | j.mu.Lock() | |||
| 105 | + | j.resolveRounds++ | |||
| 106 | + | j.resolveRefs += len(req.Uses) | |||
| 107 | + | over := j.resolveRounds > maxResolveRounds || j.resolveRefs > maxResolveRefsTotal | |||
| 108 | + | j.mu.Unlock() | |||
| 109 | + | if over { | |||
| 110 | + | return fail("this job asked to resolve too many actions (the limits are %d requests and %d references)", maxResolveRounds, maxResolveRefsTotal) | |||
| 29 | } | 111 | } | ||
| 30 | refs := make([]*ermite.ActionRef, len(req.Uses)) | 112 | refs := make([]*ermite.ActionRef, len(req.Uses)) | ||
| 31 | for i, u := range req.Uses { | 113 | for i, u := range req.Uses { | ||
| 32 | refs[i] = &ermite.ActionRef{Uses: u} | 114 | refs[i] = &ermite.ActionRef{Uses: u} | ||
| 33 | } | 115 | } | ||
| 116 | + | // The job ending, or the session, stops the call. | |||
| 117 | + | ctx, cancel := context.WithCancel(ctx) | |||
| 118 | + | defer cancel() | |||
| 119 | + | stop := context.AfterFunc(j.ctx, cancel) | |||
| 120 | + | defer stop() | |||
| 34 | b := backoff{min: j.t.BackoffMin, max: j.t.BackoffMax} | 121 | b := backoff{min: j.t.BackoffMin, max: j.t.BackoffMax} | ||
| 35 | for attempt := 1; ; attempt++ { | 122 | for attempt := 1; ; attempt++ { | ||
| 36 | - | cctx, cancel := context.WithTimeout(j.ctx, j.t.CallTimeout) | 123 | + | cctx, ccancel := context.WithTimeout(ctx, j.t.CallTimeout) |
| 37 | resp, err := j.client.ResolveActions(cctx, connect.NewRequest(&ermite.ResolveActionsRequest{JobId: j.job.JobId, Refs: refs})) | 124 | resp, err := j.client.ResolveActions(cctx, connect.NewRequest(&ermite.ResolveActionsRequest{JobId: j.job.JobId, Refs: refs})) | ||
| 38 | - | cancel() | 125 | + | ccancel() |
| 39 | if err == nil { | 126 | if err == nil { | ||
| 40 | - | return resolveResponse(resp.Msg, len(req.Uses)) | 127 | + | return j.resolveResponse(resp.Msg, len(req.Uses)) |
| 41 | } | 128 | } | ||
| 42 | - | if j.ctx.Err() != nil { | 129 | + | if ctx.Err() != nil { |
| 43 | - | return &execpb.ResolveResponse{Error: "the job was cancelled while its actions were resolved"} | 130 | + | return fail("the job was cancelled while its actions were resolved") |
| 44 | } | 131 | } | ||
| 45 | switch classify(err) { | 132 | switch classify(err) { | ||
| 46 | case kindEnded, kindAbandoned: | 133 | case kindEnded, kindAbandoned: | ||
| 47 | j.abandon(err) | 134 | j.abandon(err) | ||
| 48 | - | return &execpb.ResolveResponse{Error: "the server ended the job"} | 135 | + | return fail("the server ended the job") |
| 49 | case kindPermanent: | 136 | case kindPermanent: | ||
| 50 | - | return &execpb.ResolveResponse{Error: fmt.Sprintf("resolving actions failed: %s", connect.CodeOf(err))} | 137 | + | return fail("resolving actions failed: %s", connect.CodeOf(err)) |
| 51 | } | 138 | } | ||
| 52 | j.log.Warn().Err(err).Int("attempt", attempt).Msg("ResolveActions failed") | 139 | j.log.Warn().Err(err).Int("attempt", attempt).Msg("ResolveActions failed") | ||
| 53 | - | if attempt >= 3 || !sleep(j.ctx, b.next()) { | 140 | + | if attempt >= 3 || !sleep(ctx, b.next()) { |
| 54 | - | return &execpb.ResolveResponse{Error: fmt.Sprintf("resolving actions failed: %s", connect.CodeOf(err))} | 141 | + | return fail("resolving actions failed: %s", connect.CodeOf(err)) |
| 55 | } | 142 | } | ||
| 56 | } | 143 | } | ||
| 57 | } | 144 | } | ||
| 58 | 145 | ||||
| 59 | - | func resolveResponse(m *ermite.ResolveActionsResponse, want int) *execpb.ResolveResponse { | 146 | + | // resolveResponse converts the answer of the server. The token of an archive is |
| 147 | + | // a secret that the sandbox sends in an Authorization header, so it only goes | |||
| 148 | + | // with an archive of the ermite source whose tarball URL is on an origin this | |||
| 149 | + | // host knows to be ermite: the public origin of the job (github.server_url) or | |||
| 150 | + | // the origin of the runner's own URL. The origin the sandbox must send it to is | |||
| 151 | + | // AuthOrigin; with guest forge access the tarball URL on the public origin is | |||
| 152 | + | // rewritten to the runner's, like the URLs of the contexts. A token that does | |||
| 153 | + | // not satisfy this is dropped (the download then fails like an unauthorised | |||
| 154 | + | // one). | |||
| 155 | + | func (j *jobRun) resolveResponse(m *ermite.ResolveActionsResponse, want int) *execpb.ResolveResponse { | |||
| 60 | if len(m.Actions) != want { | 156 | if len(m.Actions) != want { | ||
| 61 | return &execpb.ResolveResponse{Error: fmt.Sprintf("the server resolved %d actions out of %d", len(m.Actions), want)} | 157 | return &execpb.ResolveResponse{Error: fmt.Sprintf("the server resolved %d actions out of %d", len(m.Actions), want)} | ||
| 62 | } | 158 | } | ||
| 63 | out := &execpb.ResolveResponse{} | 159 | out := &execpb.ResolveResponse{} | ||
| 64 | for _, a := range m.Actions { | 160 | for _, a := range m.Actions { | ||
| 65 | - | out.Actions = append(out.Actions, &execpb.ResolvedAction{ | 161 | + | ra := &execpb.ResolvedAction{ |
| 66 | Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSha: a.ResolvedSha, | 162 | Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSha: a.ResolvedSha, | ||
| 67 | - | TarballUrl: a.TarballUrl, Token: a.Token, Source: a.Source.String(), Error: a.Error, | 163 | + | TarballUrl: a.TarballUrl, Source: a.Source.String(), Error: a.Error, |
| 68 | - | }) | 164 | + | } |
| 165 | + | if a.Source == ermite.ActionSource_ACTION_SOURCE_ERMITE { | |||
| 166 | + | if origin := originOf(a.TarballUrl); origin != "" { | |||
| 167 | + | if j.guestForge != "" && origin == j.publicOrigin { | |||
| 168 | + | ra.TarballUrl = j.guestForge + strings.TrimPrefix(a.TarballUrl, a.TarballUrl[:strings.Index(a.TarballUrl, "://")+3+hostLen(a.TarballUrl)]) | |||
| 169 | + | origin = j.guestForge | |||
| 170 | + | } | |||
| 171 | + | if a.Token != "" && (origin == j.guestForge || origin == j.publicOrigin || origin == j.serverOrigin) { | |||
| 172 | + | ra.Token, ra.AuthOrigin = a.Token, origin | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | out.Actions = append(out.Actions, ra) | |||
| 69 | } | 177 | } | ||
| 70 | return out | 178 | return out | ||
| 71 | } | 179 | } | ||
| 72 | 180 | ||||
| 181 | + | // setOrigins records the origins that are ermite for this job: the public one | |||
| 182 | + | // (the origin of github.server_url as the server sent it), the runner's own | |||
| 183 | + | // (ERMITE_RUNNER_URL) and the guest forge origin, "" when it is off. | |||
| 184 | + | func (j *jobRun) setOrigins(contexts, serverURL, guestForge string) { | |||
| 185 | + | j.serverOrigin = originOf(serverURL) | |||
| 186 | + | j.guestForge = guestForge | |||
| 187 | + | if guestForge != "" { | |||
| 188 | + | j.guestForge = originOf(guestForge) | |||
| 189 | + | } | |||
| 190 | + | if top, err := parseObject([]byte(contexts)); err == nil { | |||
| 191 | + | for _, m := range top { | |||
| 192 | + | if m.key != "github" { | |||
| 193 | + | continue | |||
| 194 | + | } | |||
| 195 | + | gh, err := parseObject(m.val) | |||
| 196 | + | if err != nil { | |||
| 197 | + | break | |||
| 198 | + | } | |||
| 199 | + | for _, g := range gh { | |||
| 200 | + | var s string | |||
| 201 | + | if g.key == "server_url" && json.Unmarshal(g.val, &s) == nil { | |||
| 202 | + | j.publicOrigin = originOf(s) | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | ||||
| 73 | // SetGitHubToken returns the contexts JSON of a job with github.token set to | 209 | // SetGitHubToken returns the contexts JSON of a job with github.token set to | ||
| 74 | // token (the job repository token, Job.github_token). The key is added first, | 210 | // token (the job repository token, Job.github_token). The key is added first, | ||
| 75 | // like the runner does (ExecutionContext.cs:1023: token, then what the server | 211 | // like the runner does (ExecutionContext.cs:1023: token, then what the server | ||
| @@ -119,3 +255,12 @@ func SetGitHubToken(contexts, token string) (string, error) { | |||||
| 119 | out, err := encodeObject(top) | 255 | out, err := encodeObject(top) | ||
| 120 | return string(out), err | 256 | return string(out), err | ||
| 121 | } | 257 | } | ||
| 258 | + | ||||
| 259 | + | // hostLen is the length of the authority of an absolute URL. | |||
| 260 | + | func hostLen(u string) int { | |||
| 261 | + | rest := u[strings.Index(u, "://")+3:] | |||
| 262 | + | if i := strings.IndexAny(rest, "/?#"); i >= 0 { | |||
| 263 | + | return i | |||
| 264 | + | } | |||
| 265 | + | return len(rest) | |||
| 266 | + | } | |||
M internal/host/resolve_test.go +106 -7
| @@ -33,23 +33,25 @@ func (c *resolveStub) ResolveActions(_ context.Context, r *connect.Request[ermit | |||
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | 35 | func resolveJob(c jobClient) *jobRun { | |
| 36 | - | return &jobRun{client: c, ctx: context.Background(), job: &ermite.Job{JobId: "j1"}, log: zerowrap.Logger{}, | |
| 36 | + | j := &jobRun{client: c, ctx: context.Background(), job: &ermite.Job{JobId: "j1"}, log: zerowrap.Logger{}, | |
| 37 | 37 | t: Timing{CallTimeout: time.Second, BackoffMin: time.Millisecond, BackoffMax: time.Millisecond}} | |
| 38 | + | j.setOrigins(`{"github":{"server_url":"https://ermite.example"}}`, "https://ermite.example", "") | |
| 39 | + | return j | |
| 38 | 40 | } | |
| 39 | 41 | ||
| 40 | 42 | func TestResolveActions(t *testing.T) { | |
| 41 | 43 | c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{ | |
| 42 | - | {Ref: "a/b@v1", NameWithOwner: "a/b", ResolvedSha: strings.Repeat("a", 40), TarballUrl: "https://x/t", Token: "tok", Source: ermite.ActionSource_ACTION_SOURCE_ERMITE}, | |
| 44 | + | {Ref: "a/b@v1", NameWithOwner: "a/b", ResolvedSha: strings.Repeat("a", 40), TarballUrl: "https://ermite.example/t", Token: "tok", Source: ermite.ActionSource_ACTION_SOURCE_ERMITE}, | |
| 43 | 45 | {Ref: "a/c@v1", Error: "boom"}, | |
| 44 | 46 | }}} | |
| 45 | - | resp := resolveJob(c).resolveActions(&execpb.ResolveRequest{Id: 7, Uses: []string{"a/b@v1", "a/c@v1"}}) | |
| 47 | + | resp := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Id: 7, Uses: []string{"a/b@v1", "a/c@v1"}}) | |
| 46 | 48 | if resp.Error != "" || len(resp.Actions) != 2 { | |
| 47 | 49 | t.Fatalf("%+v", resp) | |
| 48 | 50 | } | |
| 49 | 51 | if c.got.JobId != "j1" || len(c.got.Refs) != 2 || c.got.Refs[1].Uses != "a/c@v1" { | |
| 50 | 52 | t.Errorf("request %+v", c.got) | |
| 51 | 53 | } | |
| 52 | - | if a := resp.Actions[0]; a.Token != "tok" || a.TarballUrl != "https://x/t" || a.NameWithOwner != "a/b" { | |
| 54 | + | if a := resp.Actions[0]; a.Token != "tok" || a.AuthOrigin != "https://ermite.example" || a.TarballUrl != "https://ermite.example/t" || a.NameWithOwner != "a/b" { | |
| 53 | 55 | t.Errorf("%+v", a) | |
| 54 | 56 | } | |
| 55 | 57 | if resp.Actions[1].Error != "boom" { | |
| @@ -59,15 +61,15 @@ func TestResolveActions(t *testing.T) { | |||
| 59 | 61 | ||
| 60 | 62 | func TestResolveActionsFailures(t *testing.T) { | |
| 61 | 63 | c := &resolveStub{err: connect.NewError(connect.CodeInvalidArgument, nil)} | |
| 62 | - | if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); !strings.Contains(r.Error, "invalid_argument") { | |
| 64 | + | if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); !strings.Contains(r.Error, "invalid_argument") { | |
| 63 | 65 | t.Errorf("%+v", r) | |
| 64 | 66 | } | |
| 65 | 67 | c = &resolveStub{resp: &ermite.ResolveActionsResponse{}} | |
| 66 | - | if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); r.Error == "" { | |
| 68 | + | if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); r.Error == "" { | |
| 67 | 69 | t.Errorf("a short answer must fail: %+v", r) | |
| 68 | 70 | } | |
| 69 | 71 | many := make([]string, maxResolveRefs+1) | |
| 70 | - | if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: many}); r.Error == "" { | |
| 72 | + | if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: many}); r.Error == "" { | |
| 71 | 73 | t.Error("more than 100 refs must fail") | |
| 72 | 74 | } | |
| 73 | 75 | } | |
| @@ -95,3 +97,100 @@ func TestSetGitHubToken(t *testing.T) { | |||
| 95 | 97 | t.Errorf("invalid JSON: %s", got) | |
| 96 | 98 | } | |
| 97 | 99 | } | |
| 100 | + | ||
| 101 | + | func TestCheckUses(t *testing.T) { | |
| 102 | + | for u, ok := range map[string]bool{ | |
| 103 | + | "actions/checkout@v4": true, "o/r/sub/dir@refs/heads/feat/x": true, "o/r@" + strings.Repeat("a", 40): true, "o/r.go_x-y@v1.2.3": true, | |
| 104 | + | "": false, "o/r": false, "o@v1": false, "o/r@": false, "/r@v1": false, "o//r@v1": false, "o/../r@v1": false, "o/r/..@v1": false, | |
| 105 | + | "o/r@v1 x": false, "o/r@v1\n": false, "o/r@v~1": false, "o/r@v:1": false, "o /r@v1": false, "o/r@v1@v2": false, | |
| 106 | + | "o/r\x00@v1": false, strings.Repeat("a", 101) + "/r@v1": false, "o/r@" + strings.Repeat("a", 256): false, | |
| 107 | + | "o/" + strings.Repeat("a/", 300) + "r@v1": false, "ö/r@v1": false, | |
| 108 | + | } { | |
| 109 | + | if err := checkUses(u); (err == nil) != ok { | |
| 110 | + | t.Errorf("%q: %v, want ok=%v", u, err, ok) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // GO-003: whatever the sandbox sends, the server is called with valid | |
| 116 | + | // references only, and with a bounded number of calls and references. | |
| 117 | + | func TestResolveActionsRejectsAndBudgets(t *testing.T) { | |
| 118 | + | c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Ref: "a/b@v1"}}}} | |
| 119 | + | j := resolveJob(c) | |
| 120 | + | for _, uses := range [][]string{nil, {}, {""}, {"nonsense"}, {"a/b@v1", "bad ref"}} { | |
| 121 | + | c.got = nil | |
| 122 | + | if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: uses}); r.Error == "" || c.got != nil { | |
| 123 | + | t.Errorf("%q: error %q, called=%v", uses, r.Error, c.got != nil) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | var calls int | |
| 127 | + | for i := 0; i < maxResolveRounds+20; i++ { | |
| 128 | + | c.got = nil | |
| 129 | + | r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1"}}) | |
| 130 | + | if c.got != nil { | |
| 131 | + | calls++ | |
| 132 | + | } | |
| 133 | + | if i >= maxResolveRounds && r.Error == "" { | |
| 134 | + | t.Fatalf("round %d is over the budget and was served", i) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | if calls != maxResolveRounds { | |
| 138 | + | t.Errorf("%d calls, the budget is %d", calls, maxResolveRounds) | |
| 139 | + | } | |
| 140 | + | // The references count too. | |
| 141 | + | j = resolveJob(c) | |
| 142 | + | c.resp = &ermite.ResolveActionsResponse{} | |
| 143 | + | for i := 0; i < maxResolveRefs; i++ { | |
| 144 | + | c.resp.Actions = append(c.resp.Actions, &ermite.ResolvedAction{Ref: "a/b@v1"}) | |
| 145 | + | } | |
| 146 | + | big := make([]string, maxResolveRefs) | |
| 147 | + | for i := range big { | |
| 148 | + | big[i] = "a/b@v1" | |
| 149 | + | } | |
| 150 | + | served := 0 | |
| 151 | + | for i := 0; i < 40; i++ { | |
| 152 | + | if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: big}); r.Error == "" { | |
| 153 | + | served++ | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | if served != maxResolveRefsTotal/maxResolveRefs { | |
| 157 | + | t.Errorf("served %d requests of %d refs, the budget is %d refs", served, maxResolveRefs, maxResolveRefsTotal) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // GO-002 on the host side: the token only goes with an ermite archive whose URL | |
| 162 | + | // is on an origin of ermite; with guest forge access the URL is rewritten. | |
| 163 | + | func TestResolveResponseTokenOrigin(t *testing.T) { | |
| 164 | + | j := resolveJob(&resolveStub{}) | |
| 165 | + | ermiteSrc, ghSrc := ermite.ActionSource_ACTION_SOURCE_ERMITE, ermite.ActionSource_ACTION_SOURCE_GITHUB | |
| 166 | + | conv := func(j *jobRun, src ermite.ActionSource, url, tok string) *execpb.ResolvedAction { | |
| 167 | + | r := j.resolveResponse(&ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Source: src, TarballUrl: url, Token: tok}}}, 1) | |
| 168 | + | return r.Actions[0] | |
| 169 | + | } | |
| 170 | + | if a := conv(j, ermiteSrc, "https://ermite.example/-/github/api/repos/o/r/tarball/abc", "t"); a.Token != "t" || a.AuthOrigin != "https://ermite.example" { | |
| 171 | + | t.Errorf("%+v", a) | |
| 172 | + | } | |
| 173 | + | for name, a := range map[string]*execpb.ResolvedAction{ | |
| 174 | + | "github source": conv(j, ghSrc, "https://ermite.example/x", "t"), | |
| 175 | + | "foreign origin": conv(j, ermiteSrc, "https://evil.example/x", "t"), | |
| 176 | + | "subdomain": conv(j, ermiteSrc, "https://x.ermite.example/x", "t"), | |
| 177 | + | "other port": conv(j, ermiteSrc, "https://ermite.example:8443/x", "t"), | |
| 178 | + | "other scheme": conv(j, ermiteSrc, "http://ermite.example/x", "t"), | |
| 179 | + | "not a url": conv(j, ermiteSrc, "ermite.example/x", "t"), | |
| 180 | + | "credentials in it": conv(j, ermiteSrc, "https://u:p@ermite.example/x", "t"), | |
| 181 | + | } { | |
| 182 | + | if a.Token != "" || a.AuthOrigin != "" { | |
| 183 | + | t.Errorf("%s: token kept: %+v", name, a) | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | // No token, no origin: github.com archives are anonymous. | |
| 187 | + | if a := conv(j, ghSrc, "https://codeload.github.com/o/r/tar.gz/abc", ""); a.Token != "" || a.AuthOrigin != "" { | |
| 188 | + | t.Errorf("%+v", a) | |
| 189 | + | } | |
| 190 | + | // Guest forge access: the sandbox reaches ermite at the runner's origin. | |
| 191 | + | j.setOrigins(`{"github":{"server_url":"https://ermite.example"}}`, "https://runner.internal:8088", "http://10.0.2.2:8088") | |
| 192 | + | a := conv(j, ermiteSrc, "https://ermite.example/-/github/api/repos/o/r/tarball/abc", "t") | |
| 193 | + | if a.TarballUrl != "http://10.0.2.2:8088/-/github/api/repos/o/r/tarball/abc" || a.AuthOrigin != "http://10.0.2.2:8088" || a.Token != "t" { | |
| 194 | + | t.Errorf("%+v", a) | |
| 195 | + | } | |
| 196 | + | } | |
| @@ -33,23 +33,25 @@ func (c *resolveStub) ResolveActions(_ context.Context, r *connect.Request[ermit | |||||
| 33 | } | 33 | } | ||
| 34 | 34 | ||||
| 35 | func resolveJob(c jobClient) *jobRun { | 35 | func resolveJob(c jobClient) *jobRun { | ||
| 36 | - | return &jobRun{client: c, ctx: context.Background(), job: &ermite.Job{JobId: "j1"}, log: zerowrap.Logger{}, | 36 | + | j := &jobRun{client: c, ctx: context.Background(), job: &ermite.Job{JobId: "j1"}, log: zerowrap.Logger{}, |
| 37 | t: Timing{CallTimeout: time.Second, BackoffMin: time.Millisecond, BackoffMax: time.Millisecond}} | 37 | t: Timing{CallTimeout: time.Second, BackoffMin: time.Millisecond, BackoffMax: time.Millisecond}} | ||
| 38 | + | j.setOrigins(`{"github":{"server_url":"https://ermite.example"}}`, "https://ermite.example", "") | |||
| 39 | + | return j | |||
| 38 | } | 40 | } | ||
| 39 | 41 | ||||
| 40 | func TestResolveActions(t *testing.T) { | 42 | func TestResolveActions(t *testing.T) { | ||
| 41 | c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{ | 43 | c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{ | ||
| 42 | - | {Ref: "a/b@v1", NameWithOwner: "a/b", ResolvedSha: strings.Repeat("a", 40), TarballUrl: "https://x/t", Token: "tok", Source: ermite.ActionSource_ACTION_SOURCE_ERMITE}, | 44 | + | {Ref: "a/b@v1", NameWithOwner: "a/b", ResolvedSha: strings.Repeat("a", 40), TarballUrl: "https://ermite.example/t", Token: "tok", Source: ermite.ActionSource_ACTION_SOURCE_ERMITE}, |
| 43 | {Ref: "a/c@v1", Error: "boom"}, | 45 | {Ref: "a/c@v1", Error: "boom"}, | ||
| 44 | }}} | 46 | }}} | ||
| 45 | - | resp := resolveJob(c).resolveActions(&execpb.ResolveRequest{Id: 7, Uses: []string{"a/b@v1", "a/c@v1"}}) | 47 | + | resp := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Id: 7, Uses: []string{"a/b@v1", "a/c@v1"}}) |
| 46 | if resp.Error != "" || len(resp.Actions) != 2 { | 48 | if resp.Error != "" || len(resp.Actions) != 2 { | ||
| 47 | t.Fatalf("%+v", resp) | 49 | t.Fatalf("%+v", resp) | ||
| 48 | } | 50 | } | ||
| 49 | if c.got.JobId != "j1" || len(c.got.Refs) != 2 || c.got.Refs[1].Uses != "a/c@v1" { | 51 | if c.got.JobId != "j1" || len(c.got.Refs) != 2 || c.got.Refs[1].Uses != "a/c@v1" { | ||
| 50 | t.Errorf("request %+v", c.got) | 52 | t.Errorf("request %+v", c.got) | ||
| 51 | } | 53 | } | ||
| 52 | - | if a := resp.Actions[0]; a.Token != "tok" || a.TarballUrl != "https://x/t" || a.NameWithOwner != "a/b" { | 54 | + | if a := resp.Actions[0]; a.Token != "tok" || a.AuthOrigin != "https://ermite.example" || a.TarballUrl != "https://ermite.example/t" || a.NameWithOwner != "a/b" { |
| 53 | t.Errorf("%+v", a) | 55 | t.Errorf("%+v", a) | ||
| 54 | } | 56 | } | ||
| 55 | if resp.Actions[1].Error != "boom" { | 57 | if resp.Actions[1].Error != "boom" { | ||
| @@ -59,15 +61,15 @@ func TestResolveActions(t *testing.T) { | |||||
| 59 | 61 | ||||
| 60 | func TestResolveActionsFailures(t *testing.T) { | 62 | func TestResolveActionsFailures(t *testing.T) { | ||
| 61 | c := &resolveStub{err: connect.NewError(connect.CodeInvalidArgument, nil)} | 63 | c := &resolveStub{err: connect.NewError(connect.CodeInvalidArgument, nil)} | ||
| 62 | - | if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); !strings.Contains(r.Error, "invalid_argument") { | 64 | + | if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); !strings.Contains(r.Error, "invalid_argument") { |
| 63 | t.Errorf("%+v", r) | 65 | t.Errorf("%+v", r) | ||
| 64 | } | 66 | } | ||
| 65 | c = &resolveStub{resp: &ermite.ResolveActionsResponse{}} | 67 | c = &resolveStub{resp: &ermite.ResolveActionsResponse{}} | ||
| 66 | - | if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); r.Error == "" { | 68 | + | if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); r.Error == "" { |
| 67 | t.Errorf("a short answer must fail: %+v", r) | 69 | t.Errorf("a short answer must fail: %+v", r) | ||
| 68 | } | 70 | } | ||
| 69 | many := make([]string, maxResolveRefs+1) | 71 | many := make([]string, maxResolveRefs+1) | ||
| 70 | - | if r := resolveJob(c).resolveActions(&execpb.ResolveRequest{Uses: many}); r.Error == "" { | 72 | + | if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: many}); r.Error == "" { |
| 71 | t.Error("more than 100 refs must fail") | 73 | t.Error("more than 100 refs must fail") | ||
| 72 | } | 74 | } | ||
| 73 | } | 75 | } | ||
| @@ -95,3 +97,100 @@ func TestSetGitHubToken(t *testing.T) { | |||||
| 95 | t.Errorf("invalid JSON: %s", got) | 97 | t.Errorf("invalid JSON: %s", got) | ||
| 96 | } | 98 | } | ||
| 97 | } | 99 | } | ||
| 100 | + | ||||
| 101 | + | func TestCheckUses(t *testing.T) { | |||
| 102 | + | for u, ok := range map[string]bool{ | |||
| 103 | + | "actions/checkout@v4": true, "o/r/sub/dir@refs/heads/feat/x": true, "o/r@" + strings.Repeat("a", 40): true, "o/r.go_x-y@v1.2.3": true, | |||
| 104 | + | "": false, "o/r": false, "o@v1": false, "o/r@": false, "/r@v1": false, "o//r@v1": false, "o/../r@v1": false, "o/r/..@v1": false, | |||
| 105 | + | "o/r@v1 x": false, "o/r@v1\n": false, "o/r@v~1": false, "o/r@v:1": false, "o /r@v1": false, "o/r@v1@v2": false, | |||
| 106 | + | "o/r\x00@v1": false, strings.Repeat("a", 101) + "/r@v1": false, "o/r@" + strings.Repeat("a", 256): false, | |||
| 107 | + | "o/" + strings.Repeat("a/", 300) + "r@v1": false, "ö/r@v1": false, | |||
| 108 | + | } { | |||
| 109 | + | if err := checkUses(u); (err == nil) != ok { | |||
| 110 | + | t.Errorf("%q: %v, want ok=%v", u, err, ok) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // GO-003: whatever the sandbox sends, the server is called with valid | |||
| 116 | + | // references only, and with a bounded number of calls and references. | |||
| 117 | + | func TestResolveActionsRejectsAndBudgets(t *testing.T) { | |||
| 118 | + | c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Ref: "a/b@v1"}}}} | |||
| 119 | + | j := resolveJob(c) | |||
| 120 | + | for _, uses := range [][]string{nil, {}, {""}, {"nonsense"}, {"a/b@v1", "bad ref"}} { | |||
| 121 | + | c.got = nil | |||
| 122 | + | if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: uses}); r.Error == "" || c.got != nil { | |||
| 123 | + | t.Errorf("%q: error %q, called=%v", uses, r.Error, c.got != nil) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | var calls int | |||
| 127 | + | for i := 0; i < maxResolveRounds+20; i++ { | |||
| 128 | + | c.got = nil | |||
| 129 | + | r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1"}}) | |||
| 130 | + | if c.got != nil { | |||
| 131 | + | calls++ | |||
| 132 | + | } | |||
| 133 | + | if i >= maxResolveRounds && r.Error == "" { | |||
| 134 | + | t.Fatalf("round %d is over the budget and was served", i) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | if calls != maxResolveRounds { | |||
| 138 | + | t.Errorf("%d calls, the budget is %d", calls, maxResolveRounds) | |||
| 139 | + | } | |||
| 140 | + | // The references count too. | |||
| 141 | + | j = resolveJob(c) | |||
| 142 | + | c.resp = &ermite.ResolveActionsResponse{} | |||
| 143 | + | for i := 0; i < maxResolveRefs; i++ { | |||
| 144 | + | c.resp.Actions = append(c.resp.Actions, &ermite.ResolvedAction{Ref: "a/b@v1"}) | |||
| 145 | + | } | |||
| 146 | + | big := make([]string, maxResolveRefs) | |||
| 147 | + | for i := range big { | |||
| 148 | + | big[i] = "a/b@v1" | |||
| 149 | + | } | |||
| 150 | + | served := 0 | |||
| 151 | + | for i := 0; i < 40; i++ { | |||
| 152 | + | if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: big}); r.Error == "" { | |||
| 153 | + | served++ | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | if served != maxResolveRefsTotal/maxResolveRefs { | |||
| 157 | + | t.Errorf("served %d requests of %d refs, the budget is %d refs", served, maxResolveRefs, maxResolveRefsTotal) | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // GO-002 on the host side: the token only goes with an ermite archive whose URL | |||
| 162 | + | // is on an origin of ermite; with guest forge access the URL is rewritten. | |||
| 163 | + | func TestResolveResponseTokenOrigin(t *testing.T) { | |||
| 164 | + | j := resolveJob(&resolveStub{}) | |||
| 165 | + | ermiteSrc, ghSrc := ermite.ActionSource_ACTION_SOURCE_ERMITE, ermite.ActionSource_ACTION_SOURCE_GITHUB | |||
| 166 | + | conv := func(j *jobRun, src ermite.ActionSource, url, tok string) *execpb.ResolvedAction { | |||
| 167 | + | r := j.resolveResponse(&ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Source: src, TarballUrl: url, Token: tok}}}, 1) | |||
| 168 | + | return r.Actions[0] | |||
| 169 | + | } | |||
| 170 | + | if a := conv(j, ermiteSrc, "https://ermite.example/-/github/api/repos/o/r/tarball/abc", "t"); a.Token != "t" || a.AuthOrigin != "https://ermite.example" { | |||
| 171 | + | t.Errorf("%+v", a) | |||
| 172 | + | } | |||
| 173 | + | for name, a := range map[string]*execpb.ResolvedAction{ | |||
| 174 | + | "github source": conv(j, ghSrc, "https://ermite.example/x", "t"), | |||
| 175 | + | "foreign origin": conv(j, ermiteSrc, "https://evil.example/x", "t"), | |||
| 176 | + | "subdomain": conv(j, ermiteSrc, "https://x.ermite.example/x", "t"), | |||
| 177 | + | "other port": conv(j, ermiteSrc, "https://ermite.example:8443/x", "t"), | |||
| 178 | + | "other scheme": conv(j, ermiteSrc, "http://ermite.example/x", "t"), | |||
| 179 | + | "not a url": conv(j, ermiteSrc, "ermite.example/x", "t"), | |||
| 180 | + | "credentials in it": conv(j, ermiteSrc, "https://u:p@ermite.example/x", "t"), | |||
| 181 | + | } { | |||
| 182 | + | if a.Token != "" || a.AuthOrigin != "" { | |||
| 183 | + | t.Errorf("%s: token kept: %+v", name, a) | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | // No token, no origin: github.com archives are anonymous. | |||
| 187 | + | if a := conv(j, ghSrc, "https://codeload.github.com/o/r/tar.gz/abc", ""); a.Token != "" || a.AuthOrigin != "" { | |||
| 188 | + | t.Errorf("%+v", a) | |||
| 189 | + | } | |||
| 190 | + | // Guest forge access: the sandbox reaches ermite at the runner's origin. | |||
| 191 | + | j.setOrigins(`{"github":{"server_url":"https://ermite.example"}}`, "https://runner.internal:8088", "http://10.0.2.2:8088") | |||
| 192 | + | a := conv(j, ermiteSrc, "https://ermite.example/-/github/api/repos/o/r/tarball/abc", "t") | |||
| 193 | + | if a.TarballUrl != "http://10.0.2.2:8088/-/github/api/repos/o/r/tarball/abc" || a.AuthOrigin != "http://10.0.2.2:8088" || a.Token != "t" { | |||
| 194 | + | t.Errorf("%+v", a) | |||
| 195 | + | } | |||
| 196 | + | } | |||