1// SPDX-License-Identifier: MIT
2
3package registryproxy
4
5import (
6 "container/list"
7 "context"
8 "crypto/sha256"
9 "encoding/hex"
10 "errors"
11 "fmt"
12 "io"
13 "os"
14 "path/filepath"
15 "sort"
16 "strings"
17 "sync"
18 "sync/atomic"
19 "time"
20
21 "github.com/bnema/zerowrap"
22)
23
24var errTooLarge = errors.New("registry cache capacity exceeded")
25var errDigest = errors.New("registry digest mismatch")
26
27// errBusy: no download slot came free within the slot wait. The proxy answers
28// 503, and dockerd retries the layer (about 50 s) before pulling from Docker Hub.
29var errBusy = errors.New("registry downloads busy")
30
31// minCharge is the least an entry is charged, whatever its size: about one
32// disk block and inode. It bounds the number of entries, and so of files, to
33// Max/minCharge. A tag file is charged exactly that.
34const minCharge int64 = 4096
35
36// charge is what an entry of n bytes counts against Max.
37func charge(n int64) int64 { return max(n, minCharge) }
38
39// kind is the directory of an entry: content (blobs, manifests) or a tag.
40type kind uint8
41
42const (
43 kindBlob kind = iota
44 kindManifest
45 kindTag
46)
47
48var kinds = []kind{kindBlob, kindManifest, kindTag}
49
50// dir is the directory of the kind in the store, and its route in /v2/.
51func (k kind) dir() string {
52 switch k {
53 case kindBlob:
54 return "blobs"
55 case kindManifest:
56 return "manifests"
57 default:
58 return "tags"
59 }
60}
61
62// key is the key of an entry of kind k: its path in the store.
63func (k kind) key(name string) string { return k.dir() + "/" + name }
64
65// lowWater bounds the extra room an eviction frees (with 5% of Max), so that a
66// full store evicts once per that many bytes, not on every write.
67const lowWater int64 = 64 << 20
68
69type entry struct {
70 key string
71 kind kind
72 size int64
73 used time.Time
74 pins int
75 digest string // tag target, empty for content
76 elem *list.Element // in store.lru; nil once removed
77}
78
79// flight is one download, shared by every request for its digest. Its fields
80// below written are guarded by store.mu.
81type flight struct {
82 key string
83 done chan struct{}
84 err error
85 written atomic.Int64 // bytes received so far, for the waiters' keepalive
86
87 waiters int
88 gen int // bumped each time waiters drops to zero
89 finished bool
90 pinned bool // the committed entry holds a pin for the waiters
91 kind kind // a blob flight takes a download slot
92 quota *Quota // the starter's quota, held until the flight ends
93 cancel context.CancelFunc
94}
95
96// storeConfig is a store's complete configuration: openStore applies no
97// defaults.
98type storeConfig struct {
99 dir string
100 max int64
101 now func() time.Time
102 // grace is how long a blob flight with no waiter left keeps downloading in
103 // case another request joins it; then it is cancelled.
104 grace time.Duration
105 // maxDownloads caps the concurrent blob downloads.
106 maxDownloads int
107 // slotWait is how long a request waits for a download slot before errBusy.
108 slotWait time.Duration
109 log zerowrap.Logger
110}
111
112type store struct {
113 storeConfig
114
115 mu sync.Mutex
116 total, pending int64
117 downloads int
118 // slotFreed is closed and replaced each time a download slot frees.
119 slotFreed chan struct{}
120
121 entries map[string]*entry
122 lru *list.List // *entry, least recently used first
123 tagsOf map[string]map[string]struct{} // manifest digest -> tag keys
124 flights map[string]*flight
125
126 hookCommitted func(key string) // tests: after a commit, before waking waiters
127 hookBug func(msg string) // tests: called on an accounting bug
128}
129
130// defaultBugHook is each new store's hookBug. Tests set it in TestMain, before
131// any store exists, so that an accounting bug fails the run.
132var defaultBugHook func(msg string)
133
134// storedFile pins its entry until the descriptor is closed. In particular, an
135// unlinked-but-open blob cannot silently escape the disk accounting.
136type storedFile struct {
137 *os.File
138 s *store
139 key string
140 once sync.Once
141 err error
142}
143
144func (f *storedFile) Close() error {
145 f.once.Do(func() {
146 f.err = f.File.Close()
147 f.s.mu.Lock()
148 defer f.s.mu.Unlock()
149 f.s.unpinLocked(f.key)
150 })
151 return f.err
152}
153
154func openStore(c storeConfig) (*store, error) {
155 if c.max < minCharge {
156 return nil, fmt.Errorf("registry cache cap must be at least %d bytes", minCharge)
157 }
158 if c.now == nil || c.grace <= 0 || c.maxDownloads <= 0 || c.slotWait <= 0 {
159 return nil, errors.New("registry store configuration incomplete")
160 }
161 dir := c.dir
162 s := &store{storeConfig: c, entries: map[string]*entry{}, lru: list.New(), tagsOf: map[string]map[string]struct{}{}, flights: map[string]*flight{}, slotFreed: make(chan struct{}), hookBug: defaultBugHook}
163 for _, sub := range []string{kindBlob.dir(), kindManifest.dir(), kindTag.dir(), "tmp"} {
164 if err := os.MkdirAll(filepath.Join(dir, sub), 0700); err != nil {
165 return nil, err
166 }
167 }
168 temps, err := os.ReadDir(filepath.Join(dir, "tmp"))
169 if err != nil {
170 return nil, err
171 }
172 for _, de := range temps {
173 if err := os.Remove(filepath.Join(dir, "tmp", de.Name())); err != nil {
174 return nil, err
175 }
176 }
177 var types []string
178 for _, k := range kinds {
179 ds, err := os.ReadDir(filepath.Join(dir, k.dir()))
180 if err != nil {
181 return nil, err
182 }
183 for _, de := range ds {
184 if base, ok := strings.CutSuffix(de.Name(), ".type"); ok && k == kindManifest {
185 types = append(types, base)
186 continue
187 }
188 if !validDigest("sha256:" + de.Name()) {
189 continue
190 }
191 fi, err := de.Info()
192 if err != nil {
193 return nil, err
194 }
195 if !fi.Mode().IsRegular() {
196 continue
197 }
198 key := k.key(de.Name())
199 size := fi.Size()
200 e := &entry{key: key, kind: k, used: fi.ModTime()}
201 switch k {
202 case kindManifest:
203 info, err := os.Stat(filepath.Join(dir, key) + ".type")
204 if err != nil {
205 _ = os.Remove(filepath.Join(dir, key))
206 continue
207 }
208 size += info.Size()
209 case kindTag:
210 digest := s.readTagFile(key)
211 if !validDigest(digest) {
212 _ = os.Remove(filepath.Join(dir, key))
213 continue
214 }
215 if _, ok := s.entries[kindManifest.key(digest[7:])]; !ok {
216 _ = os.Remove(filepath.Join(dir, key))
217 continue
218 }
219 size = 0
220 e.digest = digest
221 }
222 e.size = charge(size)
223 s.addLocked(e)
224 }
225 }
226 // A .type whose manifest is gone (a crash between the two writes).
227 for _, base := range types {
228 if _, ok := s.entries[kindManifest.key(base)]; !ok {
229 _ = os.Remove(filepath.Join(dir, kindManifest.key(base)+".type"))
230 }
231 }
232 // Order the LRU by the access times kept in the mtimes.
233 all := make([]*entry, 0, len(s.entries))
234 for _, e := range s.entries {
235 all = append(all, e)
236 }
237 sort.Slice(all, func(i, j int) bool { return all[i].used.Before(all[j].used) })
238 s.lru.Init()
239 for _, e := range all {
240 e.elem = s.lru.PushBack(e)
241 }
242 s.evictLocked(0)
243 return s, nil
244}
245
246func (s *store) addLocked(e *entry) {
247 s.entries[e.key] = e
248 e.elem = s.lru.PushBack(e)
249 s.total += e.size
250 if e.digest != "" {
251 s.indexTagLocked(e.key, e.digest)
252 }
253}
254
255func (s *store) indexTagLocked(key, digest string) {
256 set := s.tagsOf[digest]
257 if set == nil {
258 set = map[string]struct{}{}
259 s.tagsOf[digest] = set
260 }
261 set[key] = struct{}{}
262}
263
264func (s *store) unindexTagLocked(key, digest string) {
265 if set := s.tagsOf[digest]; set != nil {
266 delete(set, key)
267 if len(set) == 0 {
268 delete(s.tagsOf, digest)
269 }
270 }
271}
272
273func (s *store) touchLocked(e *entry) {
274 e.used = s.now()
275 s.lru.MoveToBack(e.elem)
276 _ = os.Chtimes(filepath.Join(s.dir, e.key), e.used, e.used)
277}
278
279func (s *store) unpinLocked(key string) {
280 e, ok := s.entries[key]
281 if !ok {
282 return
283 }
284 if e.pins <= 0 {
285 // A bug: the entry could have been evicted while open.
286 s.log.Error().Str("key", key).Msg("registry cache unpin of an unpinned entry")
287 if s.hookBug != nil {
288 s.hookBug("unpin of an unpinned entry " + key)
289 }
290 return
291 }
292 e.pins--
293}
294
295// removeLocked only releases a charge after the corresponding file is gone.
296// Failures leave the entry charged so a full or unwritable disk fails closed.
297func (s *store) removeLocked(key string) bool {
298 e, ok := s.entries[key]
299 if !ok {
300 return true
301 }
302 if e.pins > 0 {
303 return false
304 }
305 if e.kind == kindManifest {
306 // Prune references first. If a tag cannot be removed, retain its target too.
307 for k := range s.tagsOf["sha256:"+strings.TrimPrefix(key, kindManifest.key(""))] {
308 if !s.removeLocked(k) {
309 return false
310 }
311 }
312 if err := os.Remove(filepath.Join(s.dir, key) + ".type"); err != nil && !os.IsNotExist(err) {
313 return false
314 }
315 }
316 if err := os.Remove(filepath.Join(s.dir, key)); err != nil && !os.IsNotExist(err) {
317 return false
318 }
319 s.total -= e.size
320 delete(s.entries, key)
321 s.lru.Remove(e.elem)
322 e.elem = nil
323 if e.digest != "" {
324 s.unindexTagLocked(key, e.digest)
325 }
326 return true
327}
328
329// evictLocked makes room for need bytes. Once over the cap, it walks the LRU
330// (no sort) and evicts unpinned entries until max(need, low water) bytes are
331// free, so a full store evicts once per low-water bytes, not on every write.
332func (s *store) evictLocked(need int64) {
333 if s.total+s.pending+need <= s.max {
334 return
335 }
336 target := s.max - max(need, min(lowWater, s.max/20))
337 for el := s.lru.Front(); el != nil && s.total+s.pending > target; {
338 e := el.Value.(*entry)
339 next := el.Next()
340 if e.pins == 0 {
341 removed := s.removeLocked(e.key)
342 if e.kind == kindManifest {
343 // Removing a manifest removes its tags first, even when it then
344 // fails: next may be gone. Start over; only pinned or
345 // unremovable entries are walked again.
346 next = s.lru.Front()
347 if !removed {
348 // Do not walk into it again: it stays where it is, and
349 // the walk resumes after it.
350 next = e.elem.Next()
351 }
352 }
353 }
354 el = next
355 }
356}
357
358func (s *store) openLocked(key string) (*storedFile, string, error) {
359 e, ok := s.entries[key]
360 if !ok {
361 return nil, "", os.ErrNotExist
362 }
363 f, err := os.Open(filepath.Join(s.dir, key))
364 if err != nil {
365 return nil, "", err
366 }
367 media := "application/octet-stream"
368 if e.kind == kindManifest {
369 b, err := os.ReadFile(filepath.Join(s.dir, key) + ".type")
370 if err != nil {
371 _ = f.Close()
372 return nil, "", err
373 }
374 media = string(b)
375 }
376 s.touchLocked(e)
377 e.pins++
378 return &storedFile{File: f, s: s, key: key}, media, nil
379}
380func (s *store) open(k kind, digest string) (*storedFile, string, error) {
381 if !validDigest(digest) {
382 return nil, "", errDigest
383 }
384 s.mu.Lock()
385 defer s.mu.Unlock()
386 return s.openLocked(k.key(digest[7:]))
387}
388
389// fetch writes the upstream content to w. ctx belongs to the flight: it ends
390// when the last waiter has been gone for the grace period.
391type fetch func(ctx context.Context, w io.Writer) (string, error)
392
393type keepalive struct {
394 every time.Duration
395 fn func()
396}
397
398// ensure returns the stored content, pinned for the caller; hit reports that it
399// was already stored. Otherwise the request joins or starts the flight of that
400// digest. The flight runs on its own goroutine and context, so no single
401// request, the initiator included, can cancel it while another one waits; it is
402// cancelled once no request has waited for it for the grace period. Every
403// waiter waits for the verified commit and then reads the stored file. While
404// waiting, keep.fn is called every keep.every when the download has made
405// progress, so that the waiter's connection deadlines can follow it.
406//
407// Starting a blob flight takes a global download slot and one of the quota
408// carried by ctx (WithQuota), both held until the flight really ends; joining
409// one takes neither. When a slot is missing, the request waits for one, kept
410// alive, for at most the slot wait, and then fails with errBusy.
411func (s *store) ensure(ctx context.Context, k kind, digest string, fetch fetch, keep keepalive) (f *storedFile, media string, hit bool, err error) {
412 if !validDigest(digest) {
413 return nil, "", false, errDigest
414 }
415 key := k.key(digest[7:])
416 blob := k == kindBlob
417 quota := quotaFrom(ctx)
418 var slotDeadline <-chan time.Time
419 var fl *flight
420 for {
421 s.mu.Lock()
422 if f, media, err := s.openLocked(key); err == nil {
423 s.mu.Unlock()
424 return f, media, true, nil
425 }
426 var ok bool
427 if fl, ok = s.flights[key]; ok {
428 break // join: no slot needed
429 }
430 if !blob || s.takeSlotLocked(quota) {
431 dctx, cancel := context.WithCancel(context.Background())
432 fl = &flight{key: key, kind: k, done: make(chan struct{}), cancel: cancel}
433 if blob {
434 fl.quota = quota
435 }
436 s.flights[key] = fl
437 go s.run(dctx, fl, digest, fetch)
438 break
439 }
440 freed := s.slotFreed
441 s.mu.Unlock()
442 if slotDeadline == nil {
443 t := time.NewTimer(s.slotWait)
444 defer t.Stop()
445 slotDeadline = t.C
446 }
447 if err := waitSlot(ctx, freed, slotDeadline, keep); err != nil {
448 return nil, "", false, err
449 }
450 }
451 fl.waiters++
452 s.mu.Unlock()
453
454 err = waitFlight(ctx, fl, keep)
455 s.mu.Lock()
456 defer s.mu.Unlock()
457 if err == nil {
458 f, media, err = s.openLocked(key)
459 }
460 s.leaveLocked(fl)
461 return f, media, false, err
462}
463
464// leaveLocked drops a waiter. The last one releases the commit pin, or, before
465// the commit, starts the grace period after which the flight is cancelled.
466func (s *store) leaveLocked(fl *flight) {
467 fl.waiters--
468 if fl.waiters > 0 {
469 return
470 }
471 if fl.finished {
472 if fl.pinned {
473 fl.pinned = false
474 s.unpinLocked(fl.key)
475 }
476 return
477 }
478 fl.gen++
479 gen := fl.gen
480 // A manifest is small and bounded by the request timeout: it is cancelled at
481 // once, so its flights never outnumber the requests in progress. A blob
482 // holds a download slot until it really ends, which bounds the abandoned
483 // ones to MaxDownloads.
484 if fl.kind != kindBlob {
485 s.abandonLocked(fl)
486 return
487 }
488 time.AfterFunc(s.grace, func() {
489 s.mu.Lock()
490 defer s.mu.Unlock()
491 if fl.waiters == 0 && !fl.finished && fl.gen == gen {
492 s.abandonLocked(fl)
493 }
494 })
495}
496
497// abandonLocked cancels a flight nobody waits for. A later request for the
498// digest starts a new one.
499func (s *store) abandonLocked(fl *flight) {
500 fl.cancel()
501 if s.flights[fl.key] == fl {
502 delete(s.flights, fl.key)
503 }
504}
505
506// takeSlotLocked takes a global download slot and one of quota (nil: none),
507// both or neither.
508func (s *store) takeSlotLocked(quota *Quota) bool {
509 if s.downloads >= s.maxDownloads {
510 return false
511 }
512 if quota != nil && !quota.tryAcquire() {
513 return false
514 }
515 s.downloads++
516 return true
517}
518
519// waitSlot waits until a slot may have freed (freed is closed), keeping the
520// client alive meanwhile. It fails with errBusy at the deadline.
521func waitSlot(ctx context.Context, freed <-chan struct{}, deadline <-chan time.Time, keep keepalive) error {
522 var tick <-chan time.Time
523 if keep.every > 0 && keep.fn != nil {
524 t := time.NewTicker(keep.every)
525 defer t.Stop()
526 tick = t.C
527 }
528 for {
529 select {
530 case <-freed:
531 return nil
532 case <-ctx.Done():
533 return ctx.Err()
534 case <-deadline:
535 return errBusy
536 case <-tick:
537 keep.fn() // bounded by the slot wait
538 }
539 }
540}
541
542// finishLocked ends a flight and releases its slots. A committed entry is
543// pinned on behalf of the waiters, so that no eviction removes it before they
544// open it.
545func (s *store) finishLocked(fl *flight, err error, committed *entry) {
546 fl.err = err
547 fl.finished = true
548 if err == nil && committed != nil && fl.waiters > 0 {
549 committed.pins++
550 fl.pinned = true
551 }
552 if s.flights[fl.key] == fl {
553 delete(s.flights, fl.key)
554 }
555 if fl.kind == kindBlob {
556 s.downloads--
557 if fl.quota != nil {
558 fl.quota.release()
559 }
560 close(s.slotFreed)
561 s.slotFreed = make(chan struct{})
562 }
563}
564
565// run downloads and finishes the flight. A panic in fetch fails the flight
566// (and frees its slots) rather than leave its waiters hanging.
567func (s *store) run(ctx context.Context, fl *flight, digest string, fetch fetch) {
568 var err error
569 defer func() {
570 if v := recover(); v != nil {
571 err = fmt.Errorf("registry download panicked: %v", v)
572 s.log.Error().Str("key", fl.key).Err(err).Msg("registry download")
573 }
574 fl.cancel()
575 s.mu.Lock()
576 if !fl.finished {
577 if err == nil {
578 err = errors.New("registry download ended without a commit")
579 }
580 s.finishLocked(fl, err, nil)
581 }
582 s.mu.Unlock()
583 if fl.err == nil && s.hookCommitted != nil {
584 s.hookCommitted(fl.key)
585 }
586 close(fl.done)
587 }()
588 err = s.fill(ctx, fl, digest, fetch)
589}
590
591func waitFlight(ctx context.Context, fl *flight, keep keepalive) error {
592 var tick <-chan time.Time
593 if keep.every > 0 && keep.fn != nil {
594 t := time.NewTicker(keep.every)
595 defer t.Stop()
596 tick = t.C
597 }
598 last := fl.written.Load()
599 for {
600 select {
601 case <-ctx.Done():
602 return ctx.Err()
603 case <-fl.done:
604 return fl.err
605 case <-tick:
606 if n := fl.written.Load(); n != last {
607 last = n
608 keep.fn()
609 }
610 }
611 }
612}
613
614type chargedWriter struct {
615 s *store
616 w io.Writer
617 n int64
618 written *atomic.Int64
619}
620
621func (w *chargedWriter) Write(b []byte) (int, error) {
622 s := w.s
623 s.mu.Lock()
624 need := int64(len(b))
625 s.evictLocked(need)
626 if need > s.max-s.total-s.pending {
627 s.mu.Unlock()
628 return 0, errTooLarge
629 }
630 s.pending += need
631 w.n += need
632 s.mu.Unlock()
633 n, err := w.w.Write(b)
634 if n < len(b) {
635 s.mu.Lock()
636 s.pending -= int64(len(b) - n)
637 w.n -= int64(len(b) - n)
638 s.mu.Unlock()
639 }
640 w.written.Add(int64(n))
641 return n, err
642}
643
644// fill downloads into tmp/, verifies the digest and commits. On success it has
645// finished the flight (finishLocked) under the same lock as the commit.
646func (s *store) fill(ctx context.Context, fl *flight, digest string, fetch fetch) error {
647 key := fl.key
648 f, err := os.CreateTemp(filepath.Join(s.dir, "tmp"), "download-")
649 if err != nil {
650 return err
651 }
652 defer func() { _ = f.Close(); _ = os.Remove(f.Name()) }()
653 h := sha256.New()
654 cw := &chargedWriter{s: s, w: io.MultiWriter(f, h), written: &fl.written}
655 defer func() { s.mu.Lock(); s.pending -= cw.n; s.mu.Unlock() }()
656 media, err := fetch(ctx, cw)
657 if err != nil {
658 return err
659 }
660 if "sha256:"+hex.EncodeToString(h.Sum(nil)) != digest {
661 return errDigest
662 }
663 if err = f.Close(); err != nil {
664 return err
665 }
666 s.mu.Lock()
667 defer s.mu.Unlock()
668 if e, ok := s.entries[key]; ok {
669 // Same digest, same bytes: keep the stored file and its pins rather than
670 // renaming over a file that may be open (its bytes would leave the
671 // accounting while still allocated).
672 s.finishLocked(fl, nil, e)
673 return nil
674 }
675 path := filepath.Join(s.dir, key)
676 metadata := int64(0)
677 if fl.kind == kindManifest {
678 if len(media) > 1024 {
679 return errors.New("manifest media type too large")
680 }
681 metadata = int64(len(media))
682 }
683 // The download is charged its bytes in pending; the entry is charged at
684 // least minCharge, and its media type.
685 size := charge(cw.n + metadata)
686 if extra := size - cw.n; extra > 0 {
687 s.evictLocked(extra)
688 if extra > s.max-s.total-s.pending {
689 return errTooLarge
690 }
691 }
692 if fl.kind == kindManifest {
693 if err := os.WriteFile(path+".type", []byte(media), 0600); err != nil {
694 return err
695 }
696 }
697 if err := os.Rename(f.Name(), path); err != nil {
698 if fl.kind == kindManifest {
699 _ = os.Remove(path + ".type")
700 }
701 return err
702 }
703 e := &entry{key: key, kind: fl.kind, size: size, used: s.now()}
704 s.addLocked(e)
705 s.pending -= cw.n
706 cw.n = 0
707 s.finishLocked(fl, nil, e)
708 return nil
709}
710
711// tagKey is the key of a tag for a normalized Accept (normalizeAccept).
712func tagKey(name, tag, accept string) string {
713 return kindTag.key(fmt.Sprintf("%x", sha256.Sum256([]byte(name+"\x00"+tag+"\x00"+accept))))
714}
715func (s *store) readTagFile(key string) string {
716 f, err := os.Open(filepath.Join(s.dir, key))
717 if err != nil {
718 return ""
719 }
720 defer func() { _ = f.Close() }()
721 b, err := io.ReadAll(io.LimitReader(f, 72))
722 if err != nil || !validDigest(string(b)) {
723 return ""
724 }
725 return string(b)
726}
727func (s *store) tag(name, tag, accept string) string {
728 s.mu.Lock()
729 defer s.mu.Unlock()
730 e, ok := s.entries[tagKey(name, tag, accept)]
731 if !ok {
732 return ""
733 }
734 s.touchLocked(e)
735 return e.digest
736}
737func (s *store) setTag(name, tag, accept, digest string) error {
738 if !validDigest(digest) {
739 return errDigest
740 }
741 s.mu.Lock()
742 defer s.mu.Unlock()
743 manifest, ok := s.entries[kindManifest.key(digest[7:])]
744 if !ok {
745 return os.ErrNotExist
746 }
747 // Protect the target while making room for its metadata.
748 manifest.pins++
749 defer func() { manifest.pins-- }()
750 key := tagKey(name, tag, accept)
751 old, exists := s.entries[key]
752 if exists {
753 // Already charged: rewriting it needs no room.
754 s.touchLocked(old)
755 } else {
756 s.evictLocked(charge(0))
757 if charge(0) > s.max-s.total-s.pending {
758 return errTooLarge
759 }
760 }
761 f, err := os.CreateTemp(filepath.Join(s.dir, "tmp"), "tag-")
762 if err != nil {
763 return err
764 }
765 defer func() { _ = f.Close(); _ = os.Remove(f.Name()) }()
766 if _, err = f.WriteString(digest); err != nil {
767 return err
768 }
769 if err = f.Close(); err != nil {
770 return err
771 }
772 if err = os.Rename(f.Name(), filepath.Join(s.dir, key)); err != nil {
773 return err
774 }
775 if old, ok := s.entries[key]; ok {
776 s.unindexTagLocked(key, old.digest)
777 old.digest = digest
778 old.used = s.now()
779 s.indexTagLocked(key, digest)
780 return nil
781 }
782 s.addLocked(&entry{key: key, kind: kindTag, size: charge(0), used: s.now(), digest: digest})
783 return nil
784}