Skip to content

Commit c6b4a77

Browse files
committed
cache: confine storage I/O with os.Root
Hold an os.Root for the cache directory instead of a path string in CAS and ActionCache. Every entry read, write, rename, and eviction now goes through the root, so no entry name — hash-derived or deserialized from an action cache entry — can traverse outside the cache directory. Cache handles now hold a directory fd, so Open gains a matching Close: the analyzer opens the cache once per instance and closes it with the analyzer, and the WASM runner closes it after loading a plugin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPgq3rwip76D554BktbqR4
1 parent c34bc9b commit c6b4a77

6 files changed

Lines changed: 96 additions & 45 deletions

File tree

internal/analyzer/analyzer.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type CachedAnalyzer struct {
2020
config config.Config
2121
configBytes []byte
2222
db config.Database
23+
store *cache.Cache
2324
}
2425

2526
func Cached(a Analyzer, c config.Config, db config.Database) *CachedAnalyzer {
@@ -53,12 +54,17 @@ func (c *CachedAnalyzer) analyze(ctx context.Context, n ast.Node, q string, sche
5354
return nil, true, nil
5455
}
5556

56-
store, err := cache.Open()
57-
if err != nil {
58-
return nil, true, err
57+
if c.store == nil {
58+
var err error
59+
c.store, err = cache.Open()
60+
if err != nil {
61+
return nil, true, err
62+
}
5963
}
64+
store := c.store
6065

6166
if c.configBytes == nil {
67+
var err error
6268
c.configBytes, err = json.Marshal(c.config)
6369
if err != nil {
6470
return nil, true, err
@@ -113,6 +119,9 @@ func (c *CachedAnalyzer) analyze(ctx context.Context, n ast.Node, q string, sche
113119
}
114120

115121
func (c *CachedAnalyzer) Close(ctx context.Context) error {
122+
if c.store != nil {
123+
c.store.Close()
124+
}
116125
return c.a.Close(ctx)
117126
}
118127

internal/cache/action.go

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cache
22

33
import (
44
"encoding/json"
5+
"errors"
56
"fmt"
67
"io/fs"
78
"os"
@@ -22,17 +23,21 @@ type ActionResult struct {
2223
// entries are not self-validating — the value is not derivable from the key —
2324
// so Get additionally checks that every referenced output still exists in the
2425
// CAS before reporting a hit, exactly like Bazel's disk cache does.
26+
//
27+
// Entry I/O goes through the same os.Root as the CAS, confining every read
28+
// and write to the cache directory.
2529
type ActionCache struct {
26-
root string
30+
root *os.Root
2731
cas *CAS
2832
}
2933

30-
func newActionCache(root string, cas *CAS) *ActionCache {
34+
func newActionCache(root *os.Root, cas *CAS) *ActionCache {
3135
return &ActionCache{root: root, cas: cas}
3236
}
3337

38+
// path returns an entry's path relative to the cache root.
3439
func (a *ActionCache) path(d Digest) string {
35-
return filepath.Join(a.root, "ac", d.Hash[:2], d.Hash)
40+
return filepath.Join("ac", d.Hash[:2], d.Hash)
3641
}
3742

3843
// Get returns the cached result for an action, or ErrNotFound on a miss. An
@@ -43,21 +48,21 @@ func (a *ActionCache) Get(action Digest) (*ActionResult, error) {
4348
return nil, ErrNotFound
4449
}
4550
path := a.path(action)
46-
data, err := os.ReadFile(path)
51+
data, err := a.root.ReadFile(path)
4752
if err != nil {
48-
if os.IsNotExist(err) {
53+
if errors.Is(err, fs.ErrNotExist) {
4954
return nil, ErrNotFound
5055
}
5156
return nil, fmt.Errorf("cache: %w", err)
5257
}
5358
var result ActionResult
5459
if err := json.Unmarshal(data, &result); err != nil {
55-
os.Remove(path)
60+
a.root.Remove(path)
5661
return nil, ErrNotFound
5762
}
5863
for _, d := range result.Outputs {
5964
if !a.cas.Contains(d) {
60-
os.Remove(path)
65+
a.root.Remove(path)
6166
return nil, ErrNotFound
6267
}
6368
}
@@ -99,8 +104,8 @@ func (a *ActionCache) PutTree(action Digest, dir string) error {
99104

100105
// GetTree materializes a cached action's outputs as files under dir, or
101106
// returns ErrNotFound on a miss. Files already present with the right size
102-
// are left in place; missing ones are staged and renamed so concurrent
103-
// processes never observe partial files.
107+
// are left in place; missing ones are staged in their destination directory
108+
// and renamed so concurrent processes never observe partial files.
104109
func (a *ActionCache) GetTree(action Digest, dir string) error {
105110
result, err := a.Get(action)
106111
if err != nil {
@@ -118,7 +123,7 @@ func (a *ActionCache) GetTree(action Digest, dir string) error {
118123
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
119124
return fmt.Errorf("cache: %w", err)
120125
}
121-
f, err := os.CreateTemp(a.cas.tmp, d.Hash[:8]+"-*")
126+
f, err := os.CreateTemp(filepath.Dir(path), d.Hash[:8]+"-*")
122127
if err != nil {
123128
return fmt.Errorf("cache: %w", err)
124129
}
@@ -153,22 +158,22 @@ func (a *ActionCache) Put(action Digest, result *ActionResult) error {
153158
return fmt.Errorf("cache: %w", err)
154159
}
155160
path := a.path(action)
156-
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
161+
if err := a.root.MkdirAll(filepath.Dir(path), 0755); err != nil {
157162
return fmt.Errorf("cache: %w", err)
158163
}
159-
f, err := os.CreateTemp(a.cas.tmp, action.Hash[:8]+"-*")
164+
f, name, err := a.cas.createTemp(action.Hash[:8] + "-")
160165
if err != nil {
161166
return fmt.Errorf("cache: %w", err)
162167
}
163-
defer os.Remove(f.Name())
168+
defer a.root.Remove(name)
164169
if _, err := f.Write(data); err != nil {
165170
f.Close()
166171
return fmt.Errorf("cache: %w", err)
167172
}
168173
if err := f.Close(); err != nil {
169174
return fmt.Errorf("cache: %w", err)
170175
}
171-
if err := os.Rename(f.Name(), path); err != nil {
176+
if err := a.root.Rename(name, path); err != nil {
172177
return fmt.Errorf("cache: %w", err)
173178
}
174179
return nil

internal/cache/cache.go

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ import (
2424
"path/filepath"
2525
)
2626

27-
// Cache bundles the CAS and the action cache that shares it.
27+
// Cache bundles the CAS and the action cache that shares it. All storage
28+
// I/O is confined to the cache directory through an os.Root; callers should
29+
// Close the cache when finished with it to release the root.
2830
type Cache struct {
29-
root string
31+
root *os.Root
3032
CAS *CAS
3133
Actions *ActionCache
3234
}
@@ -56,9 +58,17 @@ func Open() (*Cache, error) {
5658

5759
// OpenAt returns the cache rooted at the given directory, creating it if
5860
// necessary.
59-
func OpenAt(root string) (*Cache, error) {
61+
func OpenAt(dir string) (*Cache, error) {
62+
if err := os.MkdirAll(dir, 0755); err != nil {
63+
return nil, fmt.Errorf("failed to create %s directory: %w", dir, err)
64+
}
65+
root, err := os.OpenRoot(dir)
66+
if err != nil {
67+
return nil, fmt.Errorf("cache: %w", err)
68+
}
6069
cas, err := newCAS(root)
6170
if err != nil {
71+
root.Close()
6272
return nil, err
6373
}
6474
return &Cache{
@@ -68,15 +78,20 @@ func OpenAt(root string) (*Cache, error) {
6878
}, nil
6979
}
7080

81+
// Close releases the cache's handle on its root directory.
82+
func (c *Cache) Close() error {
83+
return c.root.Close()
84+
}
85+
7186
// ExecDir returns a stable directory for materializing the output tree of
7287
// the given action, for tools that need their outputs on disk (like wazero's
7388
// compilation cache). It lives at exec/<action-hash> under the cache root
7489
// and, like everything else in the cache, is safe to delete at any time: the
7590
// authoritative copy of its contents is the CAS.
7691
func (c *Cache) ExecDir(action Digest) (string, error) {
77-
dir := filepath.Join(c.root, "exec", action.Hash)
78-
if err := os.MkdirAll(dir, 0755); err != nil {
79-
return "", fmt.Errorf("failed to create %s directory: %w", dir, err)
92+
rel := filepath.Join("exec", action.Hash)
93+
if err := c.root.MkdirAll(rel, 0755); err != nil {
94+
return "", fmt.Errorf("failed to create %s directory: %w", rel, err)
8095
}
81-
return dir, nil
96+
return filepath.Join(c.root.Name(), rel), nil
8297
}

internal/cache/cache_test.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"crypto/sha256"
55
"encoding/hex"
66
"errors"
7+
"io/fs"
78
"os"
89
"path/filepath"
910
"testing"
@@ -15,6 +16,7 @@ func testCache(t *testing.T) *Cache {
1516
if err != nil {
1617
t.Fatal(err)
1718
}
19+
t.Cleanup(func() { c.Close() })
1820
return c
1921
}
2022

@@ -66,14 +68,14 @@ func TestCASCorruptionEvicted(t *testing.T) {
6668

6769
// Corrupt the blob on disk while keeping its size.
6870
path := c.CAS.path(d)
69-
if err := os.WriteFile(path, []byte("tampered contents"), 0644); err != nil {
71+
if err := c.CAS.root.WriteFile(path, []byte("tampered contents"), 0644); err != nil {
7072
t.Fatal(err)
7173
}
7274

7375
if _, err := c.CAS.Get(d); !errors.Is(err, ErrNotFound) {
7476
t.Errorf("want ErrNotFound for corrupt blob, got %v", err)
7577
}
76-
if _, err := os.Stat(path); !os.IsNotExist(err) {
78+
if _, err := c.CAS.root.Stat(path); !errors.Is(err, fs.ErrNotExist) {
7779
t.Error("corrupt blob was not evicted")
7880
}
7981
}
@@ -89,7 +91,7 @@ func TestCASPutRepairsCorruptEntry(t *testing.T) {
8991
// Corrupt the entry on disk with different-sized contents, then Put the
9092
// correct blob again: the entry must be repaired, not trusted.
9193
path := c.CAS.path(d)
92-
if err := os.WriteFile(path, append(blob, "tampered"...), 0644); err != nil {
94+
if err := c.CAS.root.WriteFile(path, append(blob, "tampered"...), 0644); err != nil {
9395
t.Fatal(err)
9496
}
9597
if _, err := c.CAS.Put(blob); err != nil {
@@ -201,7 +203,7 @@ func TestActionCacheMissingOutputIsMiss(t *testing.T) {
201203
}
202204

203205
// Simulate the blob being garbage collected out from under the entry.
204-
if err := os.Remove(c.CAS.path(out)); err != nil {
206+
if err := c.CAS.root.Remove(c.CAS.path(out)); err != nil {
205207
t.Fatal(err)
206208
}
207209

internal/cache/cas.go

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ package cache
33
import (
44
"errors"
55
"fmt"
6+
"io/fs"
7+
"math/rand/v2"
68
"os"
79
"path/filepath"
10+
"strconv"
811
)
912

1013
// ErrNotFound is returned when a blob or action result is not in the cache.
@@ -19,21 +22,37 @@ var ErrNotFound = errors.New("cache: not found")
1922
// Content with an externally declared checksum (remotely fetched plugins)
2023
// needs no action cache entry: the declared sha256 is the address, so it is
2124
// stored and loaded directly — see SHA256Digest.
25+
//
26+
// All I/O goes through an os.Root, so no entry name — hash-derived or read
27+
// from an action cache entry — can escape the cache directory.
2228
type CAS struct {
23-
root string
24-
tmp string
29+
root *os.Root
2530
}
2631

27-
func newCAS(root string) (*CAS, error) {
28-
tmp := filepath.Join(root, "tmp")
29-
if err := os.MkdirAll(tmp, 0755); err != nil {
30-
return nil, fmt.Errorf("cache: create %s: %w", tmp, err)
32+
func newCAS(root *os.Root) (*CAS, error) {
33+
if err := root.MkdirAll("tmp", 0755); err != nil {
34+
return nil, fmt.Errorf("cache: create tmp: %w", err)
3135
}
32-
return &CAS{root: root, tmp: tmp}, nil
36+
return &CAS{root: root}, nil
3337
}
3438

39+
// path returns a blob's path relative to the cache root.
3540
func (c *CAS) path(d Digest) string {
36-
return filepath.Join(c.root, "cas", d.Hash[:2], d.Hash)
41+
return filepath.Join("cas", d.Hash[:2], d.Hash)
42+
}
43+
44+
// createTemp creates a staging file under tmp/ in the cache root, returning
45+
// the open file and its root-relative name.
46+
func (c *CAS) createTemp(prefix string) (*os.File, string, error) {
47+
for range 10000 {
48+
name := filepath.Join("tmp", prefix+strconv.FormatUint(rand.Uint64(), 36))
49+
f, err := c.root.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0644)
50+
if errors.Is(err, fs.ErrExist) {
51+
continue
52+
}
53+
return f, name, err
54+
}
55+
return nil, "", errors.New("cache: could not create temp file")
3756
}
3857

3958
// Put stores a blob and returns its digest. Writing is atomic: the blob is
@@ -45,25 +64,25 @@ func (c *CAS) Put(data []byte) (Digest, error) {
4564
// Skip the write only when an entry of the right size already exists; a
4665
// wrong-sized entry is corrupt and is atomically replaced by the rename
4766
// below.
48-
if fi, err := os.Stat(path); err == nil && fi.Size() == d.SizeBytes {
67+
if fi, err := c.root.Stat(path); err == nil && fi.Size() == d.SizeBytes {
4968
return d, nil
5069
}
51-
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
70+
if err := c.root.MkdirAll(filepath.Dir(path), 0755); err != nil {
5271
return Digest{}, fmt.Errorf("cache: %w", err)
5372
}
54-
f, err := os.CreateTemp(c.tmp, d.Hash[:8]+"-*")
73+
f, name, err := c.createTemp(d.Hash[:8] + "-")
5574
if err != nil {
5675
return Digest{}, fmt.Errorf("cache: %w", err)
5776
}
58-
defer os.Remove(f.Name())
77+
defer c.root.Remove(name)
5978
if _, err := f.Write(data); err != nil {
6079
f.Close()
6180
return Digest{}, fmt.Errorf("cache: %w", err)
6281
}
6382
if err := f.Close(); err != nil {
6483
return Digest{}, fmt.Errorf("cache: %w", err)
6584
}
66-
if err := os.Rename(f.Name(), path); err != nil {
85+
if err := c.root.Rename(name, path); err != nil {
6786
return Digest{}, fmt.Errorf("cache: %w", err)
6887
}
6988
return d, nil
@@ -77,15 +96,15 @@ func (c *CAS) Get(d Digest) ([]byte, error) {
7796
return nil, ErrNotFound
7897
}
7998
path := c.path(d)
80-
data, err := os.ReadFile(path)
99+
data, err := c.root.ReadFile(path)
81100
if err != nil {
82-
if os.IsNotExist(err) {
101+
if errors.Is(err, fs.ErrNotExist) {
83102
return nil, ErrNotFound
84103
}
85104
return nil, fmt.Errorf("cache: %w", err)
86105
}
87106
if DigestOf(data).Hash != d.Hash {
88-
os.Remove(path)
107+
c.root.Remove(path)
89108
return nil, ErrNotFound
90109
}
91110
return data, nil
@@ -98,7 +117,7 @@ func (c *CAS) Contains(d Digest) bool {
98117
if !d.valid() {
99118
return false
100119
}
101-
fi, err := os.Stat(c.path(d))
120+
fi, err := c.root.Stat(c.path(d))
102121
if err != nil {
103122
return false
104123
}

internal/ext/wasm/wasm.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ func (r *Runner) loadAndCompile(ctx context.Context) (*runtimeAndCode, error) {
5858
if err != nil {
5959
return nil, err
6060
}
61+
defer store.Close()
6162
value, err, _ := flight.Do(expected, func() (any, error) {
6263
return r.loadAndCompileWASM(ctx, store, expected)
6364
})

0 commit comments

Comments
 (0)