Skip to content

Commit 4d2963e

Browse files
alecthomasclaude
andauthored
feat: add Namespace type with validation and hardcode metadata S3 prefix (#239)
Introduce cache.Namespace as a named string type with TextUnmarshaler-based validation. Names must match ^[a-zA-Z0-9][a-zA-Z0-9_-]*$, preventing collisions with the .metadata/ prefix used by the S3 metadata backend. - Replace string namespace parameters throughout the Cache interface and all implementations with the Namespace type - Remove configurable Prefix from metadatadb S3BackendConfig, hardcode .metadata as the prefix - Validate namespaces at API boundaries (HTTP handlers, strategy creation, CLI via Kong TextUnmarshaler) - Change Remote default namespace from "-" to "default" Co-authored-by: Claude Code <noreply@anthropic.com> Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent 09ebdfd commit 4d2963e

15 files changed

Lines changed: 173 additions & 78 deletions

File tree

cmd/cachew/main.go

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,9 @@ func main() {
6262
}
6363

6464
type GetCmd struct {
65-
Namespace string `arg:"" help:"Namespace for organizing cache objects."`
66-
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
67-
Output *os.File `short:"o" help:"Output file (default: stdout)." default:"-"`
65+
Namespace cache.Namespace `arg:"" help:"Namespace for organizing cache objects."`
66+
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
67+
Output *os.File `short:"o" help:"Output file (default: stdout)." default:"-"`
6868
}
6969

7070
func (c *GetCmd) Run(ctx context.Context, cache cache.Cache) error {
@@ -88,8 +88,8 @@ func (c *GetCmd) Run(ctx context.Context, cache cache.Cache) error {
8888
}
8989

9090
type StatCmd struct {
91-
Namespace string `arg:"" help:"Namespace for organizing cache objects."`
92-
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
91+
Namespace cache.Namespace `arg:"" help:"Namespace for organizing cache objects."`
92+
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
9393
}
9494

9595
func (c *StatCmd) Run(ctx context.Context, cache cache.Cache) error {
@@ -109,7 +109,7 @@ func (c *StatCmd) Run(ctx context.Context, cache cache.Cache) error {
109109
}
110110

111111
type PutCmd struct {
112-
Namespace string `arg:"" help:"Namespace for organizing cache objects."`
112+
Namespace cache.Namespace `arg:"" help:"Namespace for organizing cache objects."`
113113
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
114114
Input *os.File `arg:"" help:"Input file (default: stdin)." default:"-"`
115115
TTL time.Duration `help:"Time to live for the object."`
@@ -142,8 +142,8 @@ func (c *PutCmd) Run(ctx context.Context, cache cache.Cache) error {
142142
}
143143

144144
type DeleteCmd struct {
145-
Namespace string `arg:"" help:"Namespace for organizing cache objects."`
146-
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
145+
Namespace cache.Namespace `arg:"" help:"Namespace for organizing cache objects."`
146+
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
147147
}
148148

149149
func (c *DeleteCmd) Run(ctx context.Context, cache cache.Cache) error {
@@ -171,12 +171,12 @@ func (c *NamespacesCmd) Run(ctx context.Context, cache cache.Cache) error {
171171
}
172172

173173
type SnapshotCmd struct {
174-
Namespace string `arg:"" help:"Namespace for organizing cache objects."`
175-
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
176-
Directory string `arg:"" help:"Directory to archive." type:"path"`
177-
TTL time.Duration `help:"Time to live for the object."`
178-
Exclude []string `help:"Patterns to exclude (tar --exclude syntax)."`
179-
ZstdThreads int `help:"Threads for zstd compression (0 = all CPU cores)." default:"0"`
174+
Namespace cache.Namespace `arg:"" help:"Namespace for organizing cache objects."`
175+
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
176+
Directory string `arg:"" help:"Directory to archive." type:"path"`
177+
TTL time.Duration `help:"Time to live for the object."`
178+
Exclude []string `help:"Patterns to exclude (tar --exclude syntax)."`
179+
ZstdThreads int `help:"Threads for zstd compression (0 = all CPU cores)." default:"0"`
180180
}
181181

182182
func (c *SnapshotCmd) Run(ctx context.Context, cache cache.Cache) error {
@@ -191,10 +191,10 @@ func (c *SnapshotCmd) Run(ctx context.Context, cache cache.Cache) error {
191191
}
192192

193193
type RestoreCmd struct {
194-
Namespace string `arg:"" help:"Namespace for organizing cache objects."`
195-
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
196-
Directory string `arg:"" help:"Target directory for extraction." type:"path"`
197-
ZstdThreads int `help:"Threads for zstd decompression (0 = all CPU cores)." default:"0"`
194+
Namespace cache.Namespace `arg:"" help:"Namespace for organizing cache objects."`
195+
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
196+
Directory string `arg:"" help:"Target directory for extraction." type:"path"`
197+
ZstdThreads int `help:"Threads for zstd decompression (0 = all CPU cores)." default:"0"`
198198
}
199199

200200
func (c *RestoreCmd) Run(ctx context.Context, cache cache.Cache) error {

internal/cache/api.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,47 @@ import (
88
"io"
99
"net/http"
1010
"os"
11+
"regexp"
1112
"time"
1213

1314
"github.com/alecthomas/errors"
1415
"github.com/alecthomas/hcl/v2"
1516
)
1617

18+
var namespaceRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)
19+
20+
// Namespace identifies a logical partition within a cache or metadata store.
21+
// Valid names start with an alphanumeric character and contain only
22+
// alphanumerics, hyphens, and underscores.
23+
type Namespace string
24+
25+
// ValidateNamespace checks that a namespace name is valid.
26+
func ValidateNamespace(name string) error {
27+
if !namespaceRe.MatchString(name) {
28+
return errors.Errorf("invalid namespace %q: must match %s", name, namespaceRe)
29+
}
30+
return nil
31+
}
32+
33+
// ParseNamespace validates and returns a Namespace from a plain string.
34+
func ParseNamespace(name string) (Namespace, error) {
35+
if err := ValidateNamespace(name); err != nil {
36+
return "", err
37+
}
38+
return Namespace(name), nil
39+
}
40+
41+
func (n *Namespace) String() string { return string(*n) }
42+
43+
// UnmarshalText implements encoding.TextUnmarshaler with validation.
44+
func (n *Namespace) UnmarshalText(text []byte) error {
45+
if err := ValidateNamespace(string(text)); err != nil {
46+
return err
47+
}
48+
*n = Namespace(text)
49+
return nil
50+
}
51+
1752
// ErrNotFound is returned when a cache backend is not found.
1853
var ErrNotFound = errors.New("cache backend not found")
1954

@@ -143,7 +178,7 @@ type Cache interface {
143178
String() string
144179
// Namespace creates a namespaced view of this cache.
145180
// All operations on the returned cache will use the given namespace prefix.
146-
Namespace(namespace string) Cache
181+
Namespace(namespace Namespace) Cache
147182
// Stat returns the headers of an existing object in the cache.
148183
//
149184
// Expired files MUST not be returned.

internal/cache/api_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package cache_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/alecthomas/assert/v2"
7+
8+
"github.com/block/cachew/internal/cache"
9+
)
10+
11+
func TestValidateNamespace(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
input string
15+
valid bool
16+
}{
17+
{name: "Simple", input: "git", valid: true},
18+
{name: "WithHyphen", input: "go-mod", valid: true},
19+
{name: "WithUnderscore", input: "go_mod", valid: true},
20+
{name: "WithNumbers", input: "v2cache", valid: true},
21+
{name: "UpperCase", input: "GitLFS", valid: true},
22+
{name: "Empty", input: "", valid: false},
23+
{name: "DotPrefix", input: ".metadata", valid: false},
24+
{name: "DotInMiddle", input: "go.mod", valid: false},
25+
{name: "Slash", input: "a/b", valid: false},
26+
{name: "Space", input: "a b", valid: false},
27+
{name: "HyphenPrefix", input: "-foo", valid: false},
28+
{name: "UnderscorePrefix", input: "_foo", valid: false},
29+
}
30+
for _, tt := range tests {
31+
t.Run(tt.name, func(t *testing.T) {
32+
err := cache.ValidateNamespace(tt.input)
33+
if tt.valid {
34+
assert.NoError(t, err)
35+
} else {
36+
assert.Error(t, err)
37+
}
38+
})
39+
}
40+
}

internal/cache/disk.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ type DiskConfig struct {
3838
type Disk struct {
3939
logger *slog.Logger
4040
config DiskConfig
41-
namespace string
41+
namespace Namespace
4242
db *diskMetaDB
4343
size *atomic.Int64
4444
runEviction chan struct{}
@@ -287,12 +287,12 @@ func (d *Disk) Open(ctx context.Context, key Key) (io.ReadCloser, http.Header, e
287287
return f, headers, nil
288288
}
289289

290-
func (d *Disk) keyToPath(namespace string, key Key) string {
290+
func (d *Disk) keyToPath(namespace Namespace, key Key) string {
291291
hexKey := key.String()
292292

293293
// Use first two hex digits as directory, full hex as filename
294294
if namespace != "" {
295-
return filepath.Join(namespace, hexKey[:2], hexKey)
295+
return filepath.Join(string(namespace), hexKey[:2], hexKey)
296296
}
297297
return filepath.Join(hexKey[:2], hexKey)
298298
}
@@ -320,7 +320,7 @@ func (d *Disk) evictionLoop(ctx context.Context) {
320320
}
321321

322322
type evictFileInfo struct {
323-
namespace string
323+
namespace Namespace
324324
key Key
325325
path string
326326
size int64
@@ -329,7 +329,7 @@ type evictFileInfo struct {
329329
}
330330

331331
type evictEntryKey struct {
332-
namespace string
332+
namespace Namespace
333333
key Key
334334
}
335335

@@ -338,7 +338,7 @@ func (d *Disk) evict() error {
338338
var expiredEntries []evictEntryKey
339339
now := time.Now()
340340

341-
err := d.db.walk(func(key Key, namespace string, expiresAt time.Time) error {
341+
err := d.db.walk(func(key Key, namespace Namespace, expiresAt time.Time) error {
342342
path := d.keyToPath(namespace, key)
343343
fullPath := filepath.Join(d.config.Root, path)
344344

@@ -421,7 +421,7 @@ type diskWriter struct {
421421
disk *Disk
422422
file *os.File
423423
key Key
424-
namespace string
424+
namespace Namespace
425425
path string
426426
tempPath string
427427
expiresAt time.Time
@@ -477,7 +477,7 @@ func (w *diskWriter) Close() error {
477477
}
478478

479479
// Namespace creates a namespaced view of the disk cache.
480-
func (d *Disk) Namespace(namespace string) Cache {
480+
func (d *Disk) Namespace(namespace Namespace) Cache {
481481
// Create a shallow copy with the namespace set
482482
c := *d
483483
c.namespace = namespace

internal/cache/disk_metadb.go

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,16 @@ var (
2222
// diskMetaDB manages expiration times and headers for cache entries using bbolt.
2323
type diskMetaDB struct {
2424
db *bbolt.DB
25-
namespacesCache sync.Map // map[string]bool - concurrent-safe
25+
namespacesCache sync.Map // map[Namespace]bool - concurrent-safe
2626
}
2727

2828
// compositeKey creates a unique database key from namespace and cache key.
2929
// Format: "namespace/hexkey" when namespace is set, or just "hexkey" when empty.
30-
func compositeKey(namespace string, key Key) []byte {
30+
func compositeKey(namespace Namespace, key Key) []byte {
3131
if namespace == "" {
3232
return []byte(key.String())
3333
}
34-
return []byte(namespace + "/" + key.String())
34+
return []byte(string(namespace) + "/" + key.String())
3535
}
3636

3737
// newDiskMetaDB creates a new bbolt-backed metadata storage for the disk cache.
@@ -65,7 +65,7 @@ func newDiskMetaDB(dbPath string) (*diskMetaDB, error) {
6565
return ttlBucket.ForEach(func(k, _ []byte) error {
6666
namespace, _, found := bytes.Cut(k, []byte("/"))
6767
if found && len(namespace) > 0 {
68-
metaDB.namespacesCache.Store(string(namespace), true)
68+
metaDB.namespacesCache.Store(Namespace(namespace), true)
6969
}
7070
return nil
7171
})
@@ -77,7 +77,7 @@ func newDiskMetaDB(dbPath string) (*diskMetaDB, error) {
7777
return metaDB, nil
7878
}
7979

80-
func (s *diskMetaDB) setTTL(namespace string, key Key, expiresAt time.Time) error {
80+
func (s *diskMetaDB) setTTL(namespace Namespace, key Key, expiresAt time.Time) error {
8181
ttlBytes, err := expiresAt.MarshalBinary()
8282
if err != nil {
8383
return errors.Errorf("failed to marshal TTL: %w", err)
@@ -100,7 +100,7 @@ func (s *diskMetaDB) setTTL(namespace string, key Key, expiresAt time.Time) erro
100100
return nil
101101
}
102102

103-
func (s *diskMetaDB) set(key Key, namespace string, expiresAt time.Time, headers http.Header) error {
103+
func (s *diskMetaDB) set(key Key, namespace Namespace, expiresAt time.Time, headers http.Header) error {
104104
ttlBytes, err := expiresAt.MarshalBinary()
105105
if err != nil {
106106
return errors.Errorf("failed to marshal TTL: %w", err)
@@ -133,7 +133,7 @@ func (s *diskMetaDB) set(key Key, namespace string, expiresAt time.Time, headers
133133
return nil
134134
}
135135

136-
func (s *diskMetaDB) getTTL(namespace string, key Key) (time.Time, error) {
136+
func (s *diskMetaDB) getTTL(namespace Namespace, key Key) (time.Time, error) {
137137
var expiresAt time.Time
138138
dbKey := compositeKey(namespace, key)
139139
err := s.db.View(func(tx *bbolt.Tx) error {
@@ -147,7 +147,7 @@ func (s *diskMetaDB) getTTL(namespace string, key Key) (time.Time, error) {
147147
return expiresAt, errors.WithStack(err)
148148
}
149149

150-
func (s *diskMetaDB) getHeaders(namespace string, key Key) (http.Header, error) {
150+
func (s *diskMetaDB) getHeaders(namespace Namespace, key Key) (http.Header, error) {
151151
var headers http.Header
152152
dbKey := compositeKey(namespace, key)
153153
err := s.db.View(func(tx *bbolt.Tx) error {
@@ -161,7 +161,7 @@ func (s *diskMetaDB) getHeaders(namespace string, key Key) (http.Header, error)
161161
return headers, errors.WithStack(err)
162162
}
163163

164-
func (s *diskMetaDB) delete(namespace string, key Key) error {
164+
func (s *diskMetaDB) delete(namespace Namespace, key Key) error {
165165
dbKey := compositeKey(namespace, key)
166166
return errors.WithStack(s.db.Update(func(tx *bbolt.Tx) error {
167167
ttlBucket := tx.Bucket(ttlBucketName)
@@ -195,19 +195,19 @@ func (s *diskMetaDB) deleteAll(entries []evictEntryKey) error {
195195
}))
196196
}
197197

198-
func (s *diskMetaDB) walk(fn func(key Key, namespace string, expiresAt time.Time) error) error {
198+
func (s *diskMetaDB) walk(fn func(key Key, namespace Namespace, expiresAt time.Time) error) error {
199199
return errors.WithStack(s.db.View(func(tx *bbolt.Tx) error {
200200
ttlBucket := tx.Bucket(ttlBucketName)
201201
if ttlBucket == nil {
202202
return nil
203203
}
204204
return ttlBucket.ForEach(func(k, v []byte) error {
205-
var namespace string
205+
var namespace Namespace
206206
var key Key
207207

208208
before, hexKey, found := bytes.Cut(k, []byte("/"))
209209
if found {
210-
namespace = string(before)
210+
namespace = Namespace(before)
211211
} else {
212212
hexKey = k
213213
}
@@ -251,8 +251,8 @@ func (s *diskMetaDB) close() error {
251251
func (s *diskMetaDB) listNamespaces() ([]string, error) {
252252
var namespaces []string
253253
s.namespacesCache.Range(func(key, _ any) bool {
254-
if ns, ok := key.(string); ok {
255-
namespaces = append(namespaces, ns)
254+
if ns, ok := key.(Namespace); ok {
255+
namespaces = append(namespaces, string(ns))
256256
}
257257
return true
258258
})

0 commit comments

Comments
 (0)