diff --git a/CLAUDE.md b/CLAUDE.md index f7441a4..8b9a961 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -`go-cache` is a storage-agnostic, JSON-based caching library for Go. Module path: `dappco.re/go/core/cache`. The entire package is two files: `cache.go` and `cache_test.go`. +`go-cache` is a storage-agnostic, JSON-based caching library for Go. Module path: `dappco.re/go/cache`. The entire package is two files: `cache.go` and `cache_test.go`. ## Commands diff --git a/cache.go b/cache.go index f5c366c..1674a98 100644 --- a/cache.go +++ b/cache.go @@ -4,12 +4,17 @@ package cache import ( - "encoding/json" + // Note: AX-6 — structural: coreio.Medium surfaces fs.ErrNotExist/fs.DirEntry, and Lstat symlink checks use fs.ModeSymlink. "io/fs" + // Note: AX-6 — intrinsic: coreio.Medium has no no-follow Lstat primitive or dynamic cwd lookup. + "os" + "slices" + "sync" // Note: AX-6 — structural concurrency primitive for entry-level write serialisation. + // Note: AX-6 — no core equivalent for durations or wall-clock timestamps. "time" "dappco.re/go/core" - coreio "dappco.re/go/core/io" + coreio "dappco.re/go/io" ) // DefaultTTL is the default cache expiry time. @@ -19,211 +24,1942 @@ import ( // c, err := cache.New(coreio.NewMockMedium(), "/tmp/cache", cache.DefaultTTL) const DefaultTTL = 1 * time.Hour +const ( + maxCacheKeyBytes = 4096 + maxCachePatternBytes = 4096 + maxCacheNameBytes = 255 + maxCachedRequestURLBytes = 8192 + maxCachedRequestMethodBytes = 32 + maxCachedStatusTextBytes = 1024 + maxCachedHeaderNameBytes = 256 + maxCachedHeaderValueBytes = 8192 + maxCachedHeaderCount = 128 +) + // Cache stores JSON-encoded entries in a Medium-backed cache rooted at baseDir. +// +// c, err := cache.New(coreio.Local, "/tmp/cache", 5*time.Minute) type Cache struct { + medium coreio.Medium + baseDir string + cacheTTL time.Duration + invalidation map[string][]InvalidateFunc + entryMu sync.RWMutex + runtime *core.Core +} + +// Entry is the serialized cache record written to the backing Medium. +// +// entry := cache.Entry{ +// Data: []byte(`{"foo":"bar"}`), +// CachedAt: time.Now(), +// ExpiresAt: time.Now().Add(time.Hour), +// } +type Entry struct { + Data rawJSON `json:"data"` + CachedAt time.Time `json:"cached_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +type rawJSON []byte + +func (raw rawJSON) MarshalJSON() ([]byte, error) { + if raw == nil { + return []byte("null"), nil + } + return raw, nil +} + +func (raw *rawJSON) UnmarshalJSON(data []byte) error { + if raw == nil { + return core.E("cache.rawJSON.UnmarshalJSON", "target is nil", nil) + } + *raw = append((*raw)[0:0], data...) + return nil +} + +func marshalPrettyJSON(value any) (string, error) { + result := core.JSONMarshal(value) + if !result.OK { + return "", result.Value.(error) + } + return indentJSON([]byte(core.JSONMarshalString(value))), nil +} + +func indentJSON(data []byte) string { + builder := core.NewBuilder() + indent := 0 + inString := false + escaped := false + + writeIndent := func() { + for i := 0; i < indent; i++ { + builder.WriteString(" ") + } + } + + for i, c := range data { + if inString { + builder.WriteByte(c) + if escaped { + escaped = false + continue + } + switch c { + case '\\': + escaped = true + case '"': + inString = false + } + continue + } + + switch c { + case '"': + inString = true + builder.WriteByte(c) + case '{', '[': + builder.WriteByte(c) + next := nextNonJSONSpace(data, i+1) + if next >= 0 && ((c == '{' && data[next] == '}') || (c == '[' && data[next] == ']')) { + continue + } + indent++ + builder.WriteByte('\n') + writeIndent() + case '}', ']': + previous := previousNonJSONSpace(data, i-1) + if previous >= 0 && ((c == '}' && data[previous] == '{') || (c == ']' && data[previous] == '[')) { + builder.WriteByte(c) + continue + } + if indent > 0 { + indent-- + } + builder.WriteByte('\n') + writeIndent() + builder.WriteByte(c) + case ',': + builder.WriteByte(c) + builder.WriteByte('\n') + writeIndent() + case ':': + builder.WriteString(": ") + default: + if !isJSONSpace(c) { + builder.WriteByte(c) + } + } + } + + return builder.String() +} + +func nextNonJSONSpace(data []byte, start int) int { + for i := start; i < len(data); i++ { + if !isJSONSpace(data[i]) { + return i + } + } + return -1 +} + +func previousNonJSONSpace(data []byte, start int) int { + for i := start; i >= 0; i-- { + if !isJSONSpace(data[i]) { + return i + } + } + return -1 +} + +func isJSONSpace(c byte) bool { + return c == ' ' || c == '\n' || c == '\r' || c == '\t' +} + +// BinaryMeta is the metadata for binary cache payloads. +// +// { +// "content_type":"application/wasm", +// "size":1048576, +// "cached_at":"2026-04-14T00:00:00Z", +// "expires_at":"2026-04-15T00:00:00Z" +// } +type BinaryMeta struct { + ContentType string `json:"content_type"` + Size int64 `json:"size"` + CachedAt time.Time `json:"cached_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +// InvalidateFunc returns glob patterns to delete when a registered trigger fires. +// +// c.OnInvalidate("dns.tree-root-changed", func(trigger string) []string { +// return []string{"dns/*"} +// }) +type InvalidateFunc func(trigger string) []string + +// New creates a cache with explicit storage, root directory, and TTL. +// +// c, err := cache.New(coreio.Local, "/tmp/cache", 5*time.Minute) +// c, err = cache.New(nil, "", 0) // uses Local, .core/cache, and DefaultTTL +func New(medium coreio.Medium, baseDir string, cacheTTL time.Duration) (*Cache, error) { + if medium == nil { + medium = coreio.Local + } + + if baseDir == "" { + cwd := currentDir() + if cwd == "" || cwd == "." { + return nil, core.E("cache.New", "failed to resolve current working directory", nil) + } + + baseDir = normalizePath(core.JoinPath(cwd, ".core", "cache")) + } else { + baseDir = absolutePath(baseDir) + } + + if cacheTTL < 0 { + return nil, core.E("cache.New", "ttl must be >= 0", nil) + } + + if cacheTTL == 0 { + cacheTTL = DefaultTTL + } + + if err := medium.EnsureDir(baseDir); err != nil { + return nil, core.E("cache.New", "failed to create cache directory", err) + } + + return &Cache{ + medium: medium, + baseDir: baseDir, + cacheTTL: cacheTTL, + invalidation: make(map[string][]InvalidateFunc), + runtime: core.New(), + }, nil +} + +// Path resolves the on-disk JSON path for a cache key. +// +// path, err := c.Path("github/acme/repos") +// // => /tmp/cache/github/acme/repos.json +func (cache *Cache) Path(key string) (string, error) { + if err := cache.ensureConfigured("cache.Path"); err != nil { + return "", err + } + + if err := ensureSafeKey(key); err != nil { + return "", err + } + + baseDir := absolutePath(cache.baseDir) + path := absolutePath(core.JoinPath(baseDir, key+".json")) + pathPrefix := normalizePath(core.Concat(baseDir, pathSeparator())) + + if path != baseDir && !core.HasPrefix(path, pathPrefix) { + return "", core.E("cache.Path", "invalid cache key: path traversal attempt", nil) + } + if err := ensureNoSymlinkPath(baseDir, path); err != nil { + return "", core.E("cache.Path", "invalid cache key: symlink escape attempt", err) + } + + return path, nil +} + +// entryPaths resolves the JSON and binary file paths for a cache key. +// +// jsonPath, binPath, err := c.entryPaths("github/acme/repos") +func (cache *Cache) entryPaths(key string) (string, string, error) { + jsonPath, err := cache.Path(key) + if err != nil { + return "", "", err + } + + baseDir := absolutePath(cache.baseDir) + binaryPath := absolutePath(core.JoinPath(baseDir, key+".bin")) + return jsonPath, binaryPath, nil +} + +// Get unmarshals the cached item into dest if it exists and has not expired. +// +// found, err := c.Get("github/acme/repos", &repos) +func (cache *Cache) Get(key string, dest any) (bool, error) { + if err := cache.ensureReady("cache.Get"); err != nil { + return false, err + } + + cache.entryMu.RLock() + defer cache.entryMu.RUnlock() + + path, err := cache.Path(key) + if err != nil { + return false, err + } + + dataStr, err := cache.medium.Read(path) + if err != nil { + if core.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, core.E("cache.Get", "failed to read cache file", err) + } + + var entry Entry + entryResult := core.JSONUnmarshalString(dataStr, &entry) + if !entryResult.OK { + return false, core.E("cache.Get", "failed to unmarshal cache entry", entryResult.Value.(error)) + } + + if time.Now().After(entry.ExpiresAt) { + return false, nil + } + + if err := core.JSONUnmarshal(entry.Data, dest); !err.OK { + return false, core.E("cache.Get", "failed to unmarshal cached data", err.Value.(error)) + } + + return true, nil +} + +// Set stores a value using the cache's default TTL. +// +// err := c.Set("github/acme/repos", repos) +// err = c.Set("config/theme", "dark") +func (cache *Cache) Set(key string, data any) error { + if err := cache.ensureReady("cache.Set"); err != nil { + return err + } + return cache.set(key, data, cache.defaultTTL(), true) +} + +// SetWithTTL stores a value with an explicit TTL override. +// +// err := c.SetWithTTL("dns/example.com/A", records, 5*time.Minute) +// err = c.SetWithTTL("session/token", token, 30*time.Second) +func (cache *Cache) SetWithTTL(key string, data any, ttl time.Duration) error { + if err := cache.ensureReady("cache.SetWithTTL"); err != nil { + return err + } + return cache.set(key, data, ttl, false) +} + +func (cache *Cache) set(key string, data any, ttl time.Duration, useDefaultTTL bool) error { + if err := cache.ensureReady("cache.set"); err != nil { + return err + } + + cache.entryMu.Lock() + defer cache.entryMu.Unlock() + + path, _, err := cache.entryPaths(key) + if err != nil { + return err + } + + snapshot, err := readFileSnapshot(cache.medium, path) + if err != nil { + return core.E("cache.set", "failed to inspect existing cache entry", err) + } + + if err := cache.medium.EnsureDir(core.PathDir(path)); err != nil { + return core.E("cache.Set", "failed to create directory", err) + } + + dataResult := core.JSONMarshal(data) + if !dataResult.OK { + return core.E("cache.Set", "failed to marshal cache data", dataResult.Value.(error)) + } + + if ttl < 0 { + return core.E("cache.set", "cache ttl must be >= 0", nil) + } + if ttl == 0 && useDefaultTTL { + ttl = cache.defaultTTL() + } + + now := time.Now() + entry := Entry{ + Data: rawJSON(dataResult.Value.([]byte)), + CachedAt: now, + ExpiresAt: now.Add(ttl), + } + + entryJSON, err := marshalPrettyJSON(entry) + if err != nil { + return core.E("cache.Set", "failed to marshal cache entry", err) + } + + if err := cache.medium.Write(path, entryJSON); err != nil { + _ = restoreFileSnapshot(cache.medium, snapshot) + return core.E("cache.set", "failed to write cache file", err) + } + return nil +} + +// Delete removes one cached entry. +// +// err := c.Delete("github/acme/repos") +func (cache *Cache) Delete(key string) error { + if err := cache.ensureReady("cache.Delete"); err != nil { + return err + } + + _, err := cache.removeEntryFiles(key) + if core.Is(err, fs.ErrNotExist) { + return nil + } + return err +} + +// removeEntryFiles deletes both the JSON metadata and sidecar binary payload for a key. +func (cache *Cache) removeEntryFiles(key string) (bool, error) { + if err := cache.ensureReady("cache.removeEntryFiles"); err != nil { + return false, err + } + + cache.entryMu.Lock() + defer cache.entryMu.Unlock() + + jsonPath, binaryPath, err := cache.entryPaths(key) + if err != nil { + return false, err + } + + removed := false + if err := cache.medium.Delete(jsonPath); err != nil { + if !core.Is(err, fs.ErrNotExist) { + return removed, core.E("cache.removeEntryFiles", "failed to delete cache json file", err) + } + } else { + removed = true + } + + if err := cache.medium.Delete(binaryPath); err != nil { + if !core.Is(err, fs.ErrNotExist) { + return removed, core.E("cache.removeEntryFiles", "failed to delete cache binary file", err) + } + } else { + removed = true + } + + return removed, nil +} + +// SetBinary stores raw bytes in a sidecar `.bin` file and metadata in JSON. +// +// err := c.SetBinary("wasm/my-module", wasmBytes, "application/wasm") +// err = c.SetBinary("artifacts/logo", pngBytes, "image/png") +func (cache *Cache) SetBinary(key string, data []byte, contentType string) error { + if err := cache.ensureReady("cache.SetBinary"); err != nil { + return err + } + return cache.setBinary(key, data, contentType, cache.defaultTTL(), true) +} + +// SetBinaryWithTTL stores raw bytes with an explicit TTL override. +// +// err := c.SetBinaryWithTTL("responses/temp", body, "text/html", 10*time.Minute) +// err = c.SetBinaryWithTTL("dns/example.com/AAAA", raw, "application/octet-stream", 15*time.Second) +func (cache *Cache) SetBinaryWithTTL(key string, data []byte, contentType string, ttl time.Duration) error { + if err := cache.ensureReady("cache.SetBinaryWithTTL"); err != nil { + return err + } + return cache.setBinary(key, data, contentType, ttl, false) +} + +func (cache *Cache) setBinary(key string, data []byte, contentType string, ttl time.Duration, useDefaultTTL bool) error { + if err := cache.ensureReady("cache.setBinary"); err != nil { + return err + } + + cache.entryMu.Lock() + defer cache.entryMu.Unlock() + + jsonPath, binaryPath, err := cache.entryPaths(key) + if err != nil { + return err + } + + jsonSnapshot, err := readFileSnapshot(cache.medium, jsonPath) + if err != nil { + return core.E("cache.setBinary", "failed to inspect existing binary metadata", err) + } + binarySnapshot, err := readFileSnapshot(cache.medium, binaryPath) + if err != nil { + return core.E("cache.setBinary", "failed to inspect existing binary payload", err) + } + + if ttl < 0 { + return core.E("cache.setBinary", "cache ttl must be >= 0", nil) + } + if ttl == 0 && useDefaultTTL { + ttl = cache.defaultTTL() + } + + if err := cache.medium.EnsureDir(core.PathDir(jsonPath)); err != nil { + return core.E("cache.setBinary", "failed to create directory", err) + } + + now := time.Now() + meta := BinaryMeta{ + ContentType: contentType, + Size: int64(len(data)), + CachedAt: now, + ExpiresAt: now.Add(ttl), + } + + metaJSON, err := marshalPrettyJSON(meta) + if err != nil { + return core.E("cache.setBinary", "failed to marshal binary metadata", err) + } + + if err := cache.medium.Write(binaryPath, string(data)); err != nil { + _ = restoreFileSnapshot(cache.medium, jsonSnapshot) + _ = restoreFileSnapshot(cache.medium, binarySnapshot) + return core.E("cache.setBinary", "failed to write binary payload", err) + } + + if err := cache.medium.Write(jsonPath, metaJSON); err != nil { + _ = restoreFileSnapshot(cache.medium, binarySnapshot) + _ = restoreFileSnapshot(cache.medium, jsonSnapshot) + return core.E("cache.setBinary", "failed to write binary metadata", err) + } + + return nil +} + +// GetBinary returns raw binary cache payload. +// +// data, found, err := c.GetBinary("wasm/my-module") +func (cache *Cache) GetBinary(key string) ([]byte, bool, error) { + if err := cache.ensureReady("cache.GetBinary"); err != nil { + return nil, false, err + } + + cache.entryMu.RLock() + defer cache.entryMu.RUnlock() + + metaPath, binaryPath, err := cache.entryPaths(key) + if err != nil { + return nil, false, err + } + + rawMeta, err := cache.medium.Read(metaPath) + if err != nil { + if core.Is(err, fs.ErrNotExist) { + return nil, false, nil + } + return nil, false, core.E("cache.GetBinary", "failed to read binary metadata", err) + } + + var meta BinaryMeta + metaResult := core.JSONUnmarshalString(rawMeta, &meta) + if !metaResult.OK { + return nil, false, core.E("cache.GetBinary", "failed to unmarshal binary metadata", metaResult.Value.(error)) + } + + if time.Now().After(meta.ExpiresAt) { + return nil, false, nil + } + + body, err := cache.medium.Read(binaryPath) + if err != nil { + if core.Is(err, fs.ErrNotExist) { + return nil, false, nil + } + return nil, false, core.E("cache.GetBinary", "failed to read binary data", err) + } + + return []byte(body), true, nil +} + +// DeleteMany removes several entries in one call. Missing keys are ignored. +// +// err := c.DeleteMany("github/acme/repos", "github/acme/meta") +// err = c.DeleteMany("dns/example.com/A", "dns/example.com/AAAA") +func (cache *Cache) DeleteMany(keys ...string) error { + if err := cache.ensureReady("cache.DeleteMany"); err != nil { + return err + } + + cache.entryMu.Lock() + defer cache.entryMu.Unlock() + + type entryFileSet struct { + jsonPath string + binaryPath string + } + + resolved := make([]entryFileSet, 0, len(keys)) + for _, key := range keys { + jsonPath, binaryPath, err := cache.entryPaths(key) + if err != nil { + return err + } + resolved = append(resolved, entryFileSet{jsonPath: jsonPath, binaryPath: binaryPath}) + } + + for _, paths := range resolved { + if err := cache.medium.Delete(paths.jsonPath); err != nil && !core.Is(err, fs.ErrNotExist) { + return err + } + if err := cache.medium.Delete(paths.binaryPath); err != nil && !core.Is(err, fs.ErrNotExist) { + return err + } + } + + return nil +} + +func (cache *Cache) listJSONKeys() ([]string, error) { + keys, err := cache.collectJSONKeys("") + if err != nil { + return nil, err + } + slices.Sort(keys) + return keys, nil +} + +func (cache *Cache) collectJSONKeys(prefix string) ([]string, error) { + listPath := cache.baseDir + if prefix != "" { + listPath = core.JoinPath(cache.baseDir, prefix) + } + + entries, err := cache.medium.List(listPath) + if err != nil { + if core.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, core.E("cache.collectJSONKeys", "failed to list cache directory", err) + } + + var keys []string + for _, entry := range entries { + name := entry.Name() + childPrefix := name + if prefix != "" { + childPrefix = core.JoinPath(prefix, name) + } + + if entry.IsDir() { + childKeys, err := cache.collectJSONKeys(childPrefix) + if err != nil { + return nil, err + } + keys = append(keys, childKeys...) + continue + } + + if core.HasSuffix(name, ".json") { + keys = append(keys, core.TrimSuffix(childPrefix, ".json")) + } + } + return keys, nil +} + +func (cache *Cache) keysByPattern(pattern string) ([]string, error) { + if err := ensureSafePattern(pattern); err != nil { + return nil, err + } + + cache.entryMu.RLock() + defer cache.entryMu.RUnlock() + + allKeys, err := cache.listJSONKeys() + if err != nil { + return nil, err + } + + var matched []string + for _, key := range allKeys { + ok, err := matchKeyPattern(pattern, key) + if err != nil { + return nil, core.E("cache.keysByPattern", "failed to match pattern", err) + } + if ok { + matched = append(matched, key) + } + } + return matched, nil +} + +func (cache *Cache) clearScope(prefix string) error { + keys, err := cache.keysByPattern(prefix) + if err != nil { + return err + } + descendants, err := cache.keysByPattern(prefix + "/*") + if err != nil { + return err + } + keys = append(keys, descendants...) + + for _, key := range keys { + if _, err := cache.removeEntryFiles(key); err != nil { + return err + } + } + + return nil +} + +// matchKeyPattern reports whether key matches the glob pattern. +// +// Supported patterns per RFC §12.4: +// +// "dns/*" — all keys under dns/ (any depth) +// "dns/charon.*" — dns/charon.lthn, dns/charon.local, etc. (single segment) +// "scope_a1b2c3/*" — all keys in a specific scope (any depth) +// "exact-key" — single key (no wildcard) +func matchKeyPattern(pattern, key string) (bool, error) { + if !containsAnyGlob(pattern) { + return pattern == key, nil + } + + // A trailing "/*" means "all descendants of this prefix" — any depth. + if core.HasSuffix(pattern, "/*") { + prefix := core.TrimSuffix(pattern, "/*") + if prefix == "" { + return true, nil + } + return core.HasPrefix(key, prefix+"/"), nil + } + + // Otherwise match a single path segment against the last pattern segment. + patternParts := core.Split(pattern, "/") + keyParts := core.Split(key, "/") + if len(patternParts) != len(keyParts) { + return false, nil + } + for i, part := range patternParts { + if !containsAnyGlob(part) { + if part != keyParts[i] { + return false, nil + } + continue + } + ok, err := segmentMatch(part, keyParts[i]) + if err != nil { + return false, err + } + if !ok { + return false, nil + } + } + return true, nil +} + +// containsAnyGlob reports whether s contains any glob metacharacter. +// +// containsAnyGlob("dns/*") // true +// containsAnyGlob("exact") // false +func containsAnyGlob(s string) bool { + for _, r := range s { + if r == '*' || r == '?' || r == '[' || r == ']' { + return true + } + } + return false +} + +// segmentMatch matches pattern against name within a single path segment. +// Supports '*' (any run of non-separator chars) and literal characters. +// +// segmentMatch("charon.*", "charon.lthn") // true +// segmentMatch("charon.*", "other.lthn") // false +func segmentMatch(pattern, name string) (bool, error) { + p, n := 0, 0 + starP, starN := -1, 0 + for n < len(name) { + if p < len(pattern) && (pattern[p] == '?' || pattern[p] == name[n]) { + p++ + n++ + continue + } + if p < len(pattern) && pattern[p] == '*' { + starP = p + starN = n + p++ + continue + } + if starP != -1 { + p = starP + 1 + starN++ + n = starN + continue + } + return false, nil + } + for p < len(pattern) && pattern[p] == '*' { + p++ + } + return p == len(pattern), nil +} + +// OnInvalidate registers a trigger callback that returns patterns to delete. +// +// c.OnInvalidate("dns.tree-root-changed", func(trigger string) []string { +// return []string{"dns/*"} +// }) +func (cache *Cache) OnInvalidate(trigger string, fn InvalidateFunc) { + if err := cache.ensureReady("cache.OnInvalidate"); err != nil { + return + } + if fn == nil { + return + } + lock := cache.runtime.Lock("cache") + lock.Mutex.Lock() + defer lock.Mutex.Unlock() + if cache.invalidation == nil { + cache.invalidation = make(map[string][]InvalidateFunc) + } + cache.invalidation[trigger] = append(cache.invalidation[trigger], fn) +} + +// Invalidate executes trigger callbacks and deletes matching entries. +// +// deleted, err := c.Invalidate("dns.tree-root-changed") +func (cache *Cache) Invalidate(trigger string) (int, error) { + if err := cache.ensureReady("cache.Invalidate"); err != nil { + return 0, err + } + + lock := cache.runtime.Lock("cache") + lock.Mutex.RLock() + callbacks := append([]InvalidateFunc(nil), cache.invalidation[trigger]...) + lock.Mutex.RUnlock() + total := 0 + for _, callback := range callbacks { + for _, pattern := range callback(trigger) { + if pattern == "" { + continue + } + matches, err := cache.keysByPattern(pattern) + if err != nil { + return total, err + } + for _, key := range matches { + removed, err := cache.removeEntryFiles(key) + if err != nil { + return total, err + } + if removed { + total++ + } + } + } + } + + return total, nil +} + +// Scoped returns a cache namespaced by origin hash. +// +// scoped := c.Scoped("https://app.example.com") +// _ = scoped.Set("user/profile", profile) +func (cache *Cache) Scoped(origin string) *ScopedCache { + if cache == nil { + return nil + } + return &ScopedCache{ + parent: cache, + prefix: scopePrefix(origin), + } +} + +// ClearScope removes cache entries for a scoped origin. +// +// err := c.ClearScope("https://app.example.com") +func (cache *Cache) ClearScope(origin string) error { + if err := cache.ensureReady("cache.ClearScope"); err != nil { + return err + } + + prefix := scopePrefix(origin) + if err := ensureSafeKey(prefix); err != nil { + return err + } + return cache.clearScope(prefix) +} + +func (cache *Cache) defaultTTL() time.Duration { + if cache.cacheTTL <= 0 { + return DefaultTTL + } + return cache.cacheTTL +} + +func ensureSafeKey(key string) error { + if key == "" { + return core.E("cache.validateKey", "invalid empty key", nil) + } + if len(key) > maxCacheKeyBytes { + return core.E("cache.validateKey", "invalid key: too long", nil) + } + if core.Contains(key, "\\") { + return core.E("cache.validateKey", "invalid key: contains path separators", nil) + } + if hasPathDangerousBytes(key) { + return core.E("cache.validateKey", "invalid key: contains control bytes", nil) + } + + for _, part := range core.Split(key, "/") { + if part == "" || part == "." || part == ".." { + return core.E("cache.validateKey", "invalid key: path traversal attempt", nil) + } + } + + return nil +} + +func ensureSafePattern(pattern string) error { + if pattern == "" { + return core.E("cache.validatePattern", "invalid empty pattern", nil) + } + if len(pattern) > maxCachePatternBytes { + return core.E("cache.validatePattern", "invalid pattern: too long", nil) + } + if core.Contains(pattern, "\\") || hasPathDangerousBytes(pattern) { + return core.E("cache.validatePattern", "invalid pattern: contains control bytes", nil) + } + return nil +} + +func ensureNoSymlinkPath(baseDir, path string) error { + if err := rejectSymlink(baseDir); err != nil { + return err + } + + if path == baseDir { + return nil + } + + rel := core.TrimPrefix(path, normalizePath(core.Concat(baseDir, pathSeparator()))) + if rel == path { + return nil + } + + current := baseDir + for _, part := range core.Split(rel, pathSeparator()) { + if part == "" { + continue + } + current = core.JoinPath(current, part) + if err := rejectSymlink(current); err != nil { + return err + } + } + return nil +} + +func rejectSymlink(path string) error { + info, err := os.Lstat(path) + if err != nil { + if core.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + if info.Mode()&fs.ModeSymlink != 0 { + return core.E("cache.validatePath", "path contains symlink", nil) + } + return nil +} + +func hasPathDangerousBytes(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] < 0x20 || s[i] == 0x7f { + return true + } + } + return false +} + +func ensureSafeResponseBodyPath(path string) error { + if path == "" { + return core.E("cache.validateResponseBodyPath", "invalid empty body path", nil) + } + if len(path) > maxCacheKeyBytes { + return core.E("cache.validateResponseBodyPath", "invalid body path: too long", nil) + } + if core.PathIsAbs(path) { + return core.E("cache.validateResponseBodyPath", "invalid body path: absolute paths are not allowed", nil) + } + if core.Contains(path, "\\") || hasPathDangerousBytes(path) { + return core.E("cache.validateResponseBodyPath", "invalid body path: contains control bytes", nil) + } + + normalized := normalizePath(path) + if !core.HasPrefix(normalized, "responses/") || !core.HasSuffix(normalized, ".bin") { + return core.E("cache.validateResponseBodyPath", "invalid body path: expected responses/.bin", nil) + } + + rel := core.TrimPrefix(normalized, "responses/") + rel = core.TrimSuffix(rel, ".bin") + if rel == "" { + return core.E("cache.validateResponseBodyPath", "invalid body path", nil) + } + + for _, segment := range core.Split(rel, "/") { + if err := ensureSafeKey(segment); err != nil { + return core.E("cache.validateResponseBodyPath", "invalid body path", err) + } + } + + return nil +} + +type ScopedCache struct { + parent *Cache + prefix string +} + +func scopePrefix(origin string) string { + return "scope_" + core.SHA256Hex([]byte(origin)) +} + +func (scopedCache *ScopedCache) fullKey(key string) string { + return scopedCache.prefix + "/" + key +} + +// Scoped returns a cache namespaced by a different origin. +// +// admin := scoped.Scoped("https://admin.example.com") +// _ = admin.Set("user/profile", profile) +func (scopedCache *ScopedCache) Scoped(origin string) *ScopedCache { + if scopedCache == nil || scopedCache.parent == nil { + return nil + } + return scopedCache.parent.Scoped(origin) +} + +func (scopedCache *ScopedCache) Path(key string) (string, error) { + if scopedCache == nil || scopedCache.parent == nil { + return "", core.E("cache.Scoped.Path", "scoped cache is nil", nil) + } + return scopedCache.parent.Path(scopedCache.fullKey(key)) +} + +func (scopedCache *ScopedCache) Get(key string, dest any) (bool, error) { + if scopedCache == nil || scopedCache.parent == nil { + return false, core.E("cache.Scoped.Get", "scoped cache is nil", nil) + } + return scopedCache.parent.Get(scopedCache.fullKey(key), dest) +} + +func (scopedCache *ScopedCache) Set(key string, value any) error { + if scopedCache == nil || scopedCache.parent == nil { + return core.E("cache.Scoped.Set", "scoped cache is nil", nil) + } + return scopedCache.parent.Set(scopedCache.fullKey(key), value) +} + +func (scopedCache *ScopedCache) SetWithTTL(key string, value any, ttl time.Duration) error { + if scopedCache == nil || scopedCache.parent == nil { + return core.E("cache.Scoped.SetWithTTL", "scoped cache is nil", nil) + } + return scopedCache.parent.SetWithTTL(scopedCache.fullKey(key), value, ttl) +} + +func (scopedCache *ScopedCache) SetBinary(key string, data []byte, contentType string) error { + if scopedCache == nil || scopedCache.parent == nil { + return core.E("cache.Scoped.SetBinary", "scoped cache is nil", nil) + } + return scopedCache.parent.SetBinary(scopedCache.fullKey(key), data, contentType) +} + +func (scopedCache *ScopedCache) SetBinaryWithTTL(key string, data []byte, contentType string, ttl time.Duration) error { + if scopedCache == nil || scopedCache.parent == nil { + return core.E("cache.Scoped.SetBinaryWithTTL", "scoped cache is nil", nil) + } + return scopedCache.parent.SetBinaryWithTTL(scopedCache.fullKey(key), data, contentType, ttl) +} + +func (scopedCache *ScopedCache) GetBinary(key string) ([]byte, bool, error) { + if scopedCache == nil || scopedCache.parent == nil { + return nil, false, core.E("cache.Scoped.GetBinary", "scoped cache is nil", nil) + } + return scopedCache.parent.GetBinary(scopedCache.fullKey(key)) +} + +func (scopedCache *ScopedCache) Delete(key string) error { + if scopedCache == nil || scopedCache.parent == nil { + return core.E("cache.Scoped.Delete", "scoped cache is nil", nil) + } + return scopedCache.parent.Delete(scopedCache.fullKey(key)) +} + +func (scopedCache *ScopedCache) DeleteMany(keys ...string) error { + if scopedCache == nil || scopedCache.parent == nil { + return core.E("cache.Scoped.DeleteMany", "scoped cache is nil", nil) + } + full := make([]string, len(keys)) + for i, key := range keys { + full[i] = scopedCache.fullKey(key) + } + return scopedCache.parent.DeleteMany(full...) +} + +// Clear removes all entries in the scope. +// +// err := scoped.Clear() +func (scopedCache *ScopedCache) Clear() error { + if scopedCache == nil || scopedCache.parent == nil { + return core.E("cache.Scoped.Clear", "scoped cache is nil", nil) + } + return scopedCache.parent.clearScope(scopedCache.prefix) +} + +// ClearScope removes cache entries for a scoped origin. +// +// err := scoped.ClearScope("https://app.example.com") +func (scopedCache *ScopedCache) ClearScope(origin string) error { + if scopedCache == nil || scopedCache.parent == nil { + return core.E("cache.Scoped.ClearScope", "scoped cache is nil", nil) + } + return scopedCache.parent.ClearScope(origin) +} + +func (scopedCache *ScopedCache) OnInvalidate(trigger string, fn InvalidateFunc) { + if scopedCache == nil || scopedCache.parent == nil { + return + } + if fn == nil { + return + } + + prefix := scopedCache.prefix + scopedCache.parent.OnInvalidate(trigger, func(trigger string) []string { + patterns := fn(trigger) + if len(patterns) == 0 { + return nil + } + + scopedPatterns := make([]string, 0, len(patterns)) + for _, pattern := range patterns { + if pattern == "" { + continue + } + scopedPatterns = append(scopedPatterns, scopePattern(prefix, pattern)) + } + return scopedPatterns + }) +} + +func (scopedCache *ScopedCache) Invalidate(trigger string) (int, error) { + if scopedCache == nil || scopedCache.parent == nil { + return 0, core.E("cache.Scoped.Invalidate", "scoped cache is nil", nil) + } + return scopedCache.parent.Invalidate(trigger) +} + +func (scopedCache *ScopedCache) Age(key string) time.Duration { + if scopedCache == nil || scopedCache.parent == nil { + return -1 + } + return scopedCache.parent.Age(scopedCache.fullKey(key)) +} + +func scopePattern(prefix, pattern string) string { + pattern = core.TrimPrefix(pattern, "/") + if pattern == "" { + return prefix + } + return prefix + "/" + pattern +} + +// CacheStorage manages named caches for HTTP cache API emulation. +// +// storage, _ := cache.NewCacheStorage(coreio.Local, "/tmp/cache-storage") +// appCache, err := storage.Open("my-app-v1") +// defer storage.Close() +type CacheStorage struct { + medium coreio.Medium + baseDir string + caches map[string]*HTTPCache + runtime *core.Core +} + +// NewCacheStorage creates a namespace container for HTTPCache instances. +// +// storage, err := cache.NewCacheStorage(coreio.Local, "/tmp/cache-storage") +func NewCacheStorage(medium coreio.Medium, baseDir string) (*CacheStorage, error) { + if medium == nil { + medium = coreio.Local + } + + if baseDir == "" { + cwd := currentDir() + if cwd == "" || cwd == "." { + return nil, core.E("cache.NewCacheStorage", "failed to resolve current working directory", nil) + } + baseDir = normalizePath(core.JoinPath(cwd, ".core", "cache-storage")) + } else { + baseDir = absolutePath(baseDir) + } + + if err := medium.EnsureDir(baseDir); err != nil { + return nil, core.E("cache.NewCacheStorage", "failed to create cache storage directory", err) + } + + return &CacheStorage{ + medium: medium, + baseDir: baseDir, + caches: make(map[string]*HTTPCache), + runtime: core.New(), + }, nil +} + +// Open retrieves a named HTTPCache, creating it on first use. +// +// staticCache, err := storage.Open("static-assets-v2") +// api, err := storage.Open("api-responses") +func (storage *CacheStorage) Open(name string) (*HTTPCache, error) { + if err := storage.ensureReady("cache.CacheStorage.Open"); err != nil { + return nil, err + } + if err := ensureSafeCacheName("cache.CacheStorage.Open", name); err != nil { + return nil, err + } + + lock := storage.runtime.Lock("cache-storage") + lock.Mutex.Lock() + defer lock.Mutex.Unlock() + if httpCache, ok := storage.caches[name]; ok { + return httpCache, nil + } + + cacheDir := core.JoinPath(storage.baseDir, name) + if err := storage.medium.EnsureDir(cacheDir); err != nil { + return nil, core.E("cache.CacheStorage.Open", "failed to create cache directory", err) + } + + httpCache := &HTTPCache{ + name: name, + medium: storage.medium, + baseDir: cacheDir, + } + storage.caches[name] = httpCache + return httpCache, nil +} + +// Delete removes a named HTTP cache and all entries. +// +// err := storage.Delete("static-assets-v1") +// err = storage.Delete("old-cache") +func (storage *CacheStorage) Delete(name string) error { + if err := storage.ensureReady("cache.CacheStorage.Delete"); err != nil { + return err + } + if err := ensureSafeCacheName("cache.CacheStorage.Delete", name); err != nil { + return err + } + + lock := storage.runtime.Lock("cache-storage") + lock.Mutex.Lock() + defer lock.Mutex.Unlock() + if err := storage.medium.DeleteAll(core.JoinPath(storage.baseDir, name)); err != nil && !core.Is(err, fs.ErrNotExist) { + return core.E("cache.CacheStorage.Delete", "failed to delete cache directory", err) + } + + delete(storage.caches, name) + return nil +} + +// ensureSafeCacheName rejects empty, path-separator, or traversal cache names. +func ensureSafeCacheName(op, name string) error { + if name == "" { + return core.E(op, "cache name is empty", nil) + } + if len(name) > maxCacheNameBytes { + return core.E(op, "invalid cache name: too long", nil) + } + if core.Contains(name, "/") || core.Contains(name, `\`) { + return core.E(op, "invalid cache name", nil) + } + if hasPathDangerousBytes(name) { + return core.E(op, "invalid cache name", nil) + } + if name == "." || name == ".." { + return core.E(op, "invalid cache name", nil) + } + return nil +} + +// Keys lists all named caches. +// +// names, err := storage.Keys() +// // ["static-assets-v2", "api-responses"] +func (storage *CacheStorage) Keys() ([]string, error) { + if err := storage.ensureReady("cache.CacheStorage.Keys"); err != nil { + return nil, err + } + + lock := storage.runtime.Lock("cache-storage") + lock.Mutex.RLock() + names := make(map[string]struct{}, len(storage.caches)) + for name := range storage.caches { + names[name] = struct{}{} + } + lock.Mutex.RUnlock() + + entries, err := storage.medium.List(storage.baseDir) + if err != nil { + if !core.Is(err, fs.ErrNotExist) { + return nil, core.E("cache.CacheStorage.Keys", "failed to list caches", err) + } + } + + for _, entry := range entries { + if entry.IsDir() { + names[entry.Name()] = struct{}{} + } + } + + out := make([]string, 0, len(names)) + for name := range names { + out = append(out, name) + } + slices.Sort(out) + return out, nil +} + +// Close releases storage resources for compatibility with long-lived workflows. +// +// _ = storage.Close() +// appCache, err := storage.Open("reused-cache") +func (storage *CacheStorage) Close() error { + if storage == nil { + return nil + } + if storage.runtime == nil { + storage.caches = make(map[string]*HTTPCache) + return nil + } + lock := storage.runtime.Lock("cache-storage") + lock.Mutex.Lock() + defer lock.Mutex.Unlock() + storage.caches = make(map[string]*HTTPCache) + return nil +} + +// HTTPCache stores request/response pairs. +// +// storage, _ := cache.NewCacheStorage(coreio.Local, "/tmp/cache-storage") +// appCache, _ := storage.Open("my-app-v1") +// err := appCache.Put(req, resp, body) +type HTTPCache struct { + name string medium coreio.Medium baseDir string - ttl time.Duration } -// Entry is the serialized cache record written to the backing Medium. -type Entry struct { - Data json.RawMessage `json:"data"` - CachedAt time.Time `json:"cached_at"` - ExpiresAt time.Time `json:"expires_at"` +func (storage *CacheStorage) ensureReady(op string) error { + if storage == nil { + return core.E(op, "cache storage is nil", nil) + } + if storage.medium == nil { + return core.E(op, "cache storage medium is nil; construct via cache.NewCacheStorage", nil) + } + if storage.baseDir == "" { + return core.E(op, "cache storage base directory is empty; construct via cache.NewCacheStorage", nil) + } + if storage.runtime == nil { + return core.E(op, "cache storage runtime is nil; construct via cache.NewCacheStorage", nil) + } + lock := storage.runtime.Lock("cache-storage") + lock.Mutex.Lock() + defer lock.Mutex.Unlock() + if storage.caches == nil { + storage.caches = make(map[string]*HTTPCache) + } + return nil } -// New creates a cache and applies default Medium, base directory, and TTL values -// when callers pass zero values. -// -// c, err := cache.New(coreio.Local, "/tmp/cache", time.Hour) -func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error) { - if medium == nil { - medium = coreio.Local +func (httpCache *HTTPCache) ensureReady(op string) error { + if httpCache == nil { + return core.E(op, "http cache is nil", nil) + } + if httpCache.medium == nil { + return core.E(op, "http cache medium is nil; construct via cache.CacheStorage.Open", nil) + } + if httpCache.baseDir == "" { + return core.E(op, "http cache base directory is empty; construct via cache.CacheStorage.Open", nil) } + return nil +} - if baseDir == "" { - cwd := currentDir() - if cwd == "" || cwd == "." { - return nil, core.E("cache.New", "failed to resolve current working directory", nil) - } +// CachedRequest identifies a request by URL and method. +// +// req := cache.CachedRequest{ +// URL: "https://api.example.com/users", +// Method: "GET", +// } +type CachedRequest struct { + URL string `json:"url"` + Method string `json:"method"` +} - baseDir = normalizePath(core.JoinPath(cwd, ".core", "cache")) - } else { - baseDir = absolutePath(baseDir) - } +// CachedResponse stores HTTP metadata for a cached response body. +// +// resp := cache.CachedResponse{ +// Status: 200, +// StatusText: "OK", +// Headers: map[string]string{"Content-Type": "application/json"}, +// BodyPath: "responses/a1b2c3.bin", +// } +type CachedResponse struct { + Status int `json:"status"` + StatusText string `json:"status_text"` + Headers map[string]string `json:"headers"` + BodyPath string `json:"body_path"` + CachedAt time.Time `json:"cached_at"` +} - if ttl < 0 { - return nil, core.E("cache.New", "ttl must be >= 0", nil) +type cachedResponseRecord struct { + Request CachedRequest `json:"request"` + Response CachedResponse `json:"response"` +} + +func (httpCache *HTTPCache) storagePath(parts ...string) string { + args := append([]string{httpCache.baseDir}, parts...) + return core.JoinPath(args...) +} + +func (httpCache *HTTPCache) requestKey(req CachedRequest) (string, error) { + return requestStorageKey(req) +} + +func legacyRequestKey(req CachedRequest) string { + return rawBase64URLEncode([]byte(req.Method + "\x00" + req.URL)) +} + +func rawBase64URLEncode(data []byte) string { + if len(data) == 0 { + return "" } - if ttl == 0 { - ttl = DefaultTTL + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + builder := core.NewBuilder() + + i := 0 + for ; i+3 <= len(data); i += 3 { + n := uint(data[i])<<16 | uint(data[i+1])<<8 | uint(data[i+2]) + builder.WriteByte(alphabet[(n>>18)&0x3f]) + builder.WriteByte(alphabet[(n>>12)&0x3f]) + builder.WriteByte(alphabet[(n>>6)&0x3f]) + builder.WriteByte(alphabet[n&0x3f]) } - if err := medium.EnsureDir(baseDir); err != nil { - return nil, core.E("cache.New", "failed to create cache directory", err) + switch len(data) - i { + case 1: + n := uint(data[i]) << 16 + builder.WriteByte(alphabet[(n>>18)&0x3f]) + builder.WriteByte(alphabet[(n>>12)&0x3f]) + case 2: + n := uint(data[i])<<16 | uint(data[i+1])<<8 + builder.WriteByte(alphabet[(n>>18)&0x3f]) + builder.WriteByte(alphabet[(n>>12)&0x3f]) + builder.WriteByte(alphabet[(n>>6)&0x3f]) } - return &Cache{ - medium: medium, - baseDir: baseDir, - ttl: ttl, - }, nil + return builder.String() } -// Path returns the storage path used for key and rejects path traversal -// attempts. -// -// path, err := c.Path("github/acme/repos") -func (c *Cache) Path(key string) (string, error) { - if err := c.ensureConfigured("cache.Path"); err != nil { - return "", err +func rawBase64URLDecode(encoded string) ([]byte, error) { + if core.Contains(encoded, "=") { + return nil, core.E("cache.rawBase64URLDecode", "raw URL base64 must not contain padding", nil) + } + if len(encoded)%4 == 1 { + return nil, core.E("cache.rawBase64URLDecode", "invalid raw URL base64 length", nil) } - baseDir := absolutePath(c.baseDir) - path := absolutePath(core.JoinPath(baseDir, key+".json")) - pathPrefix := normalizePath(core.Concat(baseDir, pathSeparator())) + out := make([]byte, 0, len(encoded)*3/4) + for i := 0; i < len(encoded); { + remaining := len(encoded) - i + chunkLen := 4 + if remaining < chunkLen { + chunkLen = remaining + } - if path != baseDir && !core.HasPrefix(path, pathPrefix) { - return "", core.E("cache.Path", "invalid cache key: path traversal attempt", nil) + var values [4]byte + for j := 0; j < chunkLen; j++ { + value := rawBase64URLDecodeValue(encoded[i+j]) + if value < 0 { + return nil, core.E("cache.rawBase64URLDecode", "invalid raw URL base64 character", nil) + } + values[j] = byte(value) + } + + out = append(out, values[0]<<2|values[1]>>4) + if chunkLen >= 3 { + out = append(out, values[1]<<4|values[2]>>2) + } + if chunkLen == 4 { + out = append(out, values[2]<<6|values[3]) + } + + i += chunkLen } - return path, nil + return out, nil } -// Get unmarshals the cached item into dest if it exists and has not expired. -// -// found, err := c.Get("github/acme/repos", &repos) -func (c *Cache) Get(key string, dest any) (bool, error) { - if err := c.ensureReady("cache.Get"); err != nil { - return false, err +func rawBase64URLDecodeValue(c byte) int { + switch { + case c >= 'A' && c <= 'Z': + return int(c - 'A') + case c >= 'a' && c <= 'z': + return int(c-'a') + 26 + case c >= '0' && c <= '9': + return int(c-'0') + 52 + case c == '-': + return 62 + case c == '_': + return 63 + default: + return -1 } +} - path, err := c.Path(key) +func decodeRequestKey(encoded string) (CachedRequest, error) { + raw, err := rawBase64URLDecode(encoded) if err != nil { - return false, err + return CachedRequest{}, core.E("cache.decodeRequestKey", "invalid cached request key", err) + } + parts := core.SplitN(string(raw), "\x00", 2) + if len(parts) != 2 { + return CachedRequest{}, core.E("cache.decodeRequestKey", "invalid cached request key payload", nil) } - dataStr, err := c.medium.Read(path) + return CachedRequest{ + Method: parts[0], + URL: parts[1], + }, nil +} + +func (httpCache *HTTPCache) responseMetaPath(key string) string { + return httpCache.storagePath("responses", key+".json") +} + +func (httpCache *HTTPCache) responseBinaryPath(key string) string { + return httpCache.storagePath("responses", key+".bin") +} + +func (httpCache *HTTPCache) readResponseRecord(key string) (*cachedResponseRecord, error) { + raw, err := httpCache.medium.Read(httpCache.responseMetaPath(key)) if err != nil { if core.Is(err, fs.ErrNotExist) { - return false, nil + return nil, nil } - return false, core.E("cache.Get", "failed to read cache file", err) + return nil, core.E("cache.HTTPCache.readResponseRecord", "failed to read cached response", err) } - var entry Entry - entryResult := core.JSONUnmarshalString(dataStr, &entry) - if !entryResult.OK { - return false, nil + var envelope map[string]rawJSON + envelopeResult := core.JSONUnmarshalString(raw, &envelope) + if !envelopeResult.OK { + return nil, core.E("cache.HTTPCache.readResponseRecord", "failed to unmarshal cached response", envelopeResult.Value.(error)) } - if time.Now().After(entry.ExpiresAt) { - return false, nil + _, hasRequest := envelope["request"] + _, hasResponse := envelope["response"] + + if hasRequest || hasResponse { + if !hasRequest || !hasResponse { + return nil, core.E("cache.HTTPCache.readResponseRecord", "cached response envelope is incomplete", nil) + } + + var record cachedResponseRecord + recordResult := core.JSONUnmarshalString(raw, &record) + if !recordResult.OK { + return nil, core.E("cache.HTTPCache.readResponseRecord", "failed to unmarshal cached response", recordResult.Value.(error)) + } + if err := validateCachedResponseRecord(key, &record); err != nil { + return nil, err + } + return &record, nil } - if err := core.JSONUnmarshal(entry.Data, dest); !err.OK { - return false, core.E("cache.Get", "failed to unmarshal cached data", err.Value.(error)) + var record cachedResponseRecord + var response CachedResponse + responseResult := core.JSONUnmarshalString(raw, &response) + if !responseResult.OK { + return nil, core.E("cache.HTTPCache.readResponseRecord", "failed to unmarshal cached response", responseResult.Value.(error)) } - return true, nil + req, err := decodeRequestKey(key) + if err != nil { + return nil, err + } + + record = cachedResponseRecord{ + Request: req, + Response: response, + } + if err := validateCachedResponseRecord(key, &record); err != nil { + return nil, err + } + + return &record, nil } -// Set marshals data and stores it in the cache. +// Match finds a cached response for request. // -// err := c.Set("github/acme/repos", repos) -func (c *Cache) Set(key string, data any) error { - if err := c.ensureReady("cache.Set"); err != nil { - return err +// resp, err := cache.Match(cache.CachedRequest{URL: "https://x", Method: "GET"}) +func (httpCache *HTTPCache) Match(req CachedRequest) (*CachedResponse, error) { + if err := httpCache.ensureReady("cache.HTTPCache.Match"); err != nil { + return nil, err + } + if err := validateCachedRequest(req); err != nil { + return nil, core.E("cache.HTTPCache.Match", "invalid cached request", err) + } + key, err := httpCache.requestKey(req) + if err != nil { + return nil, err } - path, err := c.Path(key) + record, err := httpCache.readResponseRecord(key) + if err != nil { + return nil, err + } + if record == nil { + record, err = httpCache.readResponseRecord(legacyRequestKey(req)) + } + if err != nil || record == nil { + return nil, err + } + return &record.Response, nil +} + +// Put stores a request/response pair and its body. +// +// err := appCache.Put( +// cache.CachedRequest{URL: "https://example.com/style.css", Method: "GET"}, +// cache.CachedResponse{Status: 200, Headers: headers}, +// bodyBytes, +// ) +func (httpCache *HTTPCache) Put(req CachedRequest, resp CachedResponse, body []byte) error { + if err := httpCache.ensureReady("cache.HTTPCache.Put"); err != nil { + return err + } + key, err := httpCache.requestKey(req) if err != nil { return err } + resp.BodyPath = core.JoinPath("responses", key+".bin") + if err := validateCachedRequest(req); err != nil { + return core.E("cache.HTTPCache.Put", "invalid cached request", err) + } + if resp.Headers == nil { + resp.Headers = make(map[string]string) + } + if err := validateCachedResponse(resp); err != nil { + return core.E("cache.HTTPCache.Put", "invalid cached response", err) + } - if err := c.medium.EnsureDir(core.PathDir(path)); err != nil { - return core.E("cache.Set", "failed to create directory", err) + if err := httpCache.medium.EnsureDir(httpCache.storagePath("responses")); err != nil { + return core.E("cache.HTTPCache.Put", "failed to create response directory", err) } - dataResult := core.JSONMarshal(data) - if !dataResult.OK { - return core.E("cache.Set", "failed to marshal cache data", dataResult.Value.(error)) + metaPath := httpCache.responseMetaPath(key) + binaryPath := httpCache.responseBinaryPath(key) + metaSnapshot, err := readFileSnapshot(httpCache.medium, metaPath) + if err != nil { + return core.E("cache.HTTPCache.Put", "failed to inspect existing cached response metadata", err) + } + binarySnapshot, err := readFileSnapshot(httpCache.medium, binaryPath) + if err != nil { + return core.E("cache.HTTPCache.Put", "failed to inspect existing cached response body", err) } - ttl := c.ttl - if ttl < 0 { - return core.E("cache.Set", "cache ttl must be >= 0", nil) + resp.CachedAt = time.Now() + record := cachedResponseRecord{ + Request: req, + Response: resp, } - if ttl == 0 { - ttl = DefaultTTL + meta, err := marshalPrettyJSON(record) + if err != nil { + return core.E("cache.HTTPCache.Put", "failed to marshal cached response", err) } - entry := Entry{ - Data: dataResult.Value.([]byte), - CachedAt: time.Now(), - ExpiresAt: time.Now().Add(ttl), + if err := httpCache.medium.Write(binaryPath, string(body)); err != nil { + _ = restoreFileSnapshot(httpCache.medium, metaSnapshot) + _ = restoreFileSnapshot(httpCache.medium, binarySnapshot) + return core.E("cache.HTTPCache.Put", "failed to write cached response body", err) } + if err := httpCache.medium.Write(metaPath, meta); err != nil { + _ = restoreFileSnapshot(httpCache.medium, binarySnapshot) + _ = restoreFileSnapshot(httpCache.medium, metaSnapshot) + return core.E("cache.HTTPCache.Put", "failed to write cached response metadata", err) + } + + return nil +} - entryBytes, err := json.MarshalIndent(entry, "", " ") +// ReadBody returns the response body bytes from medium. +// +// body, err := appCache.ReadBody(resp) +func (httpCache *HTTPCache) ReadBody(resp *CachedResponse) ([]byte, error) { + if err := httpCache.ensureReady("cache.HTTPCache.ReadBody"); err != nil { + return nil, err + } + if resp == nil { + return nil, core.E("cache.HTTPCache.ReadBody", "response is nil", nil) + } + if resp.BodyPath == "" { + return nil, core.E("cache.HTTPCache.ReadBody", "response has empty body path", nil) + } + if err := ensureSafeResponseBodyPath(resp.BodyPath); err != nil { + return nil, core.E("cache.HTTPCache.ReadBody", "invalid response body path", err) + } + body, err := httpCache.medium.Read(httpCache.storagePath(resp.BodyPath)) if err != nil { - return core.E("cache.Set", "failed to marshal cache entry", err) + return nil, core.E("cache.HTTPCache.ReadBody", "failed to read response body", err) + } + return []byte(body), nil +} + +func validateCachedResponseRecord(key string, record *cachedResponseRecord) error { + if record == nil { + return core.E("cache.HTTPCache.validateCachedResponseRecord", "cached response record is nil", nil) + } + + if err := validateCachedRequest(record.Request); err != nil { + return core.E("cache.HTTPCache.validateCachedResponseRecord", "invalid cached request", err) + } + + expectedKey, err := requestStorageKey(record.Request) + if err != nil { + return err + } + legacyKey := legacyRequestKey(record.Request) + if key != expectedKey && key != legacyKey { + return core.E("cache.HTTPCache.validateCachedResponseRecord", "cached request metadata does not match cache key", nil) + } + + if err := validateCachedResponse(record.Response); err != nil { + return err + } + expectedBodyPaths := []string{ + core.JoinPath("responses", expectedKey+".bin"), + core.JoinPath("responses", legacyKey+".bin"), + } + if !slices.Contains(expectedBodyPaths, record.Response.BodyPath) { + return core.E("cache.HTTPCache.validateCachedResponseRecord", "cached response body path does not match cache key", nil) + } + + return nil +} + +func requestStorageKey(req CachedRequest) (string, error) { + if err := validateCachedRequest(req); err != nil { + return "", core.E("cache.HTTPCache.requestStorageKey", "invalid cached request", err) + } + + return core.SHA256Hex([]byte(req.Method + "\x00" + req.URL)), nil +} + +func validateCachedRequest(req CachedRequest) error { + if core.Trim(req.URL) == "" || core.Trim(req.Method) == "" { + return core.E("cache.HTTPCache.validateCachedRequest", "request URL and method are required", nil) + } + if len(req.URL) > maxCachedRequestURLBytes { + return core.E("cache.HTTPCache.validateCachedRequest", "request URL is too long", nil) + } + if len(req.Method) > maxCachedRequestMethodBytes { + return core.E("cache.HTTPCache.validateCachedRequest", "request method is too long", nil) + } + if hasHTTPDangerousBytes(req.URL) || hasHTTPDangerousBytes(req.Method) { + return core.E("cache.HTTPCache.validateCachedRequest", "request contains control characters", nil) + } + if !isHTTPToken(req.Method) { + return core.E("cache.HTTPCache.validateCachedRequest", "invalid HTTP method", nil) + } + return nil +} + +func validateCachedResponse(resp CachedResponse) error { + if resp.Status < 100 || resp.Status > 599 { + return core.E("cache.HTTPCache.validateCachedResponse", "invalid HTTP status", nil) + } + if hasHTTPDangerousBytes(resp.StatusText) { + return core.E("cache.HTTPCache.validateCachedResponse", "invalid HTTP status text", nil) + } + if len(resp.StatusText) > maxCachedStatusTextBytes { + return core.E("cache.HTTPCache.validateCachedResponse", "HTTP status text is too long", nil) + } + if err := ensureSafeResponseBodyPath(resp.BodyPath); err != nil { + return core.E("cache.HTTPCache.validateCachedResponse", "invalid response body path", err) + } + if len(resp.Headers) > maxCachedHeaderCount { + return core.E("cache.HTTPCache.validateCachedResponse", "too many response headers", nil) } + for name, value := range resp.Headers { + if len(name) > maxCachedHeaderNameBytes { + return core.E("cache.HTTPCache.validateCachedResponse", "response header name is too long", nil) + } + if len(value) > maxCachedHeaderValueBytes { + return core.E("cache.HTTPCache.validateCachedResponse", "response header value is too long", nil) + } + if err := validateHTTPHeaderName(name); err != nil { + return core.E("cache.HTTPCache.validateCachedResponse", "invalid response header name", err) + } + if hasHTTPDangerousBytes(value) { + return core.E("cache.HTTPCache.validateCachedResponse", "invalid response header value", nil) + } + } + return nil +} - if err := c.medium.Write(path, string(entryBytes)); err != nil { - return core.E("cache.Set", "failed to write cache file", err) +func validateHTTPHeaderName(name string) error { + if name == "" { + return core.E("cache.HTTPCache.validateHTTPHeaderName", "header name is empty", nil) + } + if !isHTTPToken(name) { + return core.E("cache.HTTPCache.validateHTTPHeaderName", "invalid header name", nil) } return nil } -// Delete removes the cached item for key. +func hasHTTPDangerousBytes(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] < 0x20 || s[i] == 0x7f { + return true + } + } + return false +} + +func isHTTPToken(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + switch c := s[i]; { + case c >= 'a' && c <= 'z': + case c >= 'A' && c <= 'Z': + case c >= '0' && c <= '9': + case c == '!' || c == '#' || c == '$' || c == '%' || c == '&' || c == '\'' || c == '*' || c == '+' || c == '-' || c == '.' || c == '^' || c == '_' || c == '`' || c == '|' || c == '~': + default: + return false + } + } + return true +} + +// Delete removes a cached request/response pair. // -// err := c.Delete("github/acme/repos") -func (c *Cache) Delete(key string) error { - if err := c.ensureReady("cache.Delete"); err != nil { +// err := appCache.Delete(cache.CachedRequest{URL: "https://example.com/old.js", Method: "GET"}) +func (httpCache *HTTPCache) Delete(req CachedRequest) error { + if err := httpCache.ensureReady("cache.HTTPCache.Delete"); err != nil { return err } + if err := validateCachedRequest(req); err != nil { + return core.E("cache.HTTPCache.Delete", "invalid cached request", err) + } - path, err := c.Path(key) + key, err := httpCache.requestKey(req) if err != nil { return err } - err = c.medium.Delete(path) - if core.Is(err, fs.ErrNotExist) { - return nil + if err := httpCache.medium.Delete(httpCache.responseMetaPath(key)); err != nil && !core.Is(err, fs.ErrNotExist) { + return core.E("cache.HTTPCache.Delete", "failed to delete cached response metadata", err) } - if err != nil { - return core.E("cache.Delete", "failed to delete cache file", err) + if err := httpCache.medium.Delete(httpCache.responseBinaryPath(key)); err != nil && !core.Is(err, fs.ErrNotExist) { + return core.E("cache.HTTPCache.Delete", "failed to delete cached response body", err) + } + legacyKey := legacyRequestKey(req) + if legacyKey != key { + if err := httpCache.medium.Delete(httpCache.responseMetaPath(legacyKey)); err != nil && !core.Is(err, fs.ErrNotExist) { + return core.E("cache.HTTPCache.Delete", "failed to delete legacy cached response metadata", err) + } + if err := httpCache.medium.Delete(httpCache.responseBinaryPath(legacyKey)); err != nil && !core.Is(err, fs.ErrNotExist) { + return core.E("cache.HTTPCache.Delete", "failed to delete legacy cached response body", err) + } } + return nil } -// DeleteMany removes several cached items in one call. +// Keys returns all cached request URLs. // -// err := c.DeleteMany("github/acme/repos", "github/acme/meta") -func (c *Cache) DeleteMany(keys ...string) error { - if err := c.ensureReady("cache.DeleteMany"); err != nil { - return err +// urls, err := appCache.Keys() +// // ["https://example.com/style.css", "https://example.com/app.js"] +func (httpCache *HTTPCache) Keys() ([]string, error) { + if err := httpCache.ensureReady("cache.HTTPCache.Keys"); err != nil { + return nil, err } - for _, key := range keys { - path, err := c.Path(key) - if err != nil { - return err + entries, err := httpCache.medium.List(httpCache.storagePath("responses")) + if err != nil { + if core.Is(err, fs.ErrNotExist) { + return []string{}, nil } + return nil, core.E("cache.HTTPCache.Keys", "failed to list response entries", err) + } - err = c.medium.Delete(path) - if core.Is(err, fs.ErrNotExist) { + seen := make(map[string]struct{}) + var urls []string + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !core.HasSuffix(name, ".json") { continue } + key := core.TrimSuffix(name, ".json") + record, err := httpCache.readResponseRecord(key) if err != nil { - return core.E("cache.DeleteMany", "failed to delete cache file", err) + continue + } + if record == nil || record.Request.URL == "" { + continue + } + if _, ok := seen[record.Request.URL]; ok { + continue } + seen[record.Request.URL] = struct{}{} + urls = append(urls, record.Request.URL) } - return nil + slices.Sort(urls) + return urls, nil +} + +type fileSnapshot struct { + path string + existed bool + content string +} + +func readFileSnapshot(medium coreio.Medium, path string) (fileSnapshot, error) { + content, err := medium.Read(path) + if err != nil { + if core.Is(err, fs.ErrNotExist) { + return fileSnapshot{path: path}, nil + } + return fileSnapshot{}, err + } + return fileSnapshot{ + path: path, + existed: true, + content: content, + }, nil +} + +func restoreFileSnapshot(medium coreio.Medium, snapshot fileSnapshot) error { + if snapshot.path == "" { + return nil + } + if !snapshot.existed { + if err := medium.Delete(snapshot.path); err != nil && !core.Is(err, fs.ErrNotExist) { + return err + } + return nil + } + return medium.Write(snapshot.path, snapshot.content) } // Clear removes all cached items under the cache base directory. @@ -273,14 +2009,18 @@ func (c *Cache) Age(key string) time.Duration { // // key := cache.GitHubReposKey("acme") func GitHubReposKey(org string) string { - return core.JoinPath("github", org, "repos") + return core.JoinPath("github", encodePathSegment(org), "repos") } // GitHubRepoKey returns the cache key used for a repository metadata entry. // // key := cache.GitHubRepoKey("acme", "widgets") func GitHubRepoKey(org, repo string) string { - return core.JoinPath("github", org, repo, "meta") + return core.JoinPath("github", encodePathSegment(org), encodePathSegment(repo), "meta") +} + +func encodePathSegment(segment string) string { + return core.URLPathEscape(segment) } func pathSeparator() string { @@ -317,6 +2057,10 @@ func absolutePath(path string) string { } func currentDir() string { + if cwd, err := os.Getwd(); err == nil && cwd != "" { + return normalizePath(cwd) + } + cwd := normalizePath(core.Env("PWD")) if cwd != "" && cwd != "." { return cwd @@ -332,6 +2076,9 @@ func (c *Cache) ensureConfigured(op string) error { if c.baseDir == "" { return core.E(op, "cache base directory is empty; construct with cache.New", nil) } + if c.runtime == nil { + return core.E(op, "cache runtime is nil; construct with cache.New", nil) + } return nil } diff --git a/cache_test.go b/cache_test.go index f6b1922..e019f73 100644 --- a/cache_test.go +++ b/cache_test.go @@ -3,15 +3,100 @@ package cache_test import ( - "strings" + // Note: AX-6 — test-only, replicates internal key derivation for black-box assertion. Retain. + "crypto/sha256" + // Note: AX-6 — test-only, replicates internal key derivation for black-box assertion. Retain. + "encoding/base64" + // Note: AX-6 — test-only, replicates internal key derivation for black-box assertion. Retain. + "encoding/hex" + // Note: AX-6 — test-only, replicates internal key derivation for black-box assertion. Retain. + "encoding/json" + // Note: AX-6 — test-only fs interfaces returned by scriptedMedium and fs.ErrNotExist assertions. + "io/fs" + // Note: AX-6 — test-only symlink setup; no core equivalent for os.Symlink. + "os" + "runtime" + "sync" + "sync/atomic" "testing" "time" + "dappco.re/go/cache" "dappco.re/go/core" - "dappco.re/go/core/cache" - coreio "dappco.re/go/core/io" + coreio "dappco.re/go/io" ) +type scriptedMedium struct { + *coreio.MockMedium + readErr map[string]error + writeErr map[string]error + ensureDirErr map[string]error + deleteErr map[string]error + deleteAllErr map[string]error + listErr map[string]error +} + +func newScriptedMedium() *scriptedMedium { + return &scriptedMedium{ + MockMedium: coreio.NewMockMedium(), + readErr: make(map[string]error), + writeErr: make(map[string]error), + ensureDirErr: make(map[string]error), + deleteErr: make(map[string]error), + deleteAllErr: make(map[string]error), + listErr: make(map[string]error), + } +} + +func (m *scriptedMedium) Read(path string) (string, error) { + if err, ok := m.readErr[path]; ok { + return "", err + } + return m.MockMedium.Read(path) +} + +func (m *scriptedMedium) Write(path, content string) error { + if err, ok := m.writeErr[path]; ok { + return err + } + return m.MockMedium.Write(path, content) +} + +func (m *scriptedMedium) WriteMode(path, content string, mode fs.FileMode) error { + if err, ok := m.writeErr[path]; ok { + return err + } + return m.MockMedium.WriteMode(path, content, mode) +} + +func (m *scriptedMedium) EnsureDir(path string) error { + if err, ok := m.ensureDirErr[path]; ok { + return err + } + return m.MockMedium.EnsureDir(path) +} + +func (m *scriptedMedium) Delete(path string) error { + if err, ok := m.deleteErr[path]; ok { + return err + } + return m.MockMedium.Delete(path) +} + +func (m *scriptedMedium) DeleteAll(path string) error { + if err, ok := m.deleteAllErr[path]; ok { + return err + } + return m.MockMedium.DeleteAll(path) +} + +func (m *scriptedMedium) List(path string) ([]fs.DirEntry, error) { + if err, ok := m.listErr[path]; ok { + return nil, err + } + return m.MockMedium.List(path) +} + func newTestCache(t *testing.T, baseDir string, ttl time.Duration) (*cache.Cache, *coreio.MockMedium) { t.Helper() @@ -36,9 +121,39 @@ func readEntry(t *testing.T, raw string) cache.Entry { return entry } +func httpCacheStorageKey(req cache.CachedRequest) string { + sum := sha256.Sum256([]byte(req.Method + "\x00" + req.URL)) + return hex.EncodeToString(sum[:]) +} + +func legacyHTTPCacheStorageKey(req cache.CachedRequest) string { + return base64.RawURLEncoding.EncodeToString([]byte(req.Method + "\x00" + req.URL)) +} + +func repeatString(s string, count int) string { + builder := core.NewBuilder() + for range count { + builder.WriteString(s) + } + return builder.String() +} + +func stableTempDir(t *testing.T) string { + t.Helper() + + tmpRoot := core.JoinPath(core.Env("DIR_CWD"), ".core", "test-tmp") + if err := coreio.Local.EnsureDir(tmpRoot); err != nil { + t.Fatalf("EnsureDir temp root failed: %v", err) + } + t.Setenv("TMPDIR", tmpRoot) + return t.TempDir() +} + func TestCache_New_Good(t *testing.T) { - tmpDir := t.TempDir() + tmpDir := stableTempDir(t) t.Chdir(tmpDir) + t.Setenv("PWD", "") + t.Setenv("DIR_CWD", "") c, m := newTestCache(t, "", 0) @@ -61,7 +176,7 @@ func TestCache_New_Good(t *testing.T) { if err != nil { t.Fatalf("Read failed: %v", err) } - if !strings.Contains(raw, "\n \"data\":") { + if !core.Contains(raw, "\n \"data\":") { t.Fatalf("expected pretty-printed cache entry, got %q", raw) } @@ -79,6 +194,69 @@ func TestCache_New_Bad(t *testing.T) { } } +func TestCache_New_Bad_EnsureDirFailure(t *testing.T) { + medium := newScriptedMedium() + medium.ensureDirErr["/tmp/cache-new-backend-bad"] = core.E("cache_test", "boom", nil) + + if _, err := cache.New(medium, "/tmp/cache-new-backend-bad", time.Minute); err == nil { + t.Fatal("expected New to surface backend failure") + } +} + +func TestCache_NewCacheStorage_Good(t *testing.T) { + tmpDir := stableTempDir(t) + t.Chdir(tmpDir) + t.Setenv("PWD", "") + t.Setenv("DIR_CWD", "") + + storage, err := cache.NewCacheStorage(nil, "") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("assets-v1") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + if httpCache == nil { + t.Fatal("expected Open to return a cache") + } + + wantDir := core.JoinPath(tmpDir, ".core", "cache-storage", "assets-v1") + info, err := coreio.Local.Stat(wantDir) + if err != nil { + t.Fatalf("expected default cache storage directory to exist: %v", err) + } + if !info.IsDir() { + t.Fatalf("expected %q to be a directory", wantDir) + } +} + +func TestCache_NewCacheStorage_Bad(t *testing.T) { + medium := newScriptedMedium() + medium.ensureDirErr["/tmp/cache-storage-bad"] = core.E("cache_test", "boom", nil) + + if _, err := cache.NewCacheStorage(medium, "/tmp/cache-storage-bad"); err == nil { + t.Fatal("expected NewCacheStorage to surface backend failure") + } +} + +func TestCache_SetWithTTL_Bad(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-setwithttl-bad", time.Minute) + + if err := c.SetWithTTL("session/bad", map[string]any{"handler": func() {}}, -time.Second); err == nil { + t.Fatal("expected SetWithTTL to reject negative ttl") + } +} + +func TestCache_SetWithTTL_Ugly(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-setwithttl-ugly", time.Minute) + + if err := c.SetWithTTL("session/ugly", map[string]any{"handler": func() {}}, time.Second); err == nil { + t.Fatal("expected SetWithTTL to reject unsupported JSON payload") + } +} + func TestCache_Path_Good(t *testing.T) { c, _ := newTestCache(t, "/tmp/cache-path", time.Minute) @@ -96,9 +274,58 @@ func TestCache_Path_Good(t *testing.T) { func TestCache_Path_Bad(t *testing.T) { c, _ := newTestCache(t, "/tmp/cache-traversal", time.Minute) - _, err := c.Path("../../etc/passwd") - if err == nil { - t.Fatal("expected error for path traversal key, got nil") + tests := []struct { + name string + key string + }{ + {name: "empty", key: ""}, + {name: "traversal", key: "../../etc/passwd"}, + {name: "dot", key: "."}, + {name: "backslash", key: `foo\bar`}, + {name: "null-byte", key: "foo\x00bar"}, + {name: "too-long", key: repeatString("a", 4097)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := c.Path(tt.key); err == nil { + t.Fatalf("expected Path to reject %q", tt.key) + } + }) + } +} + +func TestCache_Path_PathTraversalSymlink_Bad(t *testing.T) { + tmpDir := t.TempDir() + baseDir := core.JoinPath(tmpDir, "cache") + outsideDir := core.JoinPath(tmpDir, "outside") + linkPath := core.JoinPath(baseDir, "link") + + if err := coreio.Local.EnsureDir(baseDir); err != nil { + t.Fatalf("EnsureDir base failed: %v", err) + } + if err := coreio.Local.EnsureDir(outsideDir); err != nil { + t.Fatalf("EnsureDir outside failed: %v", err) + } + if err := os.Symlink(outsideDir, linkPath); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + c, err := cache.New(coreio.Local, baseDir, time.Minute) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + if _, err := c.Path("link/escaped"); err == nil { + t.Fatal("expected Path to reject symlink traversal under baseDir") + } + if err := c.Set("link/escaped", "owned"); err == nil { + t.Fatal("expected Set to reject symlink traversal under baseDir") + } + if _, err := coreio.Local.Stat(core.JoinPath(outsideDir, "escaped.json")); err == nil { + t.Fatal("expected escaped file not to be written outside baseDir") + } else if !core.Is(err, fs.ErrNotExist) { + t.Fatalf("Stat outside file failed: %v", err) } } @@ -144,6 +371,54 @@ func TestCache_Get_Ugly(t *testing.T) { } } +func TestCache_Get_Bad(t *testing.T) { + c, m := newTestCache(t, "/tmp/cache-get-bad", time.Minute) + + path, err := c.Path("corrupt") + if err != nil { + t.Fatalf("Path failed: %v", err) + } + m.Files[path] = "{not-json" + + var dest map[string]string + found, err := c.Get("corrupt", &dest) + if err == nil { + t.Fatal("expected Get to reject malformed entry JSON") + } + if found { + t.Fatal("expected malformed entry to be reported as missing") + } +} + +func TestCache_Get_Ugly_MalformedCachedPayload(t *testing.T) { + c, m := newTestCache(t, "/tmp/cache-get-ugly", time.Minute) + + path, err := c.Path("bad-data") + if err != nil { + t.Fatalf("Path failed: %v", err) + } + + entry := cache.Entry{ + Data: []byte("123"), + CachedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Minute), + } + raw, err := json.Marshal(entry) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + m.Files[path] = string(raw) + + var dest map[string]string + found, err := c.Get("bad-data", &dest) + if err == nil { + t.Fatal("expected Get to reject malformed cached payload") + } + if found { + t.Fatal("expected malformed payload to be reported as missing") + } +} + func TestCache_Age_Good(t *testing.T) { c, _ := newTestCache(t, "/tmp/cache-age", time.Minute) @@ -156,6 +431,24 @@ func TestCache_Age_Good(t *testing.T) { } } +func TestCache_Age_Bad(t *testing.T) { + c, m := newTestCache(t, "/tmp/cache-age-bad", time.Minute) + + if age := c.Age("missing"); age != -1 { + t.Fatalf("expected Age to return -1 for missing entry, got %v", age) + } + + path, err := c.Path("invalid") + if err != nil { + t.Fatalf("Path failed: %v", err) + } + m.Files[path] = "{not-json" + + if age := c.Age("invalid"); age != -1 { + t.Fatalf("expected Age to return -1 for malformed entry, got %v", age) + } +} + func TestCache_NilReceiver_Good(t *testing.T) { var c *cache.Cache var target map[string]string @@ -171,6 +464,15 @@ func TestCache_NilReceiver_Good(t *testing.T) { if err := c.Set("x", map[string]string{"foo": "bar"}); err == nil { t.Fatal("expected Set to fail on nil receiver") } + if err := c.SetWithTTL("x", map[string]string{"foo": "bar"}, time.Second); err == nil { + t.Fatal("expected SetWithTTL to fail on nil receiver") + } + if err := c.SetBinary("x", []byte("body"), "text/plain"); err == nil { + t.Fatal("expected SetBinary to fail on nil receiver") + } + if err := c.SetBinaryWithTTL("x", []byte("body"), "text/plain", time.Second); err == nil { + t.Fatal("expected SetBinaryWithTTL to fail on nil receiver") + } if err := c.Delete("x"); err == nil { t.Fatal("expected Delete to fail on nil receiver") @@ -200,6 +502,15 @@ func TestCache_ZeroValue_Ugly(t *testing.T) { if err := c.Set("x", map[string]string{"foo": "bar"}); err == nil { t.Fatal("expected Set to fail on zero-value cache") } + if err := c.SetWithTTL("x", map[string]string{"foo": "bar"}, time.Second); err == nil { + t.Fatal("expected SetWithTTL to fail on zero-value cache") + } + if err := c.SetBinary("x", []byte("body"), "text/plain"); err == nil { + t.Fatal("expected SetBinary to fail on zero-value cache") + } + if err := c.SetBinaryWithTTL("x", []byte("body"), "text/plain", time.Second); err == nil { + t.Fatal("expected SetBinaryWithTTL to fail on zero-value cache") + } if err := c.Delete("x"); err == nil { t.Fatal("expected Delete to fail on zero-value cache") @@ -235,6 +546,41 @@ func TestCache_Delete_Good(t *testing.T) { } } +func TestCache_Delete_Bad(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-delete-bad", time.Minute) + + if err := c.Delete("../../etc/passwd"); err == nil { + t.Fatal("expected Delete to reject traversal key") + } +} + +func TestCache_Delete_Bad_BackendFailure(t *testing.T) { + medium := newScriptedMedium() + c, err := cache.New(medium, "/tmp/cache-delete-backend-bad", time.Minute) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + key := "delete/backend" + path, err := c.Path(key) + if err != nil { + t.Fatalf("Path failed: %v", err) + } + medium.deleteErr[path] = core.E("cache_test", "boom", nil) + + if err := c.Delete(key); err == nil { + t.Fatal("expected Delete to surface backend failure") + } +} + +func TestCache_Delete_Ugly(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-delete-ugly", time.Minute) + + if err := c.Delete("missing"); err != nil { + t.Fatalf("Delete on missing key should be a no-op: %v", err) + } +} + func TestCache_DeleteMany_Good(t *testing.T) { c, _ := newTestCache(t, "/tmp/cache-delete-many", time.Minute) data := map[string]string{"foo": "bar"} @@ -267,6 +613,38 @@ func TestCache_DeleteMany_Good(t *testing.T) { } } +func TestCache_DeleteMany_RejectsTraversalBeforeDeletingAnything(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-delete-many-traversal", time.Minute) + + if err := c.Set("key1", map[string]string{"foo": "bar"}); err != nil { + t.Fatalf("Set failed for key1: %v", err) + } + if err := c.Set("key2", map[string]string{"foo": "bar"}); err != nil { + t.Fatalf("Set failed for key2: %v", err) + } + + if err := c.DeleteMany("key1", "../../etc/passwd", "key2"); err == nil { + t.Fatal("expected DeleteMany to reject traversal key") + } + + var retrieved map[string]string + found, err := c.Get("key1", &retrieved) + if err != nil { + t.Fatalf("Get after rejected DeleteMany returned an unexpected error: %v", err) + } + if !found { + t.Fatal("expected key1 to remain after rejected DeleteMany") + } + + found, err = c.Get("key2", &retrieved) + if err != nil { + t.Fatalf("Get after rejected DeleteMany returned an unexpected error: %v", err) + } + if !found { + t.Fatal("expected key2 to remain after rejected DeleteMany") + } +} + func TestCache_Clear_Good(t *testing.T) { c, _ := newTestCache(t, "/tmp/cache-clear", time.Minute) data := map[string]string{"foo": "bar"} @@ -291,6 +669,34 @@ func TestCache_Clear_Good(t *testing.T) { } } +func TestCache_Clear_Bad(t *testing.T) { + medium := newScriptedMedium() + c, err := cache.New(medium, "/tmp/cache-clear-bad", time.Minute) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + medium.deleteAllErr["/tmp/cache-clear-bad"] = core.E("cache_test", "boom", nil) + + if err := c.Clear(); err == nil { + t.Fatal("expected Clear to surface backend failure") + } +} + +func TestCache_ClearScope_Bad_ListFailure(t *testing.T) { + medium := newScriptedMedium() + c, err := cache.New(medium, "/tmp/cache-clear-scope-bad", time.Minute) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + medium.listErr["/tmp/cache-clear-scope-bad"] = core.E("cache_test", "boom", nil) + + if err := c.ClearScope("https://app.example.com"); err == nil { + t.Fatal("expected ClearScope to surface backend list failure") + } +} + func TestCache_GitHubReposKey_Good(t *testing.T) { key := cache.GitHubReposKey("myorg") if key != "github/myorg/repos" { @@ -298,9 +704,2342 @@ func TestCache_GitHubReposKey_Good(t *testing.T) { } } +func TestCache_GitHubReposKey_EscapesUnsafeSegments(t *testing.T) { + key := cache.GitHubReposKey("my/org") + if key != "github/my%2Forg/repos" { + t.Fatalf("unexpected escaped GitHubReposKey: %q", key) + } +} + func TestCache_GitHubRepoKey_Good(t *testing.T) { key := cache.GitHubRepoKey("myorg", "myrepo") if key != "github/myorg/myrepo/meta" { t.Errorf("unexpected GitHubRepoKey: %q", key) } } + +func TestCache_GitHubRepoKey_EscapesUnsafeSegments(t *testing.T) { + key := cache.GitHubRepoKey("my/org", "widgets/v2") + if key != "github/my%2Forg/widgets%2Fv2/meta" { + t.Fatalf("unexpected escaped GitHubRepoKey: %q", key) + } +} + +func TestCache_SetWithTTL_Good(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-set-with-ttl", 10*time.Minute) + + key := "session/short" + err := c.SetWithTTL(key, map[string]string{"token": "abc"}, 20*time.Millisecond) + if err != nil { + t.Fatalf("SetWithTTL failed: %v", err) + } + + var dest map[string]string + found, err := c.Get(key, &dest) + if err != nil { + t.Fatalf("Get before expiry failed: %v", err) + } + if !found { + t.Fatalf("expected key before expiry") + } + if dest["token"] != "abc" { + t.Fatalf("expected token=abc, got %q", dest["token"]) + } + + time.Sleep(35 * time.Millisecond) + found, err = c.Get(key, &dest) + if err != nil { + t.Fatalf("Get after expiry failed: %v", err) + } + if found { + t.Fatalf("expected key to expire") + } +} + +func TestCache_SetWithTTL_ZeroExpiresImmediately(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-set-with-ttl-zero", 10*time.Minute) + + key := "session/instant" + if err := c.SetWithTTL(key, map[string]string{"token": "abc"}, 0); err != nil { + t.Fatalf("SetWithTTL failed: %v", err) + } + + var dest map[string]string + found, err := c.Get(key, &dest) + if err != nil { + t.Fatalf("Get after zero ttl failed: %v", err) + } + if found { + t.Fatalf("expected zero ttl entry to expire immediately") + } +} + +func TestCache_Set_Ugly(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-set-ugly", time.Minute) + + if err := c.Set("bad", func() {}); err == nil { + t.Fatal("expected Set to reject unsupported JSON payload") + } +} + +func TestCache_Binary_Good(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-binary", 10*time.Minute) + + blob := []byte{0x00, 0x01, 0x02, 0x03} + err := c.SetBinary("wasm/my-module", blob, "application/wasm") + if err != nil { + t.Fatalf("SetBinary failed: %v", err) + } + + data, found, err := c.GetBinary("wasm/my-module") + if err != nil { + t.Fatalf("GetBinary failed: %v", err) + } + if !found { + t.Fatalf("expected binary data") + } + if string(data) != string(blob) { + t.Fatalf("unexpected binary payload: %q", data) + } +} + +func TestCache_SetBinary_Bad(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-binary-bad", 10*time.Minute) + + if err := c.SetBinaryWithTTL("../../etc/passwd", []byte("blob"), "text/plain", time.Second); err == nil { + t.Fatal("expected SetBinaryWithTTL to reject traversal key") + } +} + +func TestCache_SetBinaryWithTTL_Bad(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-binary-negative-ttl", 10*time.Minute) + + if err := c.SetBinaryWithTTL("wasm/negative-ttl", []byte("blob"), "application/wasm", -time.Second); err == nil { + t.Fatal("expected SetBinaryWithTTL to reject negative ttl") + } +} + +func TestCache_SetBinaryWithTTL_Good(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-binary-with-ttl", 10*time.Minute) + + key := "wasm/ttl" + blob := []byte("temporary-binary") + if err := c.SetBinaryWithTTL(key, blob, "application/octet-stream", 20*time.Millisecond); err != nil { + t.Fatalf("SetBinaryWithTTL failed: %v", err) + } + + data, found, err := c.GetBinary(key) + if err != nil { + t.Fatalf("GetBinary before expiry failed: %v", err) + } + if !found { + t.Fatal("expected binary entry before expiry") + } + if string(data) != string(blob) { + t.Fatalf("unexpected payload: %q", data) + } + + time.Sleep(35 * time.Millisecond) + _, found, err = c.GetBinary(key) + if err != nil { + t.Fatalf("GetBinary after expiry failed: %v", err) + } + if found { + t.Fatal("expected binary entry to expire") + } +} + +func TestCache_SetBinary_Ugly(t *testing.T) { + medium := newScriptedMedium() + c, err := cache.New(medium, "/tmp/cache-binary-ugly", time.Minute) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + key := "wasm/ugly" + jsonPath, err := c.Path(key) + if err != nil { + t.Fatalf("Path failed: %v", err) + } + binPath := core.TrimSuffix(jsonPath, ".json") + ".bin" + medium.writeErr[jsonPath] = core.E("cache_test", "metadata boom", nil) + + if err := c.SetBinary(key, []byte("body"), "application/wasm"); err == nil { + t.Fatal("expected SetBinary to surface metadata write failure") + } + if _, ok := medium.Files[binPath]; ok { + t.Fatal("expected binary payload to be cleaned up after metadata write failure") + } +} + +func TestCache_SetBinary_Ugly_BinaryWriteFailure(t *testing.T) { + medium := newScriptedMedium() + c, err := cache.New(medium, "/tmp/cache-binary-write-failure", time.Minute) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + key := "wasm/write-failure" + jsonPath, err := c.Path(key) + if err != nil { + t.Fatalf("Path failed: %v", err) + } + binPath := core.TrimSuffix(jsonPath, ".json") + ".bin" + medium.writeErr[binPath] = core.E("cache_test", "payload boom", nil) + + if err := c.SetBinary(key, []byte("body"), "application/wasm"); err == nil { + t.Fatal("expected SetBinary to surface binary write failure") + } + if _, ok := medium.Files[jsonPath]; ok { + t.Fatal("expected metadata to be rolled back after binary write failure") + } + if _, ok := medium.Files[binPath]; ok { + t.Fatal("expected binary payload write to fail without leaving a file behind") + } +} + +func TestCache_Binary_RoundTripArbitraryBytes(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-binary-arbitrary", 10*time.Minute) + + blob := []byte{0x00, 0x7f, 0x80, 0xff, 0x1b} + if err := c.SetBinary("wasm/opaque", blob, "application/octet-stream"); err != nil { + t.Fatalf("SetBinary failed: %v", err) + } + + data, found, err := c.GetBinary("wasm/opaque") + if err != nil { + t.Fatalf("GetBinary failed: %v", err) + } + if !found { + t.Fatalf("expected binary data") + } + if len(data) != len(blob) { + t.Fatalf("unexpected payload length: got %d want %d", len(data), len(blob)) + } + for i := range blob { + if data[i] != blob[i] { + t.Fatalf("unexpected byte at %d: got 0x%x want 0x%x", i, data[i], blob[i]) + } + } +} + +func TestCache_Binary_WithTTL_Expires(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-binary-expiry", 10*time.Minute) + + blob := []byte("temporary") + if err := c.SetBinaryWithTTL("temp/nonce", blob, "text/plain", 10*time.Millisecond); err != nil { + t.Fatalf("SetBinaryWithTTL failed: %v", err) + } + + time.Sleep(25 * time.Millisecond) + _, found, err := c.GetBinary("temp/nonce") + if err != nil { + t.Fatalf("GetBinary failed: %v", err) + } + if found { + t.Fatalf("expected binary item to expire") + } +} + +func TestCache_Binary_WithTTL_ZeroExpiresImmediately(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-binary-zero-expiry", 10*time.Minute) + + blob := []byte("instant") + if err := c.SetBinaryWithTTL("temp/instant", blob, "text/plain", 0); err != nil { + t.Fatalf("SetBinaryWithTTL failed: %v", err) + } + + _, found, err := c.GetBinary("temp/instant") + if err != nil { + t.Fatalf("GetBinary after zero ttl failed: %v", err) + } + if found { + t.Fatalf("expected zero ttl binary entry to expire immediately") + } +} + +func TestCache_GetBinary_Bad(t *testing.T) { + c, m := newTestCache(t, "/tmp/cache-get-binary-bad", time.Minute) + + if _, found, err := c.GetBinary("missing"); err != nil || found { + t.Fatalf("expected missing binary entry to be a clean miss, found=%v err=%v", found, err) + } + + key := "bad/meta" + metaPath, err := c.Path(key) + if err != nil { + t.Fatalf("Path failed: %v", err) + } + m.Files[metaPath] = "{not-json" + + if _, found, err := c.GetBinary(key); err == nil || found { + t.Fatalf("expected malformed binary metadata to fail, found=%v err=%v", found, err) + } +} + +func TestCache_GetBinary_Bad_MissingPayload(t *testing.T) { + c, m := newTestCache(t, "/tmp/cache-get-binary-missing-payload", time.Minute) + + key := "blob/missing" + if err := c.SetBinary(key, []byte("payload"), "application/octet-stream"); err != nil { + t.Fatalf("SetBinary failed: %v", err) + } + + jsonPath, err := c.Path(key) + if err != nil { + t.Fatalf("Path failed: %v", err) + } + binPath := core.TrimSuffix(jsonPath, ".json") + ".bin" + delete(m.Files, binPath) + + if data, found, err := c.GetBinary(key); err != nil || found || data != nil { + t.Fatalf("expected missing payload to be a clean miss, data=%v found=%v err=%v", data, found, err) + } +} + +func TestCache_PublicMethods_RejectTraversalKeys(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-traversal-coverage", time.Minute) + + if err := c.SetWithTTL("../../etc/passwd", "value", time.Second); err == nil { + t.Fatal("expected SetWithTTL to reject traversal key") + } + + if err := c.SetBinary("../../etc/passwd", []byte("blob"), "text/plain"); err == nil { + t.Fatal("expected SetBinary to reject traversal key") + } + + if _, found, err := c.GetBinary("../../etc/passwd"); err == nil || found { + t.Fatalf("expected GetBinary to reject traversal key, found=%v err=%v", found, err) + } +} + +func TestCache_Scoped_Good(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-scoped", time.Minute) + + app := c.Scoped("https://app.example.com") + admin := c.Scoped("https://admin.example.com") + + if err := app.Set("user/profile", "app-user"); err != nil { + t.Fatalf("app Set failed: %v", err) + } + if err := admin.Set("user/profile", "admin-user"); err != nil { + t.Fatalf("admin Set failed: %v", err) + } + + var appVal string + var adminVal string + + found, err := app.Get("user/profile", &appVal) + if err != nil || !found || appVal != "app-user" { + t.Fatalf("unexpected app scoped value: found=%v val=%q err=%v", found, appVal, err) + } + + found, err = admin.Get("user/profile", &adminVal) + if err != nil || !found || adminVal != "admin-user" { + t.Fatalf("unexpected admin scoped value: found=%v val=%q err=%v", found, adminVal, err) + } + + if err := c.ClearScope("https://app.example.com"); err != nil { + t.Fatalf("ClearScope failed: %v", err) + } + + found, err = app.Get("user/profile", &appVal) + if err != nil || found { + t.Fatalf("expected app scope to be cleared, found=%v err=%v", found, err) + } + found, err = admin.Get("user/profile", &adminVal) + if err != nil || !found { + t.Fatalf("expected admin scope to remain, found=%v err=%v", found, err) + } +} + +func TestCache_Scoped_ClearScope_Good(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-scoped-clear-scope", time.Minute) + + app := c.Scoped("https://app.example.com") + admin := c.Scoped("https://admin.example.com") + + if err := app.Set("user/profile", "app-user"); err != nil { + t.Fatalf("app Set failed: %v", err) + } + if err := admin.Set("user/profile", "admin-user"); err != nil { + t.Fatalf("admin Set failed: %v", err) + } + + if err := app.ClearScope("https://app.example.com"); err != nil { + t.Fatalf("scoped ClearScope failed: %v", err) + } + + var appVal string + var adminVal string + + found, err := app.Get("user/profile", &appVal) + if err != nil || found { + t.Fatalf("expected app scope to be cleared, found=%v err=%v", found, err) + } + + found, err = admin.Get("user/profile", &adminVal) + if err != nil || !found || adminVal != "admin-user" { + t.Fatalf("expected admin scope to remain, found=%v val=%q err=%v", found, adminVal, err) + } +} + +func TestCache_Scoped_OnInvalidate_ScopesReturnedPatterns(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-scoped-invalidate", time.Minute) + + app := c.Scoped("https://app.example.com") + admin := c.Scoped("https://admin.example.com") + + if err := app.Set("config/theme", "app-dark"); err != nil { + t.Fatalf("app Set failed: %v", err) + } + if err := admin.Set("config/theme", "admin-dark"); err != nil { + t.Fatalf("admin Set failed: %v", err) + } + + app.OnInvalidate("config.changed", func(trigger string) []string { + return []string{"config/*"} + }) + + deleted, err := app.Invalidate("config.changed") + if err != nil { + t.Fatalf("Invalidate failed: %v", err) + } + if deleted != 1 { + t.Fatalf("expected one scoped entry to be deleted, got %d", deleted) + } + + var appVal string + var adminVal string + + found, err := app.Get("config/theme", &appVal) + if err != nil { + t.Fatalf("app Get failed: %v", err) + } + if found { + t.Fatalf("expected app scoped config to be deleted") + } + + found, err = admin.Get("config/theme", &adminVal) + if err != nil { + t.Fatalf("admin Get failed: %v", err) + } + if !found || adminVal != "admin-dark" { + t.Fatalf("expected admin scoped config to remain, found=%v val=%q", found, adminVal) + } +} + +func TestCache_Invalidate_Good(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-invalidate", time.Minute) + + if err := c.Set("dns/example.com/A", map[string]string{"a": "1"}); err != nil { + t.Fatalf("Set dns entry failed: %v", err) + } + if err := c.Set("dns/example.com/sub/path", map[string]string{"a": "2"}); err != nil { + t.Fatalf("Set nested dns entry failed: %v", err) + } + if err := c.Set("config/theme", "dark"); err != nil { + t.Fatalf("Set config entry failed: %v", err) + } + + c.OnInvalidate("dns.tree-root-changed", func(trigger string) []string { + return []string{"dns/*"} + }) + deleted, err := c.Invalidate("dns.tree-root-changed") + if err != nil { + t.Fatalf("Invalidate failed: %v", err) + } + if deleted == 0 { + t.Fatal("expected at least one deleted entry") + } + + var dnsValue map[string]string + found, err := c.Get("dns/example.com/A", &dnsValue) + if err != nil { + t.Fatalf("Get after invalidation failed: %v", err) + } + if found { + t.Fatal("expected dns entry to be deleted") + } + found, err = c.Get("dns/example.com/sub/path", &dnsValue) + if err != nil { + t.Fatalf("Get nested dns entry after invalidation failed: %v", err) + } + if found { + t.Fatal("expected nested dns entry to be deleted") + } + var theme string + found, err = c.Get("config/theme", &theme) + if err != nil || !found { + t.Fatalf("expected config entry to remain, found=%v err=%v", found, err) + } +} + +func TestCache_Invalidate_UntrustedPatternLength_Bad(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-invalidate-pattern-length", time.Minute) + + if err := c.Set("dns/example.com/A", "record"); err != nil { + t.Fatalf("Set failed: %v", err) + } + + c.OnInvalidate("dns.changed", func(trigger string) []string { + return []string{repeatString("a", 4097)} + }) + + deleted, err := c.Invalidate("dns.changed") + if err == nil { + t.Fatal("expected Invalidate to reject an oversized pattern") + } + if deleted != 0 { + t.Fatalf("expected no deletions after rejecting oversized pattern, got %d", deleted) + } + + var record string + found, err := c.Get("dns/example.com/A", &record) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if !found || record != "record" { + t.Fatalf("expected entry to remain, found=%v record=%q", found, record) + } +} + +func TestCache_Invalidate_PrefixWildcardDoesNotMatchBarePrefix(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-invalidate-prefix", time.Minute) + + if err := c.Set("dns", "root"); err != nil { + t.Fatalf("Set bare prefix failed: %v", err) + } + if err := c.Set("dns/example.com/A", "record"); err != nil { + t.Fatalf("Set nested dns entry failed: %v", err) + } + + c.OnInvalidate("dns.tree-root-changed", func(trigger string) []string { + return []string{"dns/*"} + }) + deleted, err := c.Invalidate("dns.tree-root-changed") + if err != nil { + t.Fatalf("Invalidate failed: %v", err) + } + if deleted != 1 { + t.Fatalf("expected one descendant to be deleted, got %d", deleted) + } + + var root string + found, err := c.Get("dns", &root) + if err != nil { + t.Fatalf("Get bare prefix failed: %v", err) + } + if !found || root != "root" { + t.Fatalf("expected bare prefix entry to remain, found=%v val=%q", found, root) + } + + var record string + found, err = c.Get("dns/example.com/A", &record) + if err != nil { + t.Fatalf("Get nested entry failed: %v", err) + } + if found { + t.Fatal("expected nested dns entry to be deleted") + } +} + +func TestCache_Invalidate_SingleSegmentWildcard_Good(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-invalidate-segment", time.Minute) + + if err := c.Set("dns/charon.lthn", "one"); err != nil { + t.Fatalf("Set failed: %v", err) + } + if err := c.Set("dns/charon.local", "two"); err != nil { + t.Fatalf("Set failed: %v", err) + } + if err := c.Set("dns/other.local", "three"); err != nil { + t.Fatalf("Set failed: %v", err) + } + + c.OnInvalidate("dns.changed", func(trigger string) []string { + return []string{"dns/charon.*"} + }) + + deleted, err := c.Invalidate("dns.changed") + if err != nil { + t.Fatalf("Invalidate failed: %v", err) + } + if deleted != 2 { + t.Fatalf("expected two wildcard matches to be deleted, got %d", deleted) + } + + var value string + found, err := c.Get("dns/charon.lthn", &value) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if found { + t.Fatal("expected charon.lthn to be deleted") + } + found, err = c.Get("dns/charon.local", &value) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if found { + t.Fatal("expected charon.local to be deleted") + } + found, err = c.Get("dns/other.local", &value) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if !found { + t.Fatal("expected unrelated entry to remain") + } +} + +func TestCache_OnInvalidate_NilCallbackIsIgnored(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-invalidate-nil", time.Minute) + + if err := c.Set("dns/example.com/A", "record"); err != nil { + t.Fatalf("Set failed: %v", err) + } + + c.OnInvalidate("dns.tree-root-changed", nil) + deleted, err := c.Invalidate("dns.tree-root-changed") + if err != nil { + t.Fatalf("Invalidate failed: %v", err) + } + if deleted != 0 { + t.Fatalf("expected nil callback to be ignored, got %d deletions", deleted) + } + + var record string + found, err := c.Get("dns/example.com/A", &record) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if !found || record != "record" { + t.Fatalf("expected entry to remain, found=%v val=%q", found, record) + } +} + +func TestCache_Scoped_OnInvalidate_NilCallbackIsIgnored(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-scoped-invalidate-nil", time.Minute) + + scoped := c.Scoped("https://app.example.com") + + if err := scoped.Set("dns/example.com/A", "record"); err != nil { + t.Fatalf("Set failed: %v", err) + } + + scoped.OnInvalidate("dns.tree-root-changed", nil) + deleted, err := scoped.Invalidate("dns.tree-root-changed") + if err != nil { + t.Fatalf("Invalidate failed: %v", err) + } + if deleted != 0 { + t.Fatalf("expected nil scoped callback to be ignored, got %d deletions", deleted) + } + + var record string + found, err := scoped.Get("dns/example.com/A", &record) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if !found || record != "record" { + t.Fatalf("expected scoped entry to remain, found=%v val=%q", found, record) + } +} + +func TestCache_Scoped_Wrappers_Good(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-scoped-wrappers", time.Minute) + scoped := c.Scoped("https://app.example.com") + + if err := scoped.Set("value", "alpha"); err != nil { + t.Fatalf("Scoped Set failed: %v", err) + } + if err := scoped.SetWithTTL("ttl", "beta", 5*time.Millisecond); err != nil { + t.Fatalf("Scoped SetWithTTL failed: %v", err) + } + if err := scoped.SetBinary("blob", []byte("bin"), "application/octet-stream"); err != nil { + t.Fatalf("Scoped SetBinary failed: %v", err) + } + if err := scoped.SetBinaryWithTTL("blob-ttl", []byte("bin2"), "application/octet-stream", 5*time.Millisecond); err != nil { + t.Fatalf("Scoped SetBinaryWithTTL failed: %v", err) + } + + path, err := scoped.Path("value") + if err != nil { + t.Fatalf("Scoped Path failed: %v", err) + } + if !core.Contains(path, "scope_") { + t.Fatalf("expected scoped path, got %q", path) + } + + var value string + found, err := scoped.Get("value", &value) + if err != nil || !found || value != "alpha" { + t.Fatalf("unexpected scoped Get result: found=%v value=%q err=%v", found, value, err) + } + + data, found, err := scoped.GetBinary("blob") + if err != nil || !found || string(data) != "bin" { + t.Fatalf("unexpected scoped GetBinary result: found=%v data=%q err=%v", found, data, err) + } + + if age := scoped.Age("value"); age < 0 { + t.Fatalf("expected scoped Age >= 0, got %v", age) + } + + if err := scoped.Delete("value"); err != nil { + t.Fatalf("Scoped Delete failed: %v", err) + } + if err := scoped.DeleteMany("ttl", "blob-ttl"); err != nil { + t.Fatalf("Scoped DeleteMany failed: %v", err) + } + if err := scoped.Clear(); err != nil { + t.Fatalf("Scoped Clear failed: %v", err) + } +} + +func TestCache_Scoped_Scoped_Good(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-scoped-scoped", time.Minute) + + app := c.Scoped("https://app.example.com") + admin := app.Scoped("https://admin.example.com") + + if admin == nil { + t.Fatal("expected Scoped on ScopedCache to return a cache") + } + + if err := app.Set("user/profile", "app-user"); err != nil { + t.Fatalf("app Set failed: %v", err) + } + if err := admin.Set("user/profile", "admin-user"); err != nil { + t.Fatalf("admin Set failed: %v", err) + } + + var appValue string + found, err := app.Get("user/profile", &appValue) + if err != nil || !found || appValue != "app-user" { + t.Fatalf("unexpected app scoped value: found=%v value=%q err=%v", found, appValue, err) + } + + var adminValue string + found, err = admin.Get("user/profile", &adminValue) + if err != nil || !found || adminValue != "admin-user" { + t.Fatalf("unexpected admin scoped value: found=%v value=%q err=%v", found, adminValue, err) + } + + if err := admin.Clear(); err != nil { + t.Fatalf("admin Clear failed: %v", err) + } + + found, err = app.Get("user/profile", &appValue) + if err != nil || !found || appValue != "app-user" { + t.Fatalf("expected app scope to remain after clearing admin, found=%v value=%q err=%v", found, appValue, err) + } + + found, err = admin.Get("user/profile", &adminValue) + if err != nil { + t.Fatalf("admin Get after clear failed: %v", err) + } + if found { + t.Fatal("expected admin scope to be cleared") + } +} + +func TestCache_Scoped_NilReceiver_Bad(t *testing.T) { + var scoped *cache.ScopedCache + var dest string + + if scoped.Scoped("https://app.example.com") != nil { + t.Fatal("expected scoped Scoped to return nil on nil receiver") + } + if _, err := scoped.Path("x"); err == nil { + t.Fatal("expected scoped Path to fail on nil receiver") + } + if _, err := scoped.Get("x", &dest); err == nil { + t.Fatal("expected scoped Get to fail on nil receiver") + } + if err := scoped.Set("x", "v"); err == nil { + t.Fatal("expected scoped Set to fail on nil receiver") + } + if err := scoped.SetWithTTL("x", "v", time.Second); err == nil { + t.Fatal("expected scoped SetWithTTL to fail on nil receiver") + } + if err := scoped.SetBinary("x", []byte("v"), "text/plain"); err == nil { + t.Fatal("expected scoped SetBinary to fail on nil receiver") + } + if err := scoped.SetBinaryWithTTL("x", []byte("v"), "text/plain", time.Second); err == nil { + t.Fatal("expected scoped SetBinaryWithTTL to fail on nil receiver") + } + if _, _, err := scoped.GetBinary("x"); err == nil { + t.Fatal("expected scoped GetBinary to fail on nil receiver") + } + if err := scoped.Delete("x"); err == nil { + t.Fatal("expected scoped Delete to fail on nil receiver") + } + if err := scoped.DeleteMany("x"); err == nil { + t.Fatal("expected scoped DeleteMany to fail on nil receiver") + } + if err := scoped.Clear(); err == nil { + t.Fatal("expected scoped Clear to fail on nil receiver") + } + if err := scoped.ClearScope("https://app.example.com"); err == nil { + t.Fatal("expected scoped ClearScope to fail on nil receiver") + } + if _, err := scoped.Invalidate("trigger"); err == nil { + t.Fatal("expected scoped Invalidate to fail on nil receiver") + } + if age := scoped.Age("x"); age != -1 { + t.Fatalf("expected scoped Age to return -1 on nil receiver, got %v", age) + } +} + +func TestCache_HTTPCacheStorage_RejectsTraversalNames(t *testing.T) { + storage, err := cache.NewCacheStorage(coreio.NewMockMedium(), "/tmp/cache-http-traversal") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + tests := []struct { + name string + fn func() error + }{ + { + name: "open-empty", + fn: func() error { + _, err := storage.Open("") + return err + }, + }, + { + name: "open-dot", + fn: func() error { + _, err := storage.Open(".") + return err + }, + }, + { + name: "open-traversal", + fn: func() error { + _, err := storage.Open("../evil") + return err + }, + }, + { + name: "delete-backslash", + fn: func() error { + return storage.Delete(`bad\cache`) + }, + }, + { + name: "open-too-long", + fn: func() error { + _, err := storage.Open(repeatString("a", 256)) + return err + }, + }, + { + name: "open-newline", + fn: func() error { + _, err := storage.Open("cache\nname") + return err + }, + }, + { + name: "open-null-byte", + fn: func() error { + _, err := storage.Open("cache\x00name") + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.fn(); err == nil { + t.Fatalf("expected %s to be rejected", tt.name) + } + }) + } +} + +func TestCache_HTTPCacheStorage_NilReceiver_Bad(t *testing.T) { + var storage *cache.CacheStorage + + if _, err := storage.Open("x"); err == nil { + t.Fatal("expected Open to fail on nil storage") + } + if err := storage.Delete("x"); err == nil { + t.Fatal("expected Delete to fail on nil storage") + } + if _, err := storage.Keys(); err == nil { + t.Fatal("expected Keys to fail on nil storage") + } + if err := storage.Close(); err != nil { + t.Fatalf("Close on nil storage should be a no-op: %v", err) + } +} + +func TestCache_HTTPCacheStorage_Good(t *testing.T) { + medium := coreio.NewMockMedium() + storage, err := cache.NewCacheStorage(medium, "/tmp/cache-http") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("my-app-v1") + if err != nil { + t.Fatalf("storage.Open failed: %v", err) + } + if again, err := storage.Open("my-app-v1"); err != nil { + t.Fatalf("storage.Open reuse failed: %v", err) + } else if again != httpCache { + t.Fatal("expected Open to reuse the existing cache instance") + } + + req := cache.CachedRequest{ + URL: "https://example.com/style.css", + Method: "GET", + } + resp := cache.CachedResponse{ + Status: 200, + StatusText: "OK", + Headers: map[string]string{ + "Content-Type": "text/css", + }, + } + + if err := httpCache.Put(req, resp, []byte("body")); err != nil { + t.Fatalf("Put failed: %v", err) + } + + metaEntries, err := medium.List("/tmp/cache-http/my-app-v1/responses") + if err != nil { + t.Fatalf("List response metadata failed: %v", err) + } + var metaPath string + for _, entry := range metaEntries { + if core.HasSuffix(entry.Name(), ".json") { + metaPath = "/tmp/cache-http/my-app-v1/responses/" + entry.Name() + break + } + } + if metaPath == "" { + t.Fatal("expected response metadata file") + } + + rawMeta, err := medium.Read(metaPath) + if err != nil { + t.Fatalf("Read response metadata failed: %v", err) + } + + var stored struct { + Request cache.CachedRequest `json:"request"` + Response cache.CachedResponse `json:"response"` + } + result := core.JSONUnmarshalString(rawMeta, &stored) + if !result.OK { + t.Fatalf("failed to unmarshal stored metadata envelope: %v", result.Value) + } + if stored.Request.URL != req.URL || stored.Request.Method != req.Method { + t.Fatalf("unexpected stored request metadata: %+v", stored.Request) + } + if stored.Response.Status != resp.Status || stored.Response.StatusText != resp.StatusText { + t.Fatalf("unexpected stored response metadata: %+v", stored.Response) + } + + matched, err := httpCache.Match(req) + if err != nil { + t.Fatalf("Match failed: %v", err) + } + if matched == nil { + t.Fatalf("expected matched response") + } + + body, err := httpCache.ReadBody(matched) + if err != nil { + t.Fatalf("ReadBody failed: %v", err) + } + if string(body) != "body" { + t.Fatalf("unexpected body: %q", body) + } + + urls, err := httpCache.Keys() + if err != nil { + t.Fatalf("Keys failed: %v", err) + } + if len(urls) != 1 { + t.Fatalf("expected one URL, got %d", len(urls)) + } + if urls[0] != "https://example.com/style.css" { + t.Fatalf("unexpected url: %q", urls[0]) + } + + if err := httpCache.Delete(req); err != nil { + t.Fatalf("Delete failed: %v", err) + } + matched, err = httpCache.Match(req) + if err != nil { + t.Fatalf("Match after delete failed: %v", err) + } + if matched != nil { + t.Fatalf("expected response to be deleted") + } + + names, err := storage.Keys() + if err != nil { + t.Fatalf("storage.Keys before delete failed: %v", err) + } + if len(names) != 1 || names[0] != "my-app-v1" { + t.Fatalf("expected cache name to be listed, got %v", core.Join(",", names...)) + } + + if err := storage.Delete("my-app-v1"); err != nil { + t.Fatalf("storage.Delete failed: %v", err) + } + + if err := storage.Delete("my-app-v1"); err != nil { + t.Fatalf("storage.Delete on missing cache should be a no-op, got %v", err) + } + + names, err = storage.Keys() + if err != nil { + t.Fatalf("storage.Keys failed: %v", err) + } + if len(names) != 0 { + t.Fatalf("expected cache name removed, got %v", core.Join(",", names...)) + } +} + +func TestCache_HTTPCacheStorage_Good_LongURLUsesFixedWidthStorageKey(t *testing.T) { + medium := coreio.NewMockMedium() + storage, err := cache.NewCacheStorage(medium, "/tmp/cache-http-long-url") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("long-url") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + + req := cache.CachedRequest{ + URL: "https://example.com/" + repeatString("a", 4000), + Method: "GET", + } + if err := httpCache.Put(req, cache.CachedResponse{Status: 200, StatusText: "OK"}, []byte("body")); err != nil { + t.Fatalf("Put failed: %v", err) + } + + key := httpCacheStorageKey(req) + metaPath := "/tmp/cache-http-long-url/long-url/responses/" + key + ".json" + if _, ok := medium.Files[metaPath]; !ok { + t.Fatalf("expected fixed-width metadata path %q to exist", metaPath) + } + if len(key) != 64 { + t.Fatalf("expected SHA-256 hex key length 64, got %d", len(key)) + } + + matched, err := httpCache.Match(req) + if err != nil { + t.Fatalf("Match failed: %v", err) + } + if matched == nil { + t.Fatal("expected long URL response to match") + } +} + +func TestCache_HTTPCacheStorage_Keys_Good_EmptyDir(t *testing.T) { + medium := newScriptedMedium() + storage, err := cache.NewCacheStorage(medium, "/tmp/cache-http-empty-keys") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + medium.listErr["/tmp/cache-http-empty-keys"] = fs.ErrNotExist + + names, err := storage.Keys() + if err != nil { + t.Fatalf("Keys should treat missing storage dir as empty: %v", err) + } + if len(names) != 0 { + t.Fatalf("expected no cache names, got %v", names) + } +} + +func TestCache_HTTPCacheStorage_Keys_Bad_ListFailure(t *testing.T) { + medium := newScriptedMedium() + storage, err := cache.NewCacheStorage(medium, "/tmp/cache-http-keys-bad") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + medium.listErr["/tmp/cache-http-keys-bad"] = core.E("cache_test", "boom", nil) + + if _, err := storage.Keys(); err == nil { + t.Fatal("expected Keys to surface backend list failure") + } +} + +func TestCache_HTTPCacheStorage_Delete_Bad_BackendFailure(t *testing.T) { + medium := newScriptedMedium() + storage, err := cache.NewCacheStorage(medium, "/tmp/cache-http-delete-storage-bad") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + medium.deleteAllErr["/tmp/cache-http-delete-storage-bad/blocked"] = core.E("cache_test", "boom", nil) + + if err := storage.Delete("blocked"); err == nil { + t.Fatal("expected Delete to surface backend failure") + } +} + +func TestCache_HTTPCacheStorage_Close_Good(t *testing.T) { + storage, err := cache.NewCacheStorage(coreio.NewMockMedium(), "/tmp/cache-http-close") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + if err := storage.Close(); err != nil { + t.Fatalf("Close failed: %v", err) + } +} + +func TestCache_HTTPCacheStorage_Close_AllowsReuse(t *testing.T) { + storage, err := cache.NewCacheStorage(coreio.NewMockMedium(), "/tmp/cache-http-close-reuse") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + if err := storage.Close(); err != nil { + t.Fatalf("Close failed: %v", err) + } + + httpCache, err := storage.Open("reused-cache") + if err != nil { + t.Fatalf("Open after Close failed: %v", err) + } + + req := cache.CachedRequest{ + URL: "https://example.com/reused", + Method: "GET", + } + resp := cache.CachedResponse{Status: 200, StatusText: "OK"} + + if err := httpCache.Put(req, resp, []byte("ok")); err != nil { + t.Fatalf("Put after Close failed: %v", err) + } +} + +func TestCache_HTTPCacheStorage_DottedName_Good(t *testing.T) { + storage, err := cache.NewCacheStorage(coreio.NewMockMedium(), "/tmp/cache-http-dotted") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("api.v2-cache") + if err != nil { + t.Fatalf("storage.Open failed: %v", err) + } + + req := cache.CachedRequest{ + URL: "https://example.com/api", + Method: "GET", + } + resp := cache.CachedResponse{Status: 200, StatusText: "OK"} + + if err := httpCache.Put(req, resp, []byte("ok")); err != nil { + t.Fatalf("Put failed: %v", err) + } + + names, err := storage.Keys() + if err != nil { + t.Fatalf("storage.Keys failed: %v", err) + } + if len(names) != 1 || names[0] != "api.v2-cache" { + t.Fatalf("expected dotted cache name to be listed, got %v", core.Join(",", names...)) + } +} + +func TestCache_HTTPCacheDeleteMissing_Good(t *testing.T) { + storage, err := cache.NewCacheStorage(coreio.NewMockMedium(), "/tmp/cache-http-delete-missing") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("missing-delete") + if err != nil { + t.Fatalf("storage.Open failed: %v", err) + } + + req := cache.CachedRequest{ + URL: "https://example.com/missing.js", + Method: "GET", + } + + if err := httpCache.Delete(req); err != nil { + t.Fatalf("Delete on missing request should be a no-op, got %v", err) + } +} + +func TestCache_HTTPCache_Keys_Good_EmptyResponseDir(t *testing.T) { + medium := newScriptedMedium() + storage, err := cache.NewCacheStorage(medium, "/tmp/cache-http-keys-empty") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("keys-empty") + if err != nil { + t.Fatalf("storage.Open failed: %v", err) + } + + medium.listErr["/tmp/cache-http-keys-empty/keys-empty/responses"] = fs.ErrNotExist + + urls, err := httpCache.Keys() + if err != nil { + t.Fatalf("Keys should treat missing response dir as empty: %v", err) + } + if len(urls) != 0 { + t.Fatalf("expected no URLs, got %v", urls) + } +} + +func TestCache_HTTPCache_Keys_Bad_ListFailure(t *testing.T) { + medium := newScriptedMedium() + storage, err := cache.NewCacheStorage(medium, "/tmp/cache-http-keys-list-bad") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("keys-list-bad") + if err != nil { + t.Fatalf("storage.Open failed: %v", err) + } + + medium.listErr["/tmp/cache-http-keys-list-bad/keys-list-bad/responses"] = core.E("cache_test", "boom", nil) + + if _, err := httpCache.Keys(); err == nil { + t.Fatal("expected Keys to surface backend list failure") + } +} + +func TestCache_HTTPCacheReadBody_Bad(t *testing.T) { + storage, err := cache.NewCacheStorage(coreio.NewMockMedium(), "/tmp/cache-http-body-safety") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("body-safety") + if err != nil { + t.Fatalf("storage.Open failed: %v", err) + } + + tests := []struct { + name string + resp *cache.CachedResponse + }{ + {name: "nil", resp: nil}, + {name: "empty", resp: &cache.CachedResponse{}}, + {name: "absolute", resp: &cache.CachedResponse{BodyPath: "/responses/secret.bin"}}, + {name: "traversal", resp: &cache.CachedResponse{BodyPath: "../../etc/passwd"}}, + {name: "wrong-root", resp: &cache.CachedResponse{BodyPath: "config/secret.bin"}}, + {name: "wrong-extension", resp: &cache.CachedResponse{BodyPath: "responses/secret.txt"}}, + {name: "backslash", resp: &cache.CachedResponse{BodyPath: `responses\secret.bin`}}, + {name: "null-byte", resp: &cache.CachedResponse{BodyPath: "responses/secret\x00.bin"}}, + {name: "too-long", resp: &cache.CachedResponse{BodyPath: "responses/" + repeatString("a", 4097) + ".bin"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := httpCache.ReadBody(tt.resp); err == nil { + t.Fatalf("expected ReadBody to reject %s body path", tt.name) + } + }) + } +} + +func TestCache_HTTPCacheReadBody_Bad_MissingPayload(t *testing.T) { + medium := newScriptedMedium() + storage, err := cache.NewCacheStorage(medium, "/tmp/cache-http-body-missing") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("body-missing") + if err != nil { + t.Fatalf("storage.Open failed: %v", err) + } + + req := cache.CachedRequest{ + URL: "https://example.com/missing", + Method: "GET", + } + resp := cache.CachedResponse{Status: 200, StatusText: "OK"} + if err := httpCache.Put(req, resp, []byte("body")); err != nil { + t.Fatalf("Put failed: %v", err) + } + + key := httpCacheStorageKey(req) + bodyPath := "/tmp/cache-http-body-missing/body-missing/responses/" + key + ".bin" + delete(medium.Files, bodyPath) + + matched, err := httpCache.Match(req) + if err != nil { + t.Fatalf("Match failed: %v", err) + } + if matched == nil { + t.Fatal("expected response metadata to remain") + } + + if _, err := httpCache.ReadBody(matched); err == nil { + t.Fatal("expected ReadBody to fail when the body payload is missing") + } +} + +func TestCache_HTTPCache_NilReceiver_Bad(t *testing.T) { + var httpCache *cache.HTTPCache + req := cache.CachedRequest{URL: "https://example.com", Method: "GET"} + resp := cache.CachedResponse{BodyPath: "responses/a.bin"} + + if _, err := httpCache.Match(req); err == nil { + t.Fatal("expected Match to fail on nil http cache") + } + if err := httpCache.Put(req, cache.CachedResponse{}, []byte("body")); err == nil { + t.Fatal("expected Put to fail on nil http cache") + } + if _, err := httpCache.ReadBody(&resp); err == nil { + t.Fatal("expected ReadBody to fail on nil http cache") + } + if err := httpCache.Delete(req); err == nil { + t.Fatal("expected Delete to fail on nil http cache") + } + if _, err := httpCache.Keys(); err == nil { + t.Fatal("expected Keys to fail on nil http cache") + } +} + +func TestCache_HTTPCache_Delete_Bad_BackendFailure(t *testing.T) { + medium := newScriptedMedium() + storage, err := cache.NewCacheStorage(medium, "/tmp/cache-http-delete-bad") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("delete-bad") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + + req := cache.CachedRequest{ + URL: "https://example.com/style.css", + Method: "GET", + } + key := httpCacheStorageKey(req) + metaPath := "/tmp/cache-http-delete-bad/delete-bad/responses/" + key + ".json" + medium.deleteErr[metaPath] = core.E("cache_test", "boom", nil) + + if err := httpCache.Delete(req); err == nil { + t.Fatal("expected Delete to surface backend failure") + } +} + +func TestCache_HTTPCache_Match_Bad_IncompleteEnvelope(t *testing.T) { + medium := newScriptedMedium() + storage, err := cache.NewCacheStorage(medium, "/tmp/cache-http-match-incomplete") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("match-incomplete") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + + req := cache.CachedRequest{ + URL: "https://example.com/style.css", + Method: "GET", + } + key := legacyHTTPCacheStorageKey(req) + metaPath := "/tmp/cache-http-match-incomplete/match-incomplete/responses/" + key + ".json" + medium.Files[metaPath] = `{"request":{"url":"https://example.com/style.css","method":"GET"}}` + + if matched, err := httpCache.Match(req); err == nil || matched != nil { + t.Fatalf("expected Match to reject incomplete cached response envelope, matched=%v err=%v", matched, err) + } +} + +func TestCache_HTTPCache_Match_Bad_EmptyRequest(t *testing.T) { + storage, err := cache.NewCacheStorage(coreio.NewMockMedium(), "/tmp/cache-http-match-empty") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("match-empty") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + + if matched, err := httpCache.Match(cache.CachedRequest{}); err == nil || matched != nil { + t.Fatalf("expected Match to reject empty request metadata, matched=%v err=%v", matched, err) + } +} + +func TestCache_HTTPCache_LegacyMetadata_Good(t *testing.T) { + medium := newScriptedMedium() + storage, err := cache.NewCacheStorage(medium, "/tmp/cache-http-legacy") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("legacy") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + + req := cache.CachedRequest{ + URL: "https://example.com/style.css", + Method: "GET", + } + key := legacyHTTPCacheStorageKey(req) + metaPath := "/tmp/cache-http-legacy/legacy/responses/" + key + ".json" + binPath := "/tmp/cache-http-legacy/legacy/responses/" + key + ".bin" + + legacy := cache.CachedResponse{ + Status: 200, + StatusText: "OK", + Headers: map[string]string{"Content-Type": "text/css"}, + BodyPath: "responses/" + key + ".bin", + CachedAt: time.Now(), + } + raw, err := json.Marshal(legacy) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + medium.Files[metaPath] = string(raw) + medium.Files[binPath] = "body" + + matched, err := httpCache.Match(req) + if err != nil { + t.Fatalf("Match failed: %v", err) + } + if matched == nil { + t.Fatal("expected legacy cached response to match") + } + if matched.Status != 200 || matched.StatusText != "OK" { + t.Fatalf("unexpected legacy response metadata: %+v", matched) + } + + body, err := httpCache.ReadBody(matched) + if err != nil { + t.Fatalf("ReadBody failed: %v", err) + } + if string(body) != "body" { + t.Fatalf("unexpected legacy body: %q", body) + } +} + +func TestCache_HTTPCache_Put_Bad(t *testing.T) { + storage, err := cache.NewCacheStorage(coreio.NewMockMedium(), "/tmp/cache-http-put-bad") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("put-bad") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + + if err := httpCache.Put(cache.CachedRequest{}, cache.CachedResponse{}, []byte("body")); err == nil { + t.Fatal("expected Put to reject empty request key") + } +} + +func TestCache_HTTPCache_Put_Bad_RequestMetadata(t *testing.T) { + storage, err := cache.NewCacheStorage(coreio.NewMockMedium(), "/tmp/cache-http-put-request-bad") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("put-request-bad") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + + tests := []struct { + name string + req cache.CachedRequest + }{ + { + name: "invalid-method", + req: cache.CachedRequest{ + URL: "https://example.com/style.css", + Method: "G ET", + }, + }, + { + name: "url-control-bytes", + req: cache.CachedRequest{ + URL: "https://example.com/\r\nX-Injected: yes", + Method: "GET", + }, + }, + { + name: "method-control-bytes", + req: cache.CachedRequest{ + URL: "https://example.com/style.css", + Method: "GET\r\nX-Injected: yes", + }, + }, + { + name: "url-too-long", + req: cache.CachedRequest{ + URL: "https://example.com/" + repeatString("a", 8193), + Method: "GET", + }, + }, + { + name: "method-too-long", + req: cache.CachedRequest{ + URL: "https://example.com/style.css", + Method: repeatString("G", 33), + }, + }, + } + + resp := cache.CachedResponse{Status: 200, StatusText: "OK"} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := httpCache.Put(tt.req, resp, []byte("body")); err == nil { + t.Fatalf("expected Put to reject %s request metadata", tt.name) + } + }) + } +} + +func TestCache_HTTPCache_Put_Bad_HTTPMetadata(t *testing.T) { + storage, err := cache.NewCacheStorage(coreio.NewMockMedium(), "/tmp/cache-http-put-metadata-bad") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("put-metadata-bad") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + + req := cache.CachedRequest{ + URL: "https://example.com/style.css", + Method: "GET", + } + + tests := []struct { + name string + resp cache.CachedResponse + }{ + { + name: "status", + resp: cache.CachedResponse{Status: 0, StatusText: "OK"}, + }, + { + name: "header-name", + resp: cache.CachedResponse{ + Status: 200, + StatusText: "OK", + Headers: map[string]string{"X-Inject\r\ned": "value"}, + }, + }, + { + name: "empty-header-name", + resp: cache.CachedResponse{ + Status: 200, + StatusText: "OK", + Headers: map[string]string{"": "value"}, + }, + }, + { + name: "header-value", + resp: cache.CachedResponse{ + Status: 200, + StatusText: "OK", + Headers: map[string]string{"Content-Type": "text/plain\r\nX-Injected: yes"}, + }, + }, + { + name: "status-text", + resp: cache.CachedResponse{Status: 200, StatusText: "OK\r\nInjected"}, + }, + { + name: "status-text-too-long", + resp: cache.CachedResponse{Status: 200, StatusText: repeatString("O", 1025)}, + }, + { + name: "header-name-too-long", + resp: cache.CachedResponse{ + Status: 200, + StatusText: "OK", + Headers: map[string]string{repeatString("X", 257): "value"}, + }, + }, + { + name: "header-value-too-long", + resp: cache.CachedResponse{ + Status: 200, + StatusText: "OK", + Headers: map[string]string{"Content-Type": repeatString("a", 8193)}, + }, + }, + { + name: "too-many-headers", + resp: func() cache.CachedResponse { + headers := make(map[string]string, 129) + for i := 0; i < 129; i++ { + headers[core.Concat("X-Test-", string(rune('a'+(i%26))), "-", string(rune('0'+((i/26)%10))))] = "value" + } + return cache.CachedResponse{ + Status: 200, + StatusText: "OK", + Headers: headers, + } + }(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := httpCache.Put(req, tt.resp, []byte("body")); err == nil { + t.Fatalf("expected Put to reject %s metadata", tt.name) + } + }) + } +} + +func TestCache_HTTPCache_Put_Ugly(t *testing.T) { + medium := newScriptedMedium() + storage, err := cache.NewCacheStorage(medium, "/tmp/cache-http-put-ugly") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("put-ugly") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + + req := cache.CachedRequest{URL: "https://example.com/style.css", Method: "GET"} + key := httpCacheStorageKey(req) + metaPath := "/tmp/cache-http-put-ugly/put-ugly/responses/" + key + ".json" + binPath := "/tmp/cache-http-put-ugly/put-ugly/responses/" + key + ".bin" + medium.writeErr[metaPath] = core.E("cache_test", "metadata boom", nil) + + if err := httpCache.Put(req, cache.CachedResponse{}, []byte("body")); err == nil { + t.Fatal("expected Put to surface metadata write failure") + } + if _, ok := medium.Files[binPath]; ok { + t.Fatal("expected response body to be cleaned up after metadata write failure") + } +} + +func TestCache_HTTPCache_Match_Bad_RequestMismatch(t *testing.T) { + medium := newScriptedMedium() + storage, err := cache.NewCacheStorage(medium, "/tmp/cache-http-match-mismatch") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("match-mismatch") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + + req := cache.CachedRequest{ + URL: "https://example.com/style.css", + Method: "GET", + } + key := httpCacheStorageKey(req) + metaPath := "/tmp/cache-http-match-mismatch/match-mismatch/responses/" + key + ".json" + + record := struct { + Request cache.CachedRequest `json:"request"` + Response cache.CachedResponse `json:"response"` + }{ + Request: cache.CachedRequest{ + URL: "https://example.com/wrong.css", + Method: "GET", + }, + Response: cache.CachedResponse{ + Status: 200, + StatusText: "OK", + Headers: map[string]string{"Content-Type": "text/css"}, + BodyPath: "responses/" + key + ".bin", + }, + } + + raw, err := json.Marshal(record) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + medium.Files[metaPath] = string(raw) + + if matched, err := httpCache.Match(req); err == nil || matched != nil { + t.Fatalf("expected Match to reject mismatched request metadata, matched=%v err=%v", matched, err) + } +} + +func TestCache_HTTPCache_Match_Bad_BodyPath(t *testing.T) { + medium := newScriptedMedium() + storage, err := cache.NewCacheStorage(medium, "/tmp/cache-http-match-body-path") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("match-body-path") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + + req := cache.CachedRequest{ + URL: "https://example.com/style.css", + Method: "GET", + } + key := httpCacheStorageKey(req) + metaPath := "/tmp/cache-http-match-body-path/match-body-path/responses/" + key + ".json" + + record := struct { + Request cache.CachedRequest `json:"request"` + Response cache.CachedResponse `json:"response"` + }{ + Request: req, + Response: cache.CachedResponse{ + Status: 200, + StatusText: "OK", + Headers: map[string]string{"Content-Type": "text/css"}, + BodyPath: "config/secret.bin", + }, + } + + raw, err := json.Marshal(record) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + medium.Files[metaPath] = string(raw) + + if matched, err := httpCache.Match(req); err == nil || matched != nil { + t.Fatalf("expected Match to reject invalid body path, matched=%v err=%v", matched, err) + } +} + +func TestCache_HTTPCache_Match_Bad_BodyPathMismatch(t *testing.T) { + medium := newScriptedMedium() + storage, err := cache.NewCacheStorage(medium, "/tmp/cache-http-match-body-path-mismatch") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("match-body-path-mismatch") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + + req := cache.CachedRequest{ + URL: "https://example.com/style.css", + Method: "GET", + } + key := httpCacheStorageKey(req) + metaPath := "/tmp/cache-http-match-body-path-mismatch/match-body-path-mismatch/responses/" + key + ".json" + + record := struct { + Request cache.CachedRequest `json:"request"` + Response cache.CachedResponse `json:"response"` + }{ + Request: req, + Response: cache.CachedResponse{ + Status: 200, + StatusText: "OK", + Headers: map[string]string{"Content-Type": "text/css"}, + BodyPath: "responses/other.bin", + }, + } + + raw, err := json.Marshal(record) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + medium.Files[metaPath] = string(raw) + + if matched, err := httpCache.Match(req); err == nil || matched != nil { + t.Fatalf("expected Match to reject mismatched body path, matched=%v err=%v", matched, err) + } +} + +func TestCache_HTTPCache_Match_RejectsTamperedMetadata(t *testing.T) { + medium := newScriptedMedium() + storage, err := cache.NewCacheStorage(medium, "/tmp/cache-http-match-tampered") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("match-tampered") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + + req := cache.CachedRequest{ + URL: "https://example.com/style.css", + Method: "GET", + } + key := httpCacheStorageKey(req) + metaPath := "/tmp/cache-http-match-tampered/match-tampered/responses/" + key + ".json" + + record := struct { + Request cache.CachedRequest `json:"request"` + Response cache.CachedResponse `json:"response"` + }{ + Request: req, + Response: cache.CachedResponse{ + Status: 200, + StatusText: "OK", + Headers: map[string]string{"X-Inject\r\ned": "value"}, + BodyPath: "responses/" + key + ".bin", + }, + } + + raw, err := json.Marshal(record) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + medium.Files[metaPath] = string(raw) + + if matched, err := httpCache.Match(req); err == nil || matched != nil { + t.Fatalf("expected Match to reject tampered metadata, matched=%v err=%v", matched, err) + } +} + +func TestCache_HTTPCache_Keys_Good(t *testing.T) { + storage, err := cache.NewCacheStorage(coreio.NewMockMedium(), "/tmp/cache-http-keys") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("keys") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + + body := []byte("body") + if err := httpCache.Put(cache.CachedRequest{URL: "https://example.com/a", Method: "GET"}, cache.CachedResponse{Status: 200, StatusText: "OK"}, body); err != nil { + t.Fatalf("Put failed: %v", err) + } + if err := httpCache.Put(cache.CachedRequest{URL: "https://example.com/a", Method: "HEAD"}, cache.CachedResponse{Status: 200, StatusText: "OK"}, body); err != nil { + t.Fatalf("Put duplicate URL failed: %v", err) + } + if err := httpCache.Put(cache.CachedRequest{URL: "https://example.com/b", Method: "GET"}, cache.CachedResponse{Status: 200, StatusText: "OK"}, body); err != nil { + t.Fatalf("Put failed: %v", err) + } + + urls, err := httpCache.Keys() + if err != nil { + t.Fatalf("Keys failed: %v", err) + } + if len(urls) != 2 { + t.Fatalf("expected deduped URLs, got %v", urls) + } + if urls[0] != "https://example.com/a" || urls[1] != "https://example.com/b" { + t.Fatalf("unexpected sorted URLs: %v", urls) + } +} + +func TestCache_HTTPCache_Match_Bad(t *testing.T) { + storage, err := cache.NewCacheStorage(coreio.NewMockMedium(), "/tmp/cache-http-match-bad") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("match-bad") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + + matched, err := httpCache.Match(cache.CachedRequest{URL: "https://example.com/missing", Method: "GET"}) + if err != nil { + t.Fatalf("Match returned unexpected error: %v", err) + } + if matched != nil { + t.Fatal("expected missing cached response to return nil") + } +} + +func TestCache_ThreatUntrustedKeyDoS_RejectsOversizedKeysOnWritePaths(t *testing.T) { + c, medium := newTestCache(t, "/tmp/cache-threat-untrusted-key", time.Minute) + key := repeatString("a", 4097) + + tests := []struct { + name string + fn func() error + }{ + { + name: "set", + fn: func() error { + return c.Set(key, "value") + }, + }, + { + name: "set-with-ttl", + fn: func() error { + return c.SetWithTTL(key, "value", time.Minute) + }, + }, + { + name: "set-binary", + fn: func() error { + return c.SetBinary(key, []byte("value"), "text/plain") + }, + }, + { + name: "set-binary-with-ttl", + fn: func() error { + return c.SetBinaryWithTTL(key, []byte("value"), "text/plain", time.Minute) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.fn(); err == nil { + t.Fatalf("expected %s to reject oversized cache key", tt.name) + } + }) + } + + if len(medium.Files) != 0 { + t.Fatalf("oversized rejected keys should not write cache files, got %d", len(medium.Files)) + } +} + +func TestCache_ThreatPathTraversal_ScopedOriginIsHashedAndKeysStillValidated(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-threat-scoped-path", time.Minute) + scoped := c.Scoped("../../evil\norigin") + if scoped == nil { + t.Fatal("expected scoped cache") + } + + if err := scoped.Set("safe-key", "value"); err != nil { + t.Fatalf("scoped Set with hostile origin failed: %v", err) + } + path, err := scoped.Path("safe-key") + if err != nil { + t.Fatalf("scoped Path failed: %v", err) + } + if core.Contains(path, "evil") || core.Contains(path, "..") || core.Contains(path, "\n") { + t.Fatalf("expected scoped path to omit raw origin, got %q", path) + } + + if err := scoped.Set("../../escape", "value"); err == nil { + t.Fatal("expected scoped Set to reject traversal key") + } +} + +func TestCache_ThreatPathTraversal_HTTPCacheUsesHashedRequestStorageKeys(t *testing.T) { + medium := coreio.NewMockMedium() + storage, err := cache.NewCacheStorage(medium, "/tmp/cache-threat-http-path") + if err != nil { + t.Fatalf("NewCacheStorage failed: %v", err) + } + + httpCache, err := storage.Open("assets") + if err != nil { + t.Fatalf("Open failed: %v", err) + } + + req := cache.CachedRequest{ + URL: "https://example.com/../../secret.css?file=../secret", + Method: "GET", + } + resp := cache.CachedResponse{Status: 200, StatusText: "OK"} + if err := httpCache.Put(req, resp, []byte("body")); err != nil { + t.Fatalf("Put failed: %v", err) + } + + key := httpCacheStorageKey(req) + if _, ok := medium.Files["/tmp/cache-threat-http-path/assets/responses/"+key+".json"]; !ok { + t.Fatal("expected HTTP metadata to be stored under hashed request key") + } + if _, ok := medium.Files["/tmp/cache-threat-http-path/assets/responses/"+key+".bin"]; !ok { + t.Fatal("expected HTTP body to be stored under hashed request key") + } + for path := range medium.Files { + if core.Contains(path, "..") || core.Contains(path, "secret.css") { + t.Fatalf("expected stored path to omit raw request URL, got %q", path) + } + } + + matched, err := httpCache.Match(req) + if err != nil { + t.Fatalf("Match failed: %v", err) + } + if matched == nil { + t.Fatal("expected cached response to match") + } + if _, err := httpCache.ReadBody(&cache.CachedResponse{BodyPath: "../../escape"}); err == nil { + t.Fatal("expected ReadBody to reject traversal body path") + } +} + +func TestCache_ThreatTOCTOU_InvalidateOnInvalidateRegistrationIsSnapshotRaceClean(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-threat-invalidate-snapshot", time.Minute) + + if err := c.Set("victim", "old"); err != nil { + t.Fatalf("Set victim failed: %v", err) + } + if err := c.Set("late", "new"); err != nil { + t.Fatalf("Set late failed: %v", err) + } + + var registerOnce sync.Once + var lateCalls int64 + c.OnInvalidate("reload", func(trigger string) []string { + registerOnce.Do(func() { + c.OnInvalidate(trigger, func(string) []string { + atomic.AddInt64(&lateCalls, 1) + return []string{"late"} + }) + }) + runtime.Gosched() + return []string{"victim"} + }) + + deleted, err := c.Invalidate("reload") + if err != nil { + t.Fatalf("Invalidate failed: %v", err) + } + if deleted != 1 { + t.Fatalf("expected first invalidation to delete snapshot callback match only, got %d", deleted) + } + if got := atomic.LoadInt64(&lateCalls); got != 0 { + t.Fatalf("newly registered callback should not run in same invalidation pass, got %d calls", got) + } + + deleted, err = c.Invalidate("reload") + if err != nil { + t.Fatalf("second Invalidate failed: %v", err) + } + if deleted != 1 { + t.Fatalf("expected second invalidation to delete late callback match, got %d", deleted) + } + if got := atomic.LoadInt64(&lateCalls); got != 1 { + t.Fatalf("expected late callback to run once on next invalidation pass, got %d calls", got) + } +} + +func TestCache_ThreatTOCTOU_InvalidateConcurrentRegistrationRaceClean(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-threat-invalidate-race", time.Minute) + c.OnInvalidate("reload", func(string) []string { + runtime.Gosched() + return nil + }) + + const workers = 16 + const registrationsPerWorker = 16 + start := make(chan struct{}) + errCh := make(chan error, workers) + + var done sync.WaitGroup + done.Add(workers * 2) + for range workers { + go func() { + defer done.Done() + <-start + for range registrationsPerWorker { + c.OnInvalidate("reload", func(string) []string { + runtime.Gosched() + return nil + }) + } + }() + } + for range workers { + go func() { + defer done.Done() + <-start + for range registrationsPerWorker { + if _, err := c.Invalidate("reload"); err != nil { + errCh <- err + return + } + } + }() + } + + close(start) + done.Wait() + close(errCh) + + for err := range errCh { + t.Errorf("Invalidate failed: %v", err) + } +} + +func TestCache_ThreatTOCTOU_ExpiredGetConcurrentReadersReturnNotFound(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-threat-expired-get", time.Minute) + if err := c.SetWithTTL("ttl/race", map[string]string{"state": "expired"}, time.Nanosecond); err != nil { + t.Fatalf("SetWithTTL failed: %v", err) + } + time.Sleep(2 * time.Millisecond) + + const readers = 64 + start := make(chan struct{}) + errCh := make(chan string, readers) + + var done sync.WaitGroup + done.Add(readers) + for range readers { + go func() { + defer done.Done() + <-start + + got := map[string]string{"state": "sentinel"} + found, err := c.Get("ttl/race", &got) + if err != nil { + errCh <- err.Error() + return + } + if found { + errCh <- "expected expired Get to return found=false" + return + } + if got["state"] != "sentinel" { + errCh <- "expired Get unmarshaled stale data into destination" + } + }() + } + + close(start) + done.Wait() + close(errCh) + + for msg := range errCh { + t.Error(msg) + } +} + +func TestCache_ThreatTOCTOU_ConcurrentSetRandomKeysRaceClean(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-threat-concurrent-random-set", time.Minute) + + const workers = 100 + keys := make([]string, workers) + for i := range workers { + keys[i] = "race/random/" + core.Itoa((i*37+11)%workers) + } + + start := make(chan struct{}) + errCh := make(chan string, workers) + + var done sync.WaitGroup + done.Add(workers) + for i, key := range keys { + go func(value int, key string) { + defer done.Done() + <-start + if err := c.Set(key, map[string]int{"writer": value}); err != nil { + errCh <- err.Error() + } + }(i, key) + } + + close(start) + done.Wait() + close(errCh) + + for msg := range errCh { + t.Error(msg) + } + + foundCount := 0 + for i, key := range keys { + var got map[string]int + found, err := c.Get(key, &got) + if err != nil { + t.Fatalf("Get %q failed: %v", key, err) + } + if !found { + continue + } + foundCount++ + if got["writer"] != i { + t.Fatalf("expected %q writer %d, got %d", key, i, got["writer"]) + } + } + if foundCount != workers { + t.Fatalf("expected %d entries after concurrent Set calls, got %d", workers, foundCount) + } +} + +func TestCache_ThreatTOCTOU_ConcurrentSetSameKeyRaceClean(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-threat-concurrent-same-set", time.Minute) + + const workers = 100 + written := make(map[int]struct{}, workers) + for i := range workers { + written[i] = struct{}{} + } + + start := make(chan struct{}) + errCh := make(chan string, workers) + + var done sync.WaitGroup + done.Add(workers) + for i := range workers { + go func(value int) { + defer done.Done() + <-start + if err := c.Set("race/same", map[string]int{"writer": value}); err != nil { + errCh <- err.Error() + } + }(i) + } + + close(start) + done.Wait() + close(errCh) + + for msg := range errCh { + t.Error(msg) + } + + var got map[string]int + found, err := c.Get("race/same", &got) + if err != nil { + t.Fatalf("final Get failed: %v", err) + } + if !found { + t.Fatal("expected final cache entry to exist") + } + if _, ok := written[got["writer"]]; !ok { + t.Fatalf("final writer %d was not one of the concurrent writers", got["writer"]) + } +} + +func TestCache_ThreatTOCTOU_ConcurrentGetSetDeleteSameKeyRaceClean(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-threat-concurrent-mixed", time.Minute) + if err := c.Set("race/mixed", map[string]int{"writer": -1}); err != nil { + t.Fatalf("initial Set failed: %v", err) + } + + const workers = 100 + const operations = 10 + start := make(chan struct{}) + errCh := make(chan string, workers*operations) + + var done sync.WaitGroup + done.Add(workers) + for i := range workers { + go func(value int) { + defer done.Done() + <-start + for op := range operations { + switch (value + op) % 3 { + case 0: + var got map[string]int + found, err := c.Get("race/mixed", &got) + if err != nil { + errCh <- err.Error() + continue + } + if found && (got["writer"] < -1 || got["writer"] >= workers) { + errCh <- "Get returned a writer outside the written range" + } + case 1: + if err := c.Set("race/mixed", map[string]int{"writer": value}); err != nil { + errCh <- err.Error() + } + default: + if err := c.Delete("race/mixed"); err != nil { + errCh <- err.Error() + } + } + } + }(i) + } + + close(start) + done.Wait() + close(errCh) + + for msg := range errCh { + t.Error(msg) + } +} + +func TestCache_ThreatTOCTOU_GetThenSetSerializesEntryWrites(t *testing.T) { + medium := &raceProbeMedium{MockMedium: coreio.NewMockMedium()} + c, err := cache.New(medium, "/tmp/cache-threat-toctou", time.Minute) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + const workers = 32 + start := make(chan struct{}) + writes := make(chan struct{}) + errCh := make(chan string, workers*3) + + var reads sync.WaitGroup + reads.Add(workers) + var done sync.WaitGroup + done.Add(workers) + + for i := range workers { + go func(value int) { + defer done.Done() + <-start + + var got map[string]int + found, err := c.Get("race/key", &got) + if err != nil { + errCh <- err.Error() + } + if found { + errCh <- "expected initial Get to miss" + } + reads.Done() + + <-writes + if err := c.Set("race/key", map[string]int{"writer": value}); err != nil { + errCh <- err.Error() + } + }(i) + } + + close(start) + reads.Wait() + close(writes) + done.Wait() + close(errCh) + + for msg := range errCh { + t.Error(msg) + } + + var got map[string]int + found, err := c.Get("race/key", &got) + if err != nil { + t.Fatalf("final Get failed: %v", err) + } + if !found { + t.Fatal("expected final cache entry to exist") + } + if medium.probedWrites == 0 { + t.Fatal("expected probe medium to observe writes") + } +} + +type raceProbeMedium struct { + *coreio.MockMedium + probedWrites int +} + +func (m *raceProbeMedium) Write(path, content string) error { + m.probedWrites++ + runtime.Gosched() + m.probedWrites++ + return m.MockMedium.Write(path, content) +} diff --git a/docs/api-contract.md b/docs/api-contract.md index e92dbc2..ac19c4d 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -1,12 +1,12 @@ --- title: API Contract -description: Exported API contract for dappco.re/go/core/cache. +description: Exported API contract for dappco.re/go/cache. --- # API Contract This table lists every exported constant, type, function, and method in -`dappco.re/go/core/cache`. +`dappco.re/go/cache`. `Test coverage` is `yes` when the export is directly exercised by `cache_test.go`. `Usage-example comment` is `yes` only when the symbol has its @@ -14,16 +14,54 @@ own usage example in a doc comment or Go example test. | Name | Signature | Package Path | Description | Test Coverage | Usage-Example Comment | |------|-----------|--------------|-------------|---------------|-----------------------| -| `DefaultTTL` | `const DefaultTTL = 1 * time.Hour` | `dappco.re/go/core/cache` | Default cache expiry time. | no | no | -| `Cache` | `type Cache struct { /* unexported fields */ }` | `dappco.re/go/core/cache` | File-based cache handle. | yes | no | -| `Entry` | `type Entry struct { Data json.RawMessage; CachedAt time.Time; ExpiresAt time.Time }` | `dappco.re/go/core/cache` | Cached item envelope with payload and timestamps. | no | no | -| `New` | `func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error)` | `dappco.re/go/core/cache` | Creates a cache instance, applying default medium, base directory, and TTL when zero-valued inputs are provided. | yes | no | -| `(*Cache).Path` | `func (c *Cache) Path(key string) (string, error)` | `dappco.re/go/core/cache` | Returns the full path for a cache key and rejects path traversal. | yes | no | -| `(*Cache).Get` | `func (c *Cache) Get(key string, dest any) (bool, error)` | `dappco.re/go/core/cache` | Retrieves a cached item if it exists and has not expired. | yes | no | -| `(*Cache).Set` | `func (c *Cache) Set(key string, data any) error` | `dappco.re/go/core/cache` | Stores an item in the cache. | yes | no | -| `(*Cache).Delete` | `func (c *Cache) Delete(key string) error` | `dappco.re/go/core/cache` | Removes an item from the cache. | yes | no | -| `(*Cache).DeleteMany` | `func (c *Cache) DeleteMany(keys ...string) error` | `dappco.re/go/core/cache` | Removes several items from the cache in one call. | yes | no | -| `(*Cache).Clear` | `func (c *Cache) Clear() error` | `dappco.re/go/core/cache` | Removes all cached items. | yes | no | -| `(*Cache).Age` | `func (c *Cache) Age(key string) time.Duration` | `dappco.re/go/core/cache` | Returns how old a cached item is, or `-1` if it is not cached. | yes | no | -| `GitHubReposKey` | `func GitHubReposKey(org string) string` | `dappco.re/go/core/cache` | Returns the cache key for an organization's repo list. | yes | no | -| `GitHubRepoKey` | `func GitHubRepoKey(org, repo string) string` | `dappco.re/go/core/cache` | Returns the cache key for a specific repo's metadata. | yes | no | +| `DefaultTTL` | `const DefaultTTL = 1 * time.Hour` | `dappco.re/go/cache` | Default cache expiry time. | no | no | +| `Cache` | `type Cache struct { /* unexported fields */ }` | `dappco.re/go/cache` | File-based cache handle. | yes | no | +| `Entry` | `type Entry struct { Data json.RawMessage; CachedAt time.Time; ExpiresAt time.Time }` | `dappco.re/go/cache` | Cached item envelope with payload and timestamps. | no | no | +| `BinaryMeta` | `type BinaryMeta struct { ContentType string; Size int64; CachedAt time.Time; ExpiresAt time.Time }` | `dappco.re/go/cache` | Metadata envelope for binary cache payloads. | yes | no | +| `InvalidateFunc` | `type InvalidateFunc func(trigger string) []string` | `dappco.re/go/cache` | Callback signature used for cache invalidation triggers. | no | no | +| `CacheStorage` | `type CacheStorage struct { /* unexported fields */ }` | `dappco.re/go/cache` | Named HTTP cache storage container. | yes | no | +| `HTTPCache` | `type HTTPCache struct { /* unexported fields */ }` | `dappco.re/go/cache` | Request/response cache scoped to a named storage entry. | yes | no | +| `CachedRequest` | `type CachedRequest struct { URL string; Method string }` | `dappco.re/go/cache` | Key used for HTTP cache matching. | yes | no | +| `CachedResponse` | `type CachedResponse struct { Status int; StatusText string; Headers map[string]string; BodyPath string; CachedAt time.Time }` | `dappco.re/go/cache` | Stored HTTP response metadata. | yes | no | +| `New` | `func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error)` | `dappco.re/go/cache` | Creates a cache instance, applying default medium, base directory, and TTL when zero-valued inputs are provided. | yes | no | +| `(*Cache).Path` | `func (c *Cache) Path(key string) (string, error)` | `dappco.re/go/cache` | Returns the full path for a cache key and rejects path traversal. | yes | no | +| `(*Cache).Get` | `func (c *Cache) Get(key string, dest any) (bool, error)` | `dappco.re/go/cache` | Retrieves a cached item if it exists and has not expired. | yes | no | +| `(*Cache).Set` | `func (c *Cache) Set(key string, data any) error` | `dappco.re/go/cache` | Stores an item in the cache. | yes | no | +| `(*Cache).SetWithTTL` | `func (c *Cache) SetWithTTL(key string, data any, ttl time.Duration) error` | `dappco.re/go/cache` | Stores an item using a key-specific TTL. | yes | no | +| `(*Cache).SetBinary` | `func (c *Cache) SetBinary(key string, data []byte, contentType string) error` | `dappco.re/go/cache` | Stores a binary payload with JSON metadata. | yes | no | +| `(*Cache).SetBinaryWithTTL` | `func (c *Cache) SetBinaryWithTTL(key string, data []byte, contentType string, ttl time.Duration) error` | `dappco.re/go/cache` | Stores a binary payload using a key-specific TTL. | yes | no | +| `(*Cache).GetBinary` | `func (c *Cache) GetBinary(key string) ([]byte, bool, error)` | `dappco.re/go/cache` | Retrieves a binary payload if it exists and has not expired. | yes | no | +| `(*Cache).Delete` | `func (c *Cache) Delete(key string) error` | `dappco.re/go/cache` | Removes an item from the cache. | yes | no | +| `(*Cache).DeleteMany` | `func (c *Cache) DeleteMany(keys ...string) error` | `dappco.re/go/cache` | Removes several items from the cache in one call. | yes | no | +| `(*Cache).Clear` | `func (c *Cache) Clear() error` | `dappco.re/go/cache` | Removes all cached items. | yes | no | +| `(*Cache).Age` | `func (c *Cache) Age(key string) time.Duration` | `dappco.re/go/cache` | Returns how old a cached item is, or `-1` if it is not cached. | yes | no | +| `(*Cache).OnInvalidate` | `func (c *Cache) OnInvalidate(trigger string, fn InvalidateFunc)` | `dappco.re/go/cache` | Registers a cache invalidation callback. | yes | no | +| `(*Cache).Invalidate` | `func (c *Cache) Invalidate(trigger string) (int, error)` | `dappco.re/go/cache` | Runs invalidation callbacks and deletes matching entries. | yes | no | +| `(*Cache).Scoped` | `func (c *Cache) Scoped(origin string) *ScopedCache` | `dappco.re/go/cache` | Returns a namespaced cache view for an origin. | yes | no | +| `(*Cache).ClearScope` | `func (c *Cache) ClearScope(origin string) error` | `dappco.re/go/cache` | Removes all entries within an origin scope. | yes | no | +| `ScopedCache` | `type ScopedCache struct { /* unexported fields */ }` | `dappco.re/go/cache` | Origin-scoped cache wrapper. | yes | no | +| `(*ScopedCache).Path` | `func (c *ScopedCache) Path(key string) (string, error)` | `dappco.re/go/cache` | Resolves a scoped cache key to a storage path. | yes | no | +| `(*ScopedCache).Get` | `func (c *ScopedCache) Get(key string, dest any) (bool, error)` | `dappco.re/go/cache` | Retrieves a scoped entry. | yes | no | +| `(*ScopedCache).Set` | `func (c *ScopedCache) Set(key string, value any) error` | `dappco.re/go/cache` | Stores a scoped entry. | yes | no | +| `(*ScopedCache).SetWithTTL` | `func (c *ScopedCache) SetWithTTL(key string, value any, ttl time.Duration) error` | `dappco.re/go/cache` | Stores a scoped entry using a key-specific TTL. | yes | no | +| `(*ScopedCache).SetBinary` | `func (c *ScopedCache) SetBinary(key string, data []byte, contentType string) error` | `dappco.re/go/cache` | Stores a scoped binary payload. | yes | no | +| `(*ScopedCache).SetBinaryWithTTL` | `func (c *ScopedCache) SetBinaryWithTTL(key string, data []byte, contentType string, ttl time.Duration) error` | `dappco.re/go/cache` | Stores a scoped binary payload using a key-specific TTL. | yes | no | +| `(*ScopedCache).GetBinary` | `func (c *ScopedCache) GetBinary(key string) ([]byte, bool, error)` | `dappco.re/go/cache` | Retrieves a scoped binary payload. | yes | no | +| `(*ScopedCache).Delete` | `func (c *ScopedCache) Delete(key string) error` | `dappco.re/go/cache` | Removes a scoped entry. | yes | no | +| `(*ScopedCache).DeleteMany` | `func (c *ScopedCache) DeleteMany(keys ...string) error` | `dappco.re/go/cache` | Removes several scoped entries in one call. | yes | no | +| `(*ScopedCache).Clear` | `func (c *ScopedCache) Clear() error` | `dappco.re/go/cache` | Removes all entries in the scope. | yes | no | +| `(*ScopedCache).OnInvalidate` | `func (c *ScopedCache) OnInvalidate(trigger string, fn InvalidateFunc)` | `dappco.re/go/cache` | Registers a scoped invalidation callback. | yes | no | +| `(*ScopedCache).Invalidate` | `func (c *ScopedCache) Invalidate(trigger string) (int, error)` | `dappco.re/go/cache` | Runs invalidation callbacks for the scope. | yes | no | +| `(*ScopedCache).Age` | `func (c *ScopedCache) Age(key string) time.Duration` | `dappco.re/go/cache` | Returns scoped entry age, or `-1` if missing. | yes | no | +| `NewCacheStorage` | `func NewCacheStorage(medium coreio.Medium, baseDir string) (*CacheStorage, error)` | `dappco.re/go/cache` | Creates a named HTTP cache storage container. | yes | no | +| `(*CacheStorage).Open` | `func (cs *CacheStorage) Open(name string) (*HTTPCache, error)` | `dappco.re/go/cache` | Opens or creates a named HTTP cache. | yes | no | +| `(*CacheStorage).Delete` | `func (cs *CacheStorage) Delete(name string) error` | `dappco.re/go/cache` | Removes a named HTTP cache and its contents. | yes | no | +| `(*CacheStorage).Keys` | `func (cs *CacheStorage) Keys() ([]string, error)` | `dappco.re/go/cache` | Lists all named HTTP caches. | yes | no | +| `(*CacheStorage).Close` | `func (cs *CacheStorage) Close() error` | `dappco.re/go/cache` | Releases storage resources for compatibility. | yes | no | +| `(*HTTPCache).Match` | `func (hc *HTTPCache) Match(req CachedRequest) (*CachedResponse, error)` | `dappco.re/go/cache` | Finds a cached HTTP response by request. | yes | no | +| `(*HTTPCache).Put` | `func (hc *HTTPCache) Put(req CachedRequest, resp CachedResponse, body []byte) error` | `dappco.re/go/cache` | Stores a cached HTTP response and its body. | yes | no | +| `(*HTTPCache).ReadBody` | `func (hc *HTTPCache) ReadBody(resp *CachedResponse) ([]byte, error)` | `dappco.re/go/cache` | Reads a cached HTTP response body. | yes | no | +| `(*HTTPCache).Delete` | `func (hc *HTTPCache) Delete(req CachedRequest) error` | `dappco.re/go/cache` | Removes a cached HTTP request/response pair. | yes | no | +| `(*HTTPCache).Keys` | `func (hc *HTTPCache) Keys() ([]string, error)` | `dappco.re/go/cache` | Lists all cached request URLs. | yes | no | +| `GitHubReposKey` | `func GitHubReposKey(org string) string` | `dappco.re/go/cache` | Returns the cache key for an organization's repo list. | yes | no | +| `GitHubRepoKey` | `func GitHubRepoKey(org, repo string) string` | `dappco.re/go/cache` | Returns the cache key for a specific repo's metadata. | yes | no | diff --git a/docs/architecture.md b/docs/architecture.md index d445611..7a9fd4d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -99,6 +99,10 @@ The resulting file on disc (or equivalent record in another medium) looks like: Parent directories for nested keys (e.g. `github/host-uk/repos`) are created automatically via `medium.EnsureDir()`. +`SetWithTTL` uses the caller-supplied TTL for a single entry. `SetBinary` and +`SetBinaryWithTTL` use the same envelope pattern, but split the payload into a +binary sidecar (`.bin`) plus JSON metadata (`BinaryMeta`). + ### Reading (`Get`) @@ -140,6 +144,10 @@ Key behaviours: missing files, using the same per-key path validation as `Delete()`. - **`Clear()`** calls `medium.DeleteAll(baseDir)`, removing the entire cache directory and all its contents. +- **`Scoped(origin)`** returns a `ScopedCache` that prepends a stable origin + hash to each key, so separate origins never collide. +- **`Invalidate(trigger)`** executes registered callbacks and deletes any keys + matched by the returned glob patterns. ### Age Inspection @@ -149,6 +157,35 @@ If the entry does not exist or cannot be parsed, it returns `-1`. This is useful for diagnostics without triggering the expiry check that `Get` performs. +## Scoped Caches + +`Scoped(origin)` creates a lightweight wrapper around the parent cache. The +wrapper hashes the origin with SHA-1 and uses the result as a fixed namespace +prefix: + +```go +scoped := c.Scoped("https://app.example.com") +_ = scoped.Set("user/profile", profile) +``` + +This gives each origin its own key space while keeping the same underlying +storage medium and TTL behaviour. + + +## HTTP Cache Storage + +`CacheStorage` manages named `HTTPCache` instances. Each named cache stores a +request/response pair using: + +- `CachedRequest{URL, Method}` as the lookup key +- `CachedResponse` JSON metadata for status, headers, and cached time +- a binary body sidecar stored under `responses/.bin` + +`Put` overwrites existing entries for the same request key, `Match` returns +`nil` on a miss, `ReadBody` validates the body path before reading, and `Keys` +returns the unique set of request URLs stored in a named cache. + + ## Key-to-Path Mapping Cache keys are mapped to file paths by appending `.json` and joining with the diff --git a/docs/development.md b/docs/development.md index 841f25a..c0042b2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -168,8 +168,8 @@ the [architecture](architecture.md) document for the full method mapping. ```go import ( - "forge.lthn.ai/core/go-cache" - "forge.lthn.ai/core/go-io/store" + "dappco.re/go/cache" + "dappco.re/go/core/io/store" "time" ) diff --git a/docs/index.md b/docs/index.md index 76cbfe8..4170c7b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,7 +8,7 @@ description: File-based caching with TTL expiry, storage-agnostic via the go-io `go-cache` is a lightweight, storage-agnostic caching library for Go. It stores JSON-serialised entries with automatic TTL expiry and path-traversal protection. -**Module path:** `forge.lthn.ai/core/go-cache` +**Module path:** `dappco.re/go/cache` **Licence:** EUPL-1.2 @@ -20,7 +20,7 @@ import ( "fmt" "time" - "forge.lthn.ai/core/go-cache" + "dappco.re/go/cache" ) func main() { @@ -68,8 +68,7 @@ func main() { | Module | Version | Role | |-------------------------------|---------|---------------------------------------------| -| `forge.lthn.ai/core/go-io` | v0.0.3 | Storage abstraction (`Medium` interface) | -| `forge.lthn.ai/core/go-log` | v0.0.1 | Structured logging (indirect, via `go-io`) | +| `dappco.re/go/core/io` | v0.4.1 | Storage abstraction (`Medium` interface) | There are no other runtime dependencies. The test suite uses the standard library only (plus the `MockMedium` from `go-io`). diff --git a/docs/security-attack-vector-mapping.md b/docs/security-attack-vector-mapping.md index 852902e..a39f36c 100644 --- a/docs/security-attack-vector-mapping.md +++ b/docs/security-attack-vector-mapping.md @@ -1,6 +1,6 @@ # Security Attack Vector Mapping -Scope: `dappco.re/go/core/cache` public API and backend read paths in `cache.go`. This package exposes a library surface only; it has no HTTP handlers or CLI argument parsing in-repo. +Scope: `dappco.re/go/cache` public API and backend read paths in `cache.go`. This package exposes a library surface only; it has no HTTP handlers or CLI argument parsing in-repo. | Function | File:line | Input source | Flows into | Current validation | Potential attack vector | | --- | --- | --- | --- | --- | --- | diff --git a/go.mod b/go.mod index 13e3c91..86f8e6c 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,8 @@ -module dappco.re/go/core/cache +module dappco.re/go/cache go 1.26.0 require ( dappco.re/go/core v0.8.0-alpha.1 - dappco.re/go/core/io v0.2.0 + dappco.re/go/io v0.8.0-alpha.1 ) - -require dappco.re/go/core/log v0.0.4 // indirect diff --git a/go.sum b/go.sum index bfbbbf3..23dc2fe 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,4 @@ dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= -dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= -dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= -dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E= -forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= -forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= diff --git a/tests/cli/cache/Taskfile.yaml b/tests/cli/cache/Taskfile.yaml new file mode 100644 index 0000000..4a40a24 --- /dev/null +++ b/tests/cli/cache/Taskfile.yaml @@ -0,0 +1,9 @@ +version: "3" + +tasks: + default: + dir: ../../.. + cmds: + - go build -o /tmp/go-cache-cli-driver ./tests/cli/cache + - /tmp/go-cache-cli-driver + - rm -f /tmp/go-cache-cli-driver diff --git a/tests/cli/cache/main.go b/tests/cli/cache/main.go new file mode 100644 index 0000000..488ce53 --- /dev/null +++ b/tests/cli/cache/main.go @@ -0,0 +1,39 @@ +// AX-10 CLI driver for go-cache. Exercises cache.New + Set + Get round-trip +// against an in-memory Medium and exits non-zero on any mismatch. +// +// task -d tests/cli/cache +// go run ./tests/cli/cache +package main + +import ( + "os" + + "dappco.re/go/cache" + coreio "dappco.re/go/io" +) + +func main() { + medium := coreio.NewMockMedium() + + c, err := cache.New(medium, "/cache", cache.DefaultTTL) + if err != nil { + os.Exit(1) + } + + payload := map[string]string{"hello": "world"} + if err := c.Set("driver/roundtrip", payload); err != nil { + os.Exit(2) + } + + var out map[string]string + found, err := c.Get("driver/roundtrip", &out) + if err != nil { + os.Exit(3) + } + if !found { + os.Exit(4) + } + if out["hello"] != "world" { + os.Exit(5) + } +} diff --git a/threats.md b/threats.md new file mode 100644 index 0000000..fe00225 --- /dev/null +++ b/threats.md @@ -0,0 +1,111 @@ +# go-cache threat-model audit + +Audit-by: Cerberus (via codex) +Repo: dappco.re/go/cache +Date: 2026-04-25 + +## 1. Untrusted-key DoS + +Status: Complete + +Question: Are key lengths bounded on the externally reachable write paths? + +Finding: Yes. `Cache.Path` validates every key with `ensureSafeKey` before constructing storage paths (`cache.go:131`, `cache.go:136`). That helper rejects empty keys, keys longer than 4096 bytes, backslashes, control bytes, empty path segments, `.`, and `..` (`cache.go:743`, `cache.go:747`, `cache.go:750`, `cache.go:753`, `cache.go:757`). `Set`, `SetWithTTL`, `SetBinary`, and `SetBinaryWithTTL` all route through `entryPaths` and therefore through `Path` before writing (`cache.go:207`, `cache.go:218`, `cache.go:225`, `cache.go:230`, `cache.go:324`, `cache.go:335`, `cache.go:342`, `cache.go:346`). Regression coverage: `TestCache_ThreatUntrustedKeyDoS_RejectsOversizedKeysOnWritePaths` (`cache_test.go:2487`). + +Severity: None for overlong single-key path or memory amplification via key string on those write paths. + +Question: Can a flood of unique valid keys cause unbounded growth? + +Finding: Yes, for storage growth inside the configured cache root. Cache entries are persisted via the configured `coreio.Medium`, and there is no entry-count or byte quota before `medium.Write` in JSON or binary writes (`cache.go:268`, `cache.go:384`, `cache.go:390`). The ordinary cache does not keep cached values in a Go map; the in-memory maps are invalidation callbacks and opened HTTP cache handles (`cache.go:48`, `cache.go:978`). A downstream consumer that forwards attacker-controlled unique valid keys can therefore grow files/inodes within `baseDir` until the backing medium or host quota stops it. + +Severity: Medium. This is bounded to the configured cache root and by the underlying storage backend, but the package does not provide a built-in quota/eviction policy. No code fix was applied because adding a default global entry cap would change cache semantics and there is no existing public configuration surface for quotas in this ticket scope. + +Question: Does `Invalidate` accept callback-returned glob patterns without a length backstop before `keysByPattern` lists and matches all cache keys? + +Finding: Yes (prior-pass finding, retained). Validate invalidation patterns with a fixed byte limit before listing cache entries. + +Severity: Medium. + +Repro test: `TestCache_Invalidate_UntrustedPatternLength_Bad`. + +## 2. Path traversal + +Status: Complete + +Question: Do disk paths derive from raw keys without sanitisation? + +Finding: No for the core cache. `Path` validates the key, joins `baseDir` with `key + ".json"`, normalizes to an absolute path, and rejects paths outside the cache root prefix (`cache.go:136`, `cache.go:140`, `cache.go:141`, `cache.go:144`). Binary sidecar paths use the same validated key through `entryPaths` before writes (`cache.go:154`, `cache.go:155`, `cache.go:161`). + +Severity: None for direct `../`, absolute path, control-byte, or backslash traversal through `Set`, `SetWithTTL`, `SetBinary`, `Get`, `Delete`, and related key-based operations. + +Question: Do CacheStorage or HTTPCache paths derive from untrusted names or request URLs? + +Finding: No direct traversal found. `CacheStorage.Open` and `CacheStorage.Delete` validate cache names before joining them under the storage base directory (`cache.go:1015`, `cache.go:1019`, `cache.go:1029`, `cache.go:1047`, `cache.go:1051`, `cache.go:1057`). The validator rejects empty names, names over 255 bytes, `/`, `\`, control bytes, `.`, and `..` (`cache.go:1066`, `cache.go:1070`, `cache.go:1073`, `cache.go:1076`, `cache.go:1079`). `HTTPCache` stores request metadata under SHA-256 hex request keys rather than raw URLs (`cache.go:1215`, `cache.go:1452`, `cache.go:1457`), and cached response body reads validate that `BodyPath` is a relative `responses/.bin` path with safe segments (`cache.go:1407`, `cache.go:1410`, `cache.go:775`, `cache.go:789`, `cache.go:800`). + +Severity: None for reviewed raw-name and raw-URL path traversal. + +Question: Does ScopedCache origin namespacing allow path injection? + +Finding: No. Scope prefixes are `scope_` plus a SHA-256 hex digest of the origin string, so raw origins are not embedded in file paths (`cache.go:711`, `cache.go:714`, `cache.go:814`, `cache.go:815`, `cache.go:817`). Scoped keys are prefixed and then passed back through the parent cache validation and path containment checks (`cache.go:820`, `cache.go:835`, `cache.go:839`). Regression coverage: `TestCache_ThreatPathTraversal_ScopedOriginIsHashedAndKeysStillValidated` (`cache_test.go:2534`). + +Severity: None for origin-derived path traversal. + +Question: Do HTTPCache request URLs become path components? + +Finding: No. HTTP request storage keys are SHA-256 hex digests of `method + NUL + URL` (`cache.go:1215`, `cache.go:1452`, `cache.go:1457`), and `Put` writes metadata/body under `responses/.json` and `responses/.bin` (`cache.go:1343`, `cache.go:1347`, `cache.go:1362`, `cache.go:1363`, `cache.go:1383`, `cache.go:1388`). Regression coverage: `TestCache_ThreatPathTraversal_HTTPCacheUsesHashedRequestStorageKeys` (`cache_test.go:2557`). + +Severity: None for raw-URL path traversal on the reviewed HTTPCache write path. + +Question (prior pass): Symlink-following inside the cache root? + +Finding: A symlinked directory or file already under `baseDir` could redirect an otherwise safe key outside the cache root. Reject existing symlink components from the cache root through the resolved cache path before returning paths for filesystem use. + +Severity: high. + +Repro test: `TestCache_Path_PathTraversalSymlink_Bad`. + +## 3. Eviction TOCTOU + +Status: Complete + +### 3.1 Invalidate map walk / OnInvalidate registration + +Status: Complete + +Question: What lock is held while `Invalidate` walks callbacks, and can `OnInvalidate` append to the same trigger while that walk is in progress? + +Finding: No map-walk race found. `Cache.mu` is the lock protecting the invalidation callback map (`cache.go:56`). `OnInvalidate` takes the write lock before appending to `cache.invalidation[trigger]` (`cache.go:818`, `cache.go:820`). `Invalidate` takes the read lock only long enough to copy the trigger's callback slice, then releases the lock before executing callbacks and deleting entries (`cache.go:831`, `cache.go:832`, `cache.go:833`, `cache.go:835`). A callback that registers more invalidations therefore cannot mutate the map while it is being read, and it does not deadlock by trying to acquire the write lock from inside the callback. The newly registered callback is not included in the already-snapshotted invalidation pass, which is acceptable snapshot semantics. No `delete(cache.invalidation, trigger)` call exists in the reviewed cache implementation. + +Severity: None. + +Repro test: `TestCache_ThreatTOCTOU_InvalidateOnInvalidateRegistrationIsSnapshotRaceClean` and `TestCache_ThreatTOCTOU_InvalidateConcurrentRegistrationRaceClean` (`cache_test.go:2688`, `cache_test.go:2734`). + +Fix: No code change required. The existing callback snapshot under `Cache.mu` is the intended mitigation; the added tests pin the race-clean and snapshot semantics. + +### 3.2 TTL expiry race on Get + +Status: Complete + +Question: Can two concurrent readers of a freshly expired entry return expired data, or does one reader delete/alter state out from under the other? + +Finding: No unsafe TTL expiry race found. `Get` reads the entry under the entry read lock, unmarshals the cache envelope, checks `time.Now().After(entry.ExpiresAt)`, and returns `found=false` before unmarshalling cached data into the caller's destination (`cache.go:300`, `cache.go:317`, `cache.go:322`, `cache.go:323`, `cache.go:326`). `GetBinary` follows the same metadata-first expiry check and returns `found=false` before reading the payload body (`cache.go:545`, `cache.go:562`, `cache.go:567`, `cache.go:568`, `cache.go:571`). Expired reads do not delete files, so two readers can both lose and safely return not-found; neither path returns expired data after observing the expiry check. + +Severity: None. + +Repro test: `TestCache_ThreatTOCTOU_ExpiredGetConcurrentReadersReturnNotFound` (`cache_test.go:2782`). + +Fix: No code change required. The current metadata-first expiry check and non-mutating expired-read behavior are safe for concurrent readers. + +### 3.3 Get-then-Set caller-site TOCTOU + +Status: Complete + +Question: If two consumers both observe `Get` as missing or expired and then both call `Set`, does `Cache.mu` serialize the writes, or is this just last-writer-wins cache behavior? + +Finding: Yes, fixed. `Cache.mu` protects invalidation callback registration and snapshotting, while `entryMu` serializes cache entry I/O separately (`cache.go:56`, `cache.go:57`). `Get` and `GetBinary` take `entryMu.RLock` while reading entries (`cache.go:300`, `cache.go:545`). `Set` and `SetBinary` take `entryMu.Lock` across path resolution, rollback snapshot, and writes (`cache.go:360`, `cache.go:368`, `cache.go:401`, `cache.go:482`, `cache.go:490`, `cache.go:494`, `cache.go:523`, `cache.go:529`). Delete paths are also serialized: single-key removal locks before deleting metadata and binary sidecars, `DeleteMany` locks across its batch, and invalidation pattern listing takes the entry read lock while walking keys (`cache.go:428`, `cache.go:431`, `cache.go:591`, `cache.go:667`, `cache.go:672`, `cache.go:675`). Pure cache freshness remains last-writer-wins, but callers no longer need the backing `coreio.Medium` to tolerate overlapping entry operations. + +Severity: Medium before fix; mitigated by entry-level serialization. + +Repro test: `TestCache_ThreatTOCTOU_GetThenSetSerializesEntryWrites` (`cache_test.go:2825`). + +Fix: Use `entryMu` for cache entry I/O so concurrent caller-side `Get`-then-`Set` misses cannot overlap backing-medium writes. This preserves last-writer-wins cache semantics while removing the lower-level I/O race.