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..c1ad04e 100644 --- a/cache.go +++ b/cache.go @@ -4,12 +4,20 @@ package cache import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" "encoding/json" "io/fs" + "net/url" + "os" + "slices" + "strings" + "sync" "time" "dappco.re/go/core" - coreio "dappco.re/go/core/io" + coreio "dappco.re/go/io" ) // DefaultTTL is the default cache expiry time. @@ -19,25 +27,69 @@ 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 - ttl time.Duration + medium coreio.Medium + baseDir string + cacheTTL time.Duration + invalidation map[string][]InvalidateFunc + mu sync.RWMutex } // 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 json.RawMessage `json:"data"` CachedAt time.Time `json:"cached_at"` ExpiresAt time.Time `json:"expires_at"` } -// New creates a cache and applies default Medium, base directory, and TTL values -// when callers pass zero values. +// 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", time.Hour) -func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error) { +// 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 } @@ -53,12 +105,12 @@ func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error baseDir = absolutePath(baseDir) } - if ttl < 0 { + if cacheTTL < 0 { return nil, core.E("cache.New", "ttl must be >= 0", nil) } - if ttl == 0 { - ttl = DefaultTTL + if cacheTTL == 0 { + cacheTTL = DefaultTTL } if err := medium.EnsureDir(baseDir); err != nil { @@ -66,46 +118,68 @@ func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error } return &Cache{ - medium: medium, - baseDir: baseDir, - ttl: ttl, + medium: medium, + baseDir: baseDir, + cacheTTL: cacheTTL, + invalidation: make(map[string][]InvalidateFunc), }, nil } -// Path returns the storage path used for key and rejects path traversal -// attempts. +// Path resolves the on-disk JSON path for a cache key. // // path, err := c.Path("github/acme/repos") -func (c *Cache) Path(key string) (string, error) { - if err := c.ensureConfigured("cache.Path"); err != nil { +// // => /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(c.baseDir) + 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 (c *Cache) Get(key string, dest any) (bool, error) { - if err := c.ensureReady("cache.Get"); err != nil { +func (cache *Cache) Get(key string, dest any) (bool, error) { + if err := cache.ensureReady("cache.Get"); err != nil { return false, err } - path, err := c.Path(key) + path, err := cache.Path(key) if err != nil { return false, err } - dataStr, err := c.medium.Read(path) + dataStr, err := cache.medium.Read(path) if err != nil { if core.Is(err, fs.ErrNotExist) { return false, nil @@ -116,7 +190,7 @@ func (c *Cache) Get(key string, dest any) (bool, error) { var entry Entry entryResult := core.JSONUnmarshalString(dataStr, &entry) if !entryResult.OK { - return false, nil + return false, core.E("cache.Get", "failed to unmarshal cache entry", entryResult.Value.(error)) } if time.Now().After(entry.ExpiresAt) { @@ -130,20 +204,44 @@ func (c *Cache) Get(key string, dest any) (bool, error) { return true, nil } -// Set marshals data and stores it in the cache. +// Set stores a value using the cache's default TTL. // // err := c.Set("github/acme/repos", repos) -func (c *Cache) Set(key string, data any) error { - if err := c.ensureReady("cache.Set"); err != nil { +// 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) +} - path, err := c.Path(key) +// 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 + } + + path, _, err := cache.entryPaths(key) if err != nil { return err } - if err := c.medium.EnsureDir(core.PathDir(path)); err != nil { + 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) } @@ -152,18 +250,18 @@ func (c *Cache) Set(key string, data any) error { return core.E("cache.Set", "failed to marshal cache data", dataResult.Value.(error)) } - ttl := c.ttl if ttl < 0 { - return core.E("cache.Set", "cache ttl must be >= 0", nil) + return core.E("cache.set", "cache ttl must be >= 0", nil) } - if ttl == 0 { - ttl = DefaultTTL + if ttl == 0 && useDefaultTTL { + ttl = cache.defaultTTL() } + now := time.Now() entry := Entry{ Data: dataResult.Value.([]byte), - CachedAt: time.Now(), - ExpiresAt: time.Now().Add(ttl), + CachedAt: now, + ExpiresAt: now.Add(ttl), } entryBytes, err := json.MarshalIndent(entry, "", " ") @@ -171,152 +269,1556 @@ func (c *Cache) Set(key string, data any) error { return core.E("cache.Set", "failed to marshal cache entry", err) } - if err := c.medium.Write(path, string(entryBytes)); err != nil { - return core.E("cache.Set", "failed to write cache file", err) + if err := cache.medium.Write(path, string(entryBytes)); err != nil { + _ = restoreFileSnapshot(cache.medium, snapshot) + return core.E("cache.set", "failed to write cache file", err) } return nil } -// Delete removes the cached item for key. +// Delete removes one cached entry. // // err := c.Delete("github/acme/repos") -func (c *Cache) Delete(key string) error { - if err := c.ensureReady("cache.Delete"); err != nil { +func (cache *Cache) Delete(key string) error { + if err := cache.ensureReady("cache.Delete"); err != nil { return err } - path, err := c.Path(key) + _, 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 + } + 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) +} - err = c.medium.Delete(path) - if core.Is(err, fs.ErrNotExist) { - return nil +// 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 + } + 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), } + + metaBytes, err := json.MarshalIndent(meta, "", " ") if err != nil { - return core.E("cache.Delete", "failed to delete cache file", err) + 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, string(metaBytes)); err != nil { + _ = restoreFileSnapshot(cache.medium, binarySnapshot) + _ = restoreFileSnapshot(cache.medium, jsonSnapshot) + return core.E("cache.setBinary", "failed to write binary metadata", err) + } + return nil } -// DeleteMany removes several cached items in one call. +// 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 + } + 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") -func (c *Cache) DeleteMany(keys ...string) error { - if err := c.ensureReady("cache.DeleteMany"); err != nil { +// 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 } + type entryFileSet struct { + jsonPath string + binaryPath string + } + + resolved := make([]entryFileSet, 0, len(keys)) for _, key := range keys { - path, err := c.Path(key) + jsonPath, binaryPath, err := cache.entryPaths(key) if err != nil { return err } + resolved = append(resolved, entryFileSet{jsonPath: jsonPath, binaryPath: binaryPath}) + } - err = c.medium.Delete(path) - if core.Is(err, fs.ErrNotExist) { - continue + for _, paths := range resolved { + if err := cache.medium.Delete(paths.jsonPath); err != nil && !core.Is(err, fs.ErrNotExist) { + return err } - if err != nil { - return core.E("cache.DeleteMany", "failed to delete cache file", err) + if err := cache.medium.Delete(paths.binaryPath); err != nil && !core.Is(err, fs.ErrNotExist) { + return err } } return nil } -// Clear removes all cached items under the cache base directory. -// -// err := c.Clear() -func (c *Cache) Clear() error { - if err := c.ensureReady("cache.Clear"); err != nil { - return err +func (cache *Cache) listJSONKeys() ([]string, error) { + keys, err := cache.collectJSONKeys("") + if err != nil { + return nil, err } + slices.Sort(keys) + return keys, nil +} - if err := c.medium.DeleteAll(c.baseDir); err != nil { - return core.E("cache.Clear", "failed to clear cache", err) +func (cache *Cache) collectJSONKeys(prefix string) ([]string, error) { + listPath := cache.baseDir + if prefix != "" { + listPath = core.JoinPath(cache.baseDir, prefix) } - return nil + + 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 } -// Age reports how long ago key was cached, or -1 if it is missing or unreadable. -// -// age := c.Age("github/acme/repos") -func (c *Cache) Age(key string) time.Duration { - if err := c.ensureReady("cache.Age"); err != nil { - return -1 +func (cache *Cache) keysByPattern(pattern string) ([]string, error) { + if err := ensureSafePattern(pattern); err != nil { + return nil, err } - path, err := c.Path(key) + allKeys, err := cache.listJSONKeys() if err != nil { - return -1 + return nil, err } - dataStr, err := c.medium.Read(path) + 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 -1 + return err + } + descendants, err := cache.keysByPattern(prefix + "/*") + if err != nil { + return err } + keys = append(keys, descendants...) - var entry Entry - entryResult := core.JSONUnmarshalString(dataStr, &entry) - if !entryResult.OK { - return -1 + for _, key := range keys { + if _, err := cache.removeEntryFiles(key); err != nil { + return err + } } - return time.Since(entry.CachedAt) + return nil } -// GitHub-specific cache keys +// 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 + } -// GitHubReposKey returns the cache key used for an organisation's repo list. + // 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. // -// key := cache.GitHubReposKey("acme") -func GitHubReposKey(org string) string { - return core.JoinPath("github", org, "repos") +// containsAnyGlob("dns/*") // true +// containsAnyGlob("exact") // false +func containsAnyGlob(s string) bool { + for _, r := range s { + if r == '*' || r == '?' || r == '[' || r == ']' { + return true + } + } + return false } -// GitHubRepoKey returns the cache key used for a repository metadata entry. +// segmentMatch matches pattern against name within a single path segment. +// Supports '*' (any run of non-separator chars) and literal characters. // -// key := cache.GitHubRepoKey("acme", "widgets") -func GitHubRepoKey(org, repo string) string { - return core.JoinPath("github", org, repo, "meta") +// 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 } -func pathSeparator() string { - if ds := core.Env("DS"); ds != "" { - return ds +// 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 } + cache.mu.Lock() + defer cache.mu.Unlock() + cache.invalidation[trigger] = append(cache.invalidation[trigger], fn) +} - return "/" +// 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 + } + + cache.mu.RLock() + callbacks := append([]InvalidateFunc(nil), cache.invalidation[trigger]...) + cache.mu.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 } -func normalizePath(path string) string { - ds := pathSeparator() - normalized := core.Replace(path, "\\", ds) +// 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), + } +} - if ds != "/" { - normalized = core.Replace(normalized, "/", ds) +// 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 } - return core.CleanPath(normalized, ds) + prefix := scopePrefix(origin) + if err := ensureSafeKey(prefix); err != nil { + return err + } + return cache.clearScope(prefix) } -func absolutePath(path string) string { - normalized := normalizePath(path) - if core.PathIsAbs(normalized) { - return normalized +func (cache *Cache) defaultTTL() time.Duration { + if cache.cacheTTL <= 0 { + return DefaultTTL } + return cache.cacheTTL +} - cwd := currentDir() - if cwd == "" || cwd == "." { - return normalized +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) } - return normalizePath(core.JoinPath(cwd, normalized)) + for _, part := range core.Split(key, "/") { + if part == "" || part == "." || part == ".." { + return core.E("cache.validateKey", "invalid key: path traversal attempt", nil) + } + } + + return nil } -func currentDir() string { +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 os.IsNotExist(err) { + return nil + } + return err + } + if info.Mode()&os.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 { + sum := sha256.Sum256([]byte(origin)) + hash := hex.EncodeToString(sum[:]) + return "scope_" + hash +} + +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 = strings.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 + mu sync.RWMutex +} + +// 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), + }, 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 + } + + storage.mu.Lock() + defer storage.mu.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 + } + + storage.mu.Lock() + defer storage.mu.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 + } + + storage.mu.RLock() + names := make(map[string]struct{}, len(storage.caches)) + for name := range storage.caches { + names[name] = struct{}{} + } + storage.mu.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 + } + storage.mu.Lock() + storage.caches = make(map[string]*HTTPCache) + storage.mu.Unlock() + 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 +} + +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) + } + storage.mu.Lock() + if storage.caches == nil { + storage.caches = make(map[string]*HTTPCache) + } + storage.mu.Unlock() + return nil +} + +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 +} + +// 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"` +} + +// 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"` +} + +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 base64.RawURLEncoding.EncodeToString([]byte(req.Method + "\x00" + req.URL)) +} + +func decodeRequestKey(encoded string) (CachedRequest, error) { + raw, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + 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) + } + + 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 nil, nil + } + return nil, core.E("cache.HTTPCache.readResponseRecord", "failed to read cached response", err) + } + + var envelope map[string]json.RawMessage + envelopeResult := core.JSONUnmarshalString(raw, &envelope) + if !envelopeResult.OK { + return nil, core.E("cache.HTTPCache.readResponseRecord", "failed to unmarshal cached response", envelopeResult.Value.(error)) + } + + _, 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 + } + + 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)) + } + + 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 +} + +// Match finds a cached response for request. +// +// 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 + } + + 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 := httpCache.medium.EnsureDir(httpCache.storagePath("responses")); err != nil { + return core.E("cache.HTTPCache.Put", "failed to create response directory", err) + } + + 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) + } + + resp.CachedAt = time.Now() + record := cachedResponseRecord{ + Request: req, + Response: resp, + } + meta, err := json.MarshalIndent(record, "", " ") + if err != nil { + return core.E("cache.HTTPCache.Put", "failed to marshal cached response", err) + } + + 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, string(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 +} + +// 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 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) + } + + sum := sha256.Sum256([]byte(req.Method + "\x00" + req.URL)) + return hex.EncodeToString(sum[:]), 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 +} + +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 +} + +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 := 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) + } + + key, err := httpCache.requestKey(req) + if err != nil { + return err + } + + 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 := 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 +} + +// Keys returns all cached request URLs. +// +// 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 + } + + 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) + } + + 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 { + 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) + } + + 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. +// +// err := c.Clear() +func (c *Cache) Clear() error { + if err := c.ensureReady("cache.Clear"); err != nil { + return err + } + + if err := c.medium.DeleteAll(c.baseDir); err != nil { + return core.E("cache.Clear", "failed to clear cache", err) + } + return nil +} + +// Age reports how long ago key was cached, or -1 if it is missing or unreadable. +// +// age := c.Age("github/acme/repos") +func (c *Cache) Age(key string) time.Duration { + if err := c.ensureReady("cache.Age"); err != nil { + return -1 + } + + path, err := c.Path(key) + if err != nil { + return -1 + } + + dataStr, err := c.medium.Read(path) + if err != nil { + return -1 + } + + var entry Entry + entryResult := core.JSONUnmarshalString(dataStr, &entry) + if !entryResult.OK { + return -1 + } + + return time.Since(entry.CachedAt) +} + +// GitHub-specific cache keys + +// GitHubReposKey returns the cache key used for an organisation's repo list. +// +// key := cache.GitHubReposKey("acme") +func GitHubReposKey(org string) string { + 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", encodePathSegment(org), encodePathSegment(repo), "meta") +} + +func encodePathSegment(segment string) string { + return url.PathEscape(segment) +} + +func pathSeparator() string { + if ds := core.Env("DS"); ds != "" { + return ds + } + + return "/" +} + +func normalizePath(path string) string { + ds := pathSeparator() + normalized := core.Replace(path, "\\", ds) + + if ds != "/" { + normalized = core.Replace(normalized, "/", ds) + } + + return core.CleanPath(normalized, ds) +} + +func absolutePath(path string) string { + normalized := normalizePath(path) + if core.PathIsAbs(normalized) { + return normalized + } + + cwd := currentDir() + if cwd == "" || cwd == "." { + return normalized + } + + return normalizePath(core.JoinPath(cwd, normalized)) +} + +func currentDir() string { + if cwd, err := os.Getwd(); err == nil && cwd != "" { + return normalizePath(cwd) + } + cwd := normalizePath(core.Env("PWD")) if cwd != "" && cwd != "." { return cwd diff --git a/cache_test.go b/cache_test.go index f6b1922..9afb906 100644 --- a/cache_test.go +++ b/cache_test.go @@ -3,15 +3,93 @@ package cache_test import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "io/fs" + "os" "strings" "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 +114,20 @@ 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 TestCache_New_Good(t *testing.T) { tmpDir := t.TempDir() t.Chdir(tmpDir) + t.Setenv("PWD", "") + t.Setenv("DIR_CWD", "") c, m := newTestCache(t, "", 0) @@ -52,7 +141,11 @@ func TestCache_New_Good(t *testing.T) { t.Fatalf("Path failed: %v", err) } - wantPath := core.JoinPath(tmpDir, ".core", "cache", key+".json") + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd failed: %v", err) + } + wantPath := core.JoinPath(cwd, ".core", "cache", key+".json") if path != wantPath { t.Fatalf("expected default path %q, got %q", wantPath, path) } @@ -79,6 +172,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"] = errors.New("boom") + + 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 := t.TempDir() + 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 := os.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"] = errors.New("boom") + + 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 +252,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: strings.Repeat("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 := os.MkdirAll(baseDir, 0o755); err != nil { + t.Fatalf("MkdirAll base failed: %v", err) + } + if err := os.MkdirAll(outsideDir, 0o755); err != nil { + t.Fatalf("MkdirAll 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 := os.Stat(core.JoinPath(outsideDir, "escaped.json")); err == nil { + t.Fatal("expected escaped file not to be written outside baseDir") + } else if !os.IsNotExist(err) { + t.Fatalf("Stat outside file failed: %v", err) } } @@ -144,6 +349,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 +409,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 +442,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 +480,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 +524,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] = errors.New("boom") + + 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 +591,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 +647,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"] = errors.New("boom") + + 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"] = errors.New("boom") + + 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 +682,1867 @@ 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 := strings.TrimSuffix(jsonPath, ".json") + ".bin" + medium.writeErr[jsonPath] = errors.New("metadata boom") + + 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 := strings.TrimSuffix(jsonPath, ".json") + ".bin" + medium.writeErr[binPath] = errors.New("payload boom") + + 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 := strings.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{strings.Repeat("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 !strings.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(strings.Repeat("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 strings.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", strings.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", strings.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/" + strings.Repeat("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"] = errors.New("boom") + + 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"] = errors.New("boom") + + 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", strings.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"] = errors.New("boom") + + 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/" + strings.Repeat("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] = errors.New("boom") + + 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/" + strings.Repeat("a", 8193), + Method: "GET", + }, + }, + { + name: "method-too-long", + req: cache.CachedRequest{ + URL: "https://example.com/style.css", + Method: strings.Repeat("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: strings.Repeat("O", 1025)}, + }, + { + name: "header-name-too-long", + resp: cache.CachedResponse{ + Status: 200, + StatusText: "OK", + Headers: map[string]string{strings.Repeat("X", 257): "value"}, + }, + }, + { + name: "header-value-too-long", + resp: cache.CachedResponse{ + Status: 200, + StatusText: "OK", + Headers: map[string]string{"Content-Type": strings.Repeat("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] = errors.New("metadata boom") + + 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") + } +} 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..31bd0d3 --- /dev/null +++ b/threats.md @@ -0,0 +1,26 @@ +# go-cache threat-model audit + +Audit-by: Cerberus (via codex) +Repo: dappco.re/go/cache +Date: 2026-04-25 + +## 1. Untrusted-key DoS +**Question:** Does every public method (Set, Get, Delete, SetWithTTL, SetBinary, SetBinaryWithTTL, GetBinary, DeleteMany, Clear, ClearScope, Path, Scoped, OnInvalidate, Invalidate) reach `ensureSafeKey` before touching the filesystem? Are there backstops on key length, ScopedCache prefix escape, glob-pattern blowup? +**Finding:** YES - cache keys are bounded and public key-taking methods route through `Path`/`entryPaths` before filesystem access, and scoped prefixes are hashed plus revalidated. However, `Invalidate` accepted callback-returned glob patterns without a length backstop before `keysByPattern` listed and matched all cache keys. +**Severity:** medium +**Repro test:** TestCache_Invalidate_UntrustedPatternLength_Bad +**Fix:** validate invalidation patterns with a fixed byte limit before listing cache entries. + +## 2. Path traversal +**Question:** What bytes does `hasPathDangerousBytes` cover (null byte, .., leading / or ~, control chars, URL-encoded %2e%2e)? Does symlink-following escape baseDir? Is `ensureSafeResponseBodyPath` rigour applied to JSON entry paths too? +**Finding:** YES - `ensureSafeKey` rejects empty keys, `..` segments, leading `/` via empty segments, backslashes, null/control bytes, and overlong keys; URL-encoded `%2e%2e` remains a literal safe filename segment. `ensureSafeResponseBodyPath` is applied to cached HTTP response metadata on read and write. The gap was local symlink following: a symlinked directory or file already under `baseDir` could redirect an otherwise safe key outside the cache root. +**Severity:** high +**Repro test:** TestCache_Path_PathTraversalSymlink_Bad +**Fix:** reject existing symlink components from the cache root through the resolved cache path before returning paths for filesystem use. + +## 3. Eviction / TOCTOU +**Question:** Is `Cache.mu` (RWMutex) discipline correct? Does `Invalidate` walk the `invalidation` map race-cleanly while `OnInvalidate` may register more? On TTL expiry, do concurrent readers race? +**Finding:** TBD +**Severity:** TBD +**Repro test:** TBD +**Fix:** TBD