v0.3.0 8f98fd3

handler.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package cachesvc
4
5import (
6	"encoding/json"
7	"errors"
8	"fmt"
9	"io"
10	"net/http"
11	"net/url"
12	"os"
13	"strconv"
14	"strings"
15	"time"
16)
17
18// APIPath is the prefix of the cache API: the toolkit appends "_apis/
19// artifactcache/<resource>" to ACTIONS_CACHE_URL
20// (toolkit/packages/cache/src/internal/cacheHttpClient.ts:47).
21const APIPath = "/_apis/artifactcache/"
22
23// Limits of the requests, from the toolkit (cache.ts:97-115: a key is at most
24// 512 characters and has no comma; restore keys are at most 10) and of this
25// service (the version is a sha256 hex string for the toolkit).
26const (
27	maxKeyLen     = 512
28	maxVersionLen = 128
29	maxKeys       = 11
30	maxJSONBody   = 1 << 20
31)
32
33// Messages the toolkit looks for (cache.ts:50, constants.ts:45): a denial that
34// starts with them is logged as a warning, not as a failure.
35const (
36	writeDenied = "cache write denied: "
37	readDenied  = "cache read denied: "
38)
39
40// Handler returns the HTTP handler of the cache service (API v1), for any job.
41func (s *Service) Handler() http.Handler { return s.handler("") }
42
43// handler is Handler for the channel of one job: with a job id, a token made
44// for another job is refused, so a token that left its sandbox is useless on
45// any other sandbox's channel.
46func (s *Service) handler(job string) http.Handler {
47	mux := http.NewServeMux()
48	// The routes are those of the toolkit's v1 client (cacheHttpClient.ts):
49	//   GET   cache?keys=k1,k2&version=v  :89  lookup
50	//   GET   caches?key=k                :132 diagnostics (core.isDebug())
51	//   POST  caches {key,version,cacheSize} :190 reserve -> {cacheId}
52	//   PATCH caches/{id} Content-Range   :230 chunk upload
53	//   POST  caches/{id} {size}          :330 commit
54	//   GET   <archiveLocation>           downloadUtils.ts:168 (no bearer token)
55	mux.HandleFunc("GET "+APIPath+"cache", s.auth(job, s.lookup))
56	mux.HandleFunc("GET "+APIPath+"caches", s.auth(job, s.listCaches))
57	mux.HandleFunc("POST "+APIPath+"caches", s.auth(job, s.reserve))
58	mux.HandleFunc("PATCH "+APIPath+"caches/{id}", s.auth(job, s.upload))
59	mux.HandleFunc("POST "+APIPath+"caches/{id}", s.auth(job, s.commit))
60	mux.HandleFunc("GET "+APIPath+"artifacts/{id}", s.download(job))
61	mux.HandleFunc("HEAD "+APIPath+"artifacts/{id}", s.download(job))
62	// net/http resets the write deadline per request only when the server has a
63	// WriteTimeout, which would cut long uploads and downloads: set a fresh
64	// progress deadline for each request here, so that a deadline left by the
65	// previous request of a kept-alive connection never applies to this one.
66	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
67		rc := http.NewResponseController(w)
68		d := time.Now().Add(s.progress)
69		_ = rc.SetReadDeadline(d)
70		_ = rc.SetWriteDeadline(d)
71		mux.ServeHTTP(w, r)
72	})
73}
74
75type handlerFunc func(w http.ResponseWriter, r *http.Request, c Claims)
76
77// auth checks the bearer token (ACTIONS_RUNTIME_TOKEN), and when job is not
78// empty that it was made for that job. Every refusal is a bare 403 whatever the
79// reason.
80func (s *Service) auth(job string, h handlerFunc) http.HandlerFunc {
81	return func(w http.ResponseWriter, r *http.Request) {
82		const p = "Bearer "
83		a := r.Header.Get("Authorization")
84		if len(a) <= len(p) || !strings.EqualFold(a[:len(p)], p) {
85			reply(w, http.StatusForbidden, "forbidden")
86			return
87		}
88		c, err := s.sign.verify(strings.TrimSpace(a[len(p):]), s.now())
89		if err != nil || (job != "" && c.JobID != job) {
90			reply(w, http.StatusForbidden, "forbidden")
91			return
92		}
93		// Every body, JSON or chunk, must keep moving: a client that stalls
94		// loses its connection (and an upload its in-flight range).
95		pr := newProgressReader(r.Body, http.NewResponseController(w), s.progress, s.remaining(c.Expiry))
96		defer pr.done()
97		r.Body = readCloser{pr, r.Body}
98		h(w, r, c)
99	}
100}
101
102type readCloser struct {
103	io.Reader
104	io.Closer
105}
106
107// reply sends the {"message": …} error body that @actions/http-client turns into
108// the error message.
109func reply(w http.ResponseWriter, code int, msg string) {
110	w.Header().Set("Content-Type", "application/json")
111	w.WriteHeader(code)
112	_ = json.NewEncoder(w).Encode(map[string]string{"message": msg})
113}
114
115func replyJSON(w http.ResponseWriter, code int, v any) {
116	w.Header().Set("Content-Type", "application/json")
117	w.WriteHeader(code)
118	_ = json.NewEncoder(w).Encode(v)
119}
120
121// ArtifactCacheEntry is the lookup answer (toolkit contracts.ts:10-16).
122type artifactCacheEntry struct {
123	CacheKey        string `json:"cacheKey"`
124	Scope           string `json:"scope"`
125	CacheVersion    string `json:"cacheVersion"`
126	CreationTime    string `json:"creationTime"`
127	ArchiveLocation string `json:"archiveLocation"`
128}
129
130func (s *Service) entryJSON(r *http.Request, c Claims, e Entry) artifactCacheEntry {
131	exp := c.Expiry
132	q := url.Values{"exp": {strconv.FormatInt(exp, 10)}, "job": {c.JobID}, "sig": {s.sign.archiveSig(e.ID, e.Repository, c.JobID, exp)}}
133	host := r.Host
134	return artifactCacheEntry{
135		CacheKey: e.Key, Scope: e.Ref, CacheVersion: e.Version,
136		CreationTime:    e.Created.UTC().Format("2006-01-02T15:04:05Z"),
137		ArchiveLocation: "http://" + host + APIPath + "artifacts/" + strconv.FormatInt(e.ID, 10) + "?" + q.Encode(),
138	}
139}
140
141func validKey(k string) bool {
142	return k != "" && len(k) <= maxKeyLen && !strings.Contains(k, ",")
143}
144
145func validVersion(v string) bool { return v != "" && len(v) <= maxVersionLen }
146
147func (s *Service) lookup(w http.ResponseWriter, r *http.Request, c Claims) {
148	if len(c.ReadRefs) == 0 {
149		reply(w, http.StatusForbidden, readDenied+"this job may not read the cache")
150		return
151	}
152	q := r.URL.Query()
153	keys := strings.Split(q.Get("keys"), ",")
154	version := q.Get("version")
155	if len(keys) > maxKeys || !validVersion(version) {
156		reply(w, http.StatusBadRequest, "bad keys or version")
157		return
158	}
159	for _, k := range keys {
160		if !validKey(k) {
161			reply(w, http.StatusBadRequest, "bad keys or version")
162			return
163		}
164	}
165	e, ok := s.store.find(c.Repository, c.ReadRefs, keys, version)
166	if !ok {
167		w.WriteHeader(http.StatusNoContent)
168		return
169	}
170	replyJSON(w, http.StatusOK, s.entryJSON(r, c, e))
171}
172
173func (s *Service) listCaches(w http.ResponseWriter, r *http.Request, c Claims) {
174	if len(c.ReadRefs) == 0 {
175		reply(w, http.StatusForbidden, readDenied+"this job may not read the cache")
176		return
177	}
178	list := s.store.list(c.Repository, c.ReadRefs, r.URL.Query().Get("key"))
179	out := struct {
180		TotalCount     int                  `json:"totalCount"`
181		ArtifactCaches []artifactCacheEntry `json:"artifactCaches"`
182	}{TotalCount: len(list), ArtifactCaches: []artifactCacheEntry{}}
183	for _, e := range list {
184		out.ArtifactCaches = append(out.ArtifactCaches, s.entryJSON(r, c, e))
185	}
186	replyJSON(w, http.StatusOK, out)
187}
188
189func (s *Service) reserve(w http.ResponseWriter, r *http.Request, c Claims) {
190	if c.WriteRef == "" {
191		reply(w, http.StatusForbidden, writeDenied+"this job may not write the cache")
192		return
193	}
194	var req struct {
195		Key       string `json:"key"`
196		Version   string `json:"version"`
197		CacheSize int64  `json:"cacheSize"`
198	}
199	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxJSONBody)).Decode(&req); err != nil || !validKey(req.Key) || !validVersion(req.Version) || req.CacheSize < 0 {
200		reply(w, http.StatusBadRequest, "bad reservation")
201		return
202	}
203	id, err := s.store.reserve(c.JobID, c.Repository, c.WriteRef, req.Key, req.Version, req.CacheSize, time.Unix(c.Expiry, 0))
204	switch {
205	case errors.Is(err, errConflict):
206		reply(w, http.StatusConflict, err.Error())
207	case errors.Is(err, errTooBig):
208		reply(w, http.StatusBadRequest, err.Error())
209	case errors.Is(err, errNoRoom), errors.Is(err, errTooMany):
210		reply(w, http.StatusServiceUnavailable, err.Error())
211	case err != nil:
212		s.log.Error().Err(err).Msg("cache reserve")
213		reply(w, http.StatusInternalServerError, "cache service error")
214	default:
215		replyJSON(w, http.StatusCreated, map[string]int64{"cacheId": id})
216	}
217}
218
219func parseID(r *http.Request) (int64, bool) {
220	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
221	return id, err == nil && id > 0
222}
223
224// parseRange reads "bytes a-b/*" (cacheHttpClient.ts:206-212): both ends
225// inclusive; the total is a star or a positive size that covers the range.
226func parseRange(h string) (lo, hi int64, ok bool) {
227	rest, found := strings.CutPrefix(h, "bytes ")
228	if !found {
229		return 0, 0, false
230	}
231	span, total, found := strings.Cut(rest, "/")
232	if !found {
233		return 0, 0, false
234	}
235	a, b, found := strings.Cut(span, "-")
236	if !found {
237		return 0, 0, false
238	}
239	lo, err1 := strconv.ParseInt(a, 10, 64)
240	last, err2 := strconv.ParseInt(b, 10, 64)
241	if err1 != nil || err2 != nil || lo < 0 || last < lo || last >= 1<<62 {
242		return 0, 0, false
243	}
244	if total != "*" {
245		n, err := strconv.ParseInt(total, 10, 64)
246		if err != nil || n <= 0 || n < last+1 {
247			return 0, 0, false
248		}
249	}
250	return lo, last + 1, true
251}
252
253func (s *Service) upload(w http.ResponseWriter, r *http.Request, c Claims) {
254	id, ok := parseID(r)
255	lo, hi, ok2 := parseRange(r.Header.Get("Content-Range"))
256	if !ok || !ok2 {
257		reply(w, http.StatusBadRequest, "bad cache id or Content-Range")
258		return
259	}
260	path, err := s.store.beginChunk(id, c, lo, hi)
261	if err != nil {
262		s.storeError(w, err)
263		return
264	}
265	// The range counts as received only when the whole body was written and
266	// checked; on any failure its coverage stays removed.
267	written := false
268	defer func() { s.store.endChunk(id, lo, hi, written) }()
269	if s.hookChunk != nil {
270		s.hookChunk(id)
271	}
272	f, err := os.OpenFile(path, os.O_WRONLY, 0)
273	if err != nil {
274		s.log.Error().Err(err).Msg("cache chunk")
275		reply(w, http.StatusInternalServerError, "cache service error")
276		return
277	}
278	defer func() { _ = f.Close() }()
279	// Exactly the announced bytes: fewer or more is a broken client or a
280	// truncated connection.
281	n, err := io.Copy(io.NewOffsetWriter(f, lo), io.LimitReader(r.Body, hi-lo))
282	if err != nil || n != hi-lo {
283		reply(w, http.StatusBadRequest, fmt.Sprintf("the body is not the %d bytes of the Content-Range", hi-lo))
284		return
285	}
286	if extra, _ := io.CopyN(io.Discard, r.Body, 1); extra > 0 {
287		reply(w, http.StatusBadRequest, "the body is longer than the Content-Range")
288		return
289	}
290	if err := f.Close(); err != nil {
291		s.log.Error().Err(err).Msg("cache chunk")
292		reply(w, http.StatusInternalServerError, "cache service error")
293		return
294	}
295	written = true
296	w.WriteHeader(http.StatusNoContent)
297}
298
299// progressReader extends the read deadline of the connection before each read:
300// a client that sends nothing for the progress timeout loses its upload
301// (slowloris on bodies), a slow one that keeps sending does not.
302type progressReader struct {
303	r         io.Reader
304	rc        *http.ResponseController
305	idle      time.Duration
306	remaining func() time.Duration
307}
308
309// remaining returns how long a token that expires at the Unix time exp (or a
310// signed archive URL) is still valid, by the service clock.
311func (s *Service) remaining(exp int64) func() time.Duration {
312	return func() time.Duration { return time.Unix(exp, 0).Sub(s.now()) }
313}
314
315// deadline is the next deadline of a transfer: the progress period from now, but
316// never later than the end of the credential that admitted it (a request
317// admitted before the expiry ends at the expiry). The clock of the credential is
318// the service's, so the remaining time is turned into a real-time deadline.
319func deadline(idle time.Duration, remaining func() time.Duration) time.Time {
320	d := idle
321	if remaining != nil {
322		d = max(0, min(d, remaining()))
323	}
324	return time.Now().Add(d)
325}
326
327func newProgressReader(r io.Reader, rc *http.ResponseController, idle time.Duration, remaining func() time.Duration) *progressReader {
328	return &progressReader{r: r, rc: rc, idle: idle, remaining: remaining}
329}
330
331// set moves the deadlines. The reply to a body that stalled still gets one more
332// progress period to be written, but not past the credential's end.
333func (p *progressReader) set() {
334	_ = p.rc.SetReadDeadline(deadline(p.idle, p.remaining))
335	_ = p.rc.SetWriteDeadline(deadline(2*p.idle, p.remaining))
336}
337
338// Read: reading is progress, so the reply can wait for the body as long as the
339// body keeps coming.
340func (p *progressReader) Read(b []byte) (int, error) {
341	p.set()
342	return p.r.Read(b)
343}
344
345// done leaves a deadline in place, never none: after the handler the server
346// still drains what is left of the body, and that must not wait for ever either.
347// The server resets the deadlines for the next request of the connection.
348func (p *progressReader) done() { p.set() }
349
350// progressWriter is the same for downloads: the write deadline moves with
351// every write.
352type progressWriter struct {
353	http.ResponseWriter
354	rc        *http.ResponseController
355	idle      time.Duration
356	remaining func() time.Duration
357}
358
359func (p *progressWriter) Write(b []byte) (int, error) {
360	_ = p.rc.SetWriteDeadline(deadline(p.idle, p.remaining))
361	return p.ResponseWriter.Write(b)
362}
363
364// Unwrap lets http.ResponseController reach the real writer.
365func (p *progressWriter) Unwrap() http.ResponseWriter { return p.ResponseWriter }
366
367func (s *Service) commit(w http.ResponseWriter, r *http.Request, c Claims) {
368	id, ok := parseID(r)
369	var req struct {
370		Size int64 `json:"size"`
371	}
372	if !ok || json.NewDecoder(http.MaxBytesReader(w, r.Body, maxJSONBody)).Decode(&req) != nil {
373		reply(w, http.StatusBadRequest, "bad cache id or size")
374		return
375	}
376	if err := s.store.commit(id, c, req.Size); err != nil {
377		s.storeError(w, err)
378		return
379	}
380	w.WriteHeader(http.StatusNoContent)
381}
382
383// storeError maps the errors of the store. A reservation of another job, ref or
384// repository is the same 403 as a bad token; an unknown or expired one is 404.
385func (s *Service) storeError(w http.ResponseWriter, err error) {
386	switch {
387	case errors.Is(err, errDenied):
388		reply(w, http.StatusForbidden, "forbidden")
389	case errors.Is(err, errNotFound):
390		reply(w, http.StatusNotFound, err.Error())
391	case errors.Is(err, errTooBig):
392		reply(w, http.StatusRequestEntityTooLarge, err.Error())
393	case errors.Is(err, errBadSize), errors.Is(err, errState):
394		reply(w, http.StatusBadRequest, err.Error())
395	case errors.Is(err, errOverlap):
396		reply(w, http.StatusConflict, err.Error())
397	case errors.Is(err, errNoRoom), errors.Is(err, errTooMany):
398		reply(w, http.StatusServiceUnavailable, err.Error())
399	default:
400		s.log.Error().Err(err).Msg("cache")
401		reply(w, http.StatusInternalServerError, "cache service error")
402	}
403}
404
405// download serves an archive at the location given by a lookup. The client
406// fetches it without the bearer token (downloadUtils.ts:168), so the URL
407// carries the proof: an HMAC over the entry, its repository, the job the URL
408// was issued to and an expiry. On the channel of a job (job not empty) a URL
409// issued to another job is refused, as a token of another job is.
410func (s *Service) download(job string) http.HandlerFunc {
411	return func(w http.ResponseWriter, r *http.Request) {
412		s.serveArchive(w, r, job)
413	}
414}
415
416func (s *Service) serveArchive(w http.ResponseWriter, r *http.Request, channelJob string) {
417	id, ok := parseID(r)
418	q := r.URL.Query()
419	exp, err := strconv.ParseInt(q.Get("exp"), 10, 64)
420	issued := q.Get("job")
421	if !ok || err != nil || issued == "" || (channelJob != "" && issued != channelJob) {
422		reply(w, http.StatusForbidden, "forbidden")
423		return
424	}
425	e, found := s.store.get(id)
426	if !found || !s.sign.archiveOK(id, e.Repository, issued, exp, q.Get("sig"), s.now()) {
427		reply(w, http.StatusForbidden, "forbidden")
428		return
429	}
430	f, e, release, err := s.store.open(id, e.Repository)
431	if err != nil {
432		reply(w, http.StatusNotFound, "no such cache")
433		return
434	}
435	// The entry cannot be evicted (its bytes stay charged) while it is read.
436	defer release()
437	defer func() { _ = f.Close() }()
438	rc := http.NewResponseController(w)
439	remaining := s.remaining(exp)
440	defer func() { _ = rc.SetWriteDeadline(deadline(s.progress, remaining)) }()
441	w.Header().Set("Content-Type", "application/octet-stream")
442	http.ServeContent(&progressWriter{ResponseWriter: w, rc: rc, idle: s.progress, remaining: remaining}, r, "", e.Created, f)
443}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close