From faec0f2ec58c77ec7558fc0d1323f66520749225 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 7 Apr 2026 14:58:45 +0100 Subject: [PATCH 1/5] fix: upgrade io and log deps to dappco.re versions, tidy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade io v0.2.0→v0.4.1, log v0.0.4→v0.1.2 (forge path → dappco.re). Co-Authored-By: Virgil --- go.mod | 4 +--- go.sum | 6 ++---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 13e3c91..d334364 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,5 @@ 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/core/io v0.4.1 ) - -require dappco.re/go/core/log v0.0.4 // indirect diff --git a/go.sum b/go.sum index bfbbbf3..4c5e2f3 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,7 @@ 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= +dappco.re/go/core/io v0.4.1 h1:15dm7ldhFIAuZOrBiQG6XVZDpSvCxtZsUXApwTAB3wQ= +dappco.re/go/core/io v0.4.1/go.mod h1:w71dukyunczLb8frT9JOd5B78PjwWQD3YAXiCt3AcPA= 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= From ffda5b4788f082e59692605ff27aa9a9f528f076 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 8 Apr 2026 16:33:09 +0100 Subject: [PATCH 2/5] fix(cache): update go-io dep for MockMedium support Co-Authored-By: Virgil --- go.mod | 2 ++ go.sum | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index d334364..34a38dc 100644 --- a/go.mod +++ b/go.mod @@ -6,3 +6,5 @@ require ( dappco.re/go/core v0.8.0-alpha.1 dappco.re/go/core/io v0.4.1 ) + +replace dappco.re/go/core/io => ../go-io diff --git a/go.sum b/go.sum index 4c5e2f3..12069ab 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ 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.4.1 h1:15dm7ldhFIAuZOrBiQG6XVZDpSvCxtZsUXApwTAB3wQ= -dappco.re/go/core/io v0.4.1/go.mod h1:w71dukyunczLb8frT9JOd5B78PjwWQD3YAXiCt3AcPA= 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= From e5b69fc37eb5c28b9b5ef9c1d5f85d77e23e568c Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 13 Apr 2026 09:32:01 +0100 Subject: [PATCH 3/5] =?UTF-8?q?refactor:=20AX=20compliance=20sweep=20?= =?UTF-8?q?=E2=80=94=20replace=20banned=20stdlib=20imports=20with=20core?= =?UTF-8?q?=20primitives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced fmt, strings, sort, os, io, sync, encoding/json, path/filepath, errors, log, reflect with core.Sprintf, core.E, core.Contains, core.Trim, core.Split, core.Join, core.JoinPath, slices.Sort, c.Fs(), c.Lock(), core.JSONMarshal, core.ReadAll and other CoreGO v0.8.0 primitives. Framework boundary exceptions preserved where stdlib types are required by external interfaces (Gin, net/http, CGo, Wails, bubbletea). Co-Authored-By: Virgil --- cache.go | 6 +++--- go.sum | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cache.go b/cache.go index f5c366c..64bb979 100644 --- a/cache.go +++ b/cache.go @@ -4,12 +4,12 @@ package cache import ( - "encoding/json" "io/fs" "time" "dappco.re/go/core" coreio "dappco.re/go/core/io" + "dappco.re/go/core/store" ) // DefaultTTL is the default cache expiry time. @@ -28,7 +28,7 @@ type Cache struct { // Entry is the serialized cache record written to the backing Medium. type Entry struct { - Data json.RawMessage `json:"data"` + Data store.RawMessage `json:"data"` CachedAt time.Time `json:"cached_at"` ExpiresAt time.Time `json:"expires_at"` } @@ -166,7 +166,7 @@ func (c *Cache) Set(key string, data any) error { ExpiresAt: time.Now().Add(ttl), } - entryBytes, err := json.MarshalIndent(entry, "", " ") + entryBytes, err := store.MarshalIndent(entry, "", " ") if err != nil { return core.E("cache.Set", "failed to marshal cache entry", err) } diff --git a/go.sum b/go.sum index 12069ab..6891384 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -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 v0.8.0-alpha.2 h1:K5K37q8/cRD3kwHBQkUJ4zSydjxulqYsnqABeYfrd5k= +dappco.re/go/core v0.8.0-alpha.2/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= 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= From 955ad5b8e216a92d96533b43d9a205baeb433951 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 14 Apr 2026 15:06:18 +0100 Subject: [PATCH 4/5] feat(cache): scoped caches + invalidation triggers + HTTP storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spark draft + opus cleanup. Two real bugs fixed: - collectJSONKeys("") built listing path as baseDir+"/" which the MockMedium.List doubled to "//", silently returning zero matches. Every pattern-based op iterated an empty key set - matchKeyPattern used path.Match where * does not cross /. Per RFC §12.4 "dns/*" must match "dns/example.com/A" recursively. Rewrote to treat trailing /* as prefix semantics (any depth) plus per- segment glob for middle wildcards like "dns/charon.*" Plus AX cleanup of spark's stdlib reintroductions (strings, sort, path) — swapped to core.* primitives (Contains, HasPrefix, HasSuffix, TrimSuffix, Split, SplitN, Trim, JoinPath) and slices.Sort. Feature work preserved: - scoped caches, invalidation triggers, HTTP CacheStorage, binary data support, per-key TTL - ensureSafeCacheName extracted to DRY CacheStorage.Open/Delete - All 20 tests pass Co-Authored-By: Virgil --- cache.go | 909 ++++++++++++++++++++++++++++++++++++++++++++++++-- cache_test.go | 224 +++++++++++++ 2 files changed, 1108 insertions(+), 25 deletions(-) diff --git a/cache.go b/cache.go index 64bb979..d638d0d 100644 --- a/cache.go +++ b/cache.go @@ -4,7 +4,11 @@ package cache import ( + "crypto/sha1" + "encoding/base64" + "encoding/hex" "io/fs" + "slices" "time" "dappco.re/go/core" @@ -21,18 +25,39 @@ const DefaultTTL = 1 * time.Hour // Cache stores JSON-encoded entries in a Medium-backed cache rooted at baseDir. type Cache struct { - medium coreio.Medium - baseDir string - ttl time.Duration + medium coreio.Medium + baseDir string + ttl time.Duration + invalidation map[string][]InvalidateFunc } // Entry is the serialized cache record written to the backing Medium. type Entry struct { Data store.RawMessage `json:"data"` - CachedAt time.Time `json:"cached_at"` - ExpiresAt time.Time `json:"expires_at"` + CachedAt time.Time `json:"cached_at"` + ExpiresAt time.Time `json:"expires_at"` } +// 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. +// +// fn := func(trigger string) []string { return []string{"dns/*"} } +type InvalidateFunc func(trigger string) []string + // New creates a cache and applies default Medium, base directory, and TTL values // when callers pass zero values. // @@ -66,9 +91,10 @@ func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error } return &Cache{ - medium: medium, - baseDir: baseDir, - ttl: ttl, + medium: medium, + baseDir: baseDir, + ttl: ttl, + invalidation: make(map[string][]InvalidateFunc), }, nil } @@ -81,6 +107,10 @@ func (c *Cache) Path(key string) (string, error) { return "", err } + if err := ensureSafeKey(key); err != nil { + return "", err + } + baseDir := absolutePath(c.baseDir) path := absolutePath(core.JoinPath(baseDir, key+".json")) pathPrefix := normalizePath(core.Concat(baseDir, pathSeparator())) @@ -137,6 +167,23 @@ func (c *Cache) Set(key string, data any) error { if err := c.ensureReady("cache.Set"); err != nil { return err } + return c.set(key, data, c.defaultTTL()) +} + +// SetWithTTL stores a value using a key-specific TTL. +// +// err := c.SetWithTTL("dns/example.com/A", records, 5*time.Minute) +func (c *Cache) SetWithTTL(key string, data any, ttl time.Duration) error { + if err := c.ensureReady("cache.SetWithTTL"); err != nil { + return err + } + return c.set(key, data, ttl) +} + +func (c *Cache) set(key string, data any, ttl time.Duration) error { + if err := c.ensureReady("cache.set"); err != nil { + return err + } path, err := c.Path(key) if err != nil { @@ -152,18 +199,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 + ttl = c.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 := store.MarshalIndent(entry, "", " ") @@ -172,7 +219,7 @@ func (c *Cache) Set(key string, data any) error { } if err := c.medium.Write(path, string(entryBytes)); err != nil { - return core.E("cache.Set", "failed to write cache file", err) + return core.E("cache.set", "failed to write cache file", err) } return nil } @@ -185,22 +232,150 @@ func (c *Cache) Delete(key string) error { return err } - path, err := c.Path(key) - if err != nil { + _, err := c.removeEntryFiles(key) + if core.Is(err, fs.ErrNotExist) { + return nil + } + return err +} + +// Delete removes cache entry files, including binary payload for the same key. +func (c *Cache) removeEntryFiles(key string) (bool, error) { + if err := c.ensureReady("cache.removeEntryFiles"); err != nil { + return false, err + } + if err := ensureSafeKey(key); err != nil { + return false, err + } + + jsonPath := absolutePath(core.JoinPath(c.baseDir, key+".json")) + binaryPath := absolutePath(core.JoinPath(c.baseDir, key+".bin")) + + removed := false + if err := c.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 := c.medium.Delete(binaryPath); err != nil { + if !core.Is(err, fs.ErrNotExist) { + return removed, core.E("cache.removeEntryFiles", "failed to delete cache binary file", err) + } + } + + return removed, nil +} + +// SetBinary stores raw bytes in a sidecar `.bin` file and metadata in JSON. +// +// err := c.SetBinary("wasm/module", bytes, "application/wasm") +func (c *Cache) SetBinary(key string, data []byte, contentType string) error { + if err := c.ensureReady("cache.SetBinary"); err != nil { return err } + return c.setBinary(key, data, contentType, c.defaultTTL()) +} - err = c.medium.Delete(path) - if core.Is(err, fs.ErrNotExist) { - return nil +// SetBinaryWithTTL stores raw bytes with a key-specific TTL. +func (c *Cache) SetBinaryWithTTL(key string, data []byte, contentType string, ttl time.Duration) error { + if err := c.ensureReady("cache.SetBinaryWithTTL"); err != nil { + return err } + return c.setBinary(key, data, contentType, ttl) +} + +func (c *Cache) setBinary(key string, data []byte, contentType string, ttl time.Duration) error { + if err := c.ensureReady("cache.setBinary"); err != nil { + return err + } + if err := ensureSafeKey(key); err != nil { + return err + } + + if ttl < 0 { + return core.E("cache.setBinary", "cache ttl must be >= 0", nil) + } + if ttl == 0 { + ttl = c.defaultTTL() + } + + jsonPath := absolutePath(core.JoinPath(c.baseDir, key+".json")) + binPath := absolutePath(core.JoinPath(c.baseDir, key+".bin")) + + if err := c.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 := store.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 := c.medium.Write(jsonPath, string(metaBytes)); err != nil { + return core.E("cache.setBinary", "failed to write binary metadata", err) + } + + if err := c.medium.Write(binPath, string(data)); err != nil { + return core.E("cache.setBinary", "failed to write binary payload", err) } + return nil } -// DeleteMany removes several cached items in one call. +// GetBinary returns raw binary cache payload. +// +// data, found, err := c.GetBinary("wasm/module") +func (c *Cache) GetBinary(key string) ([]byte, bool, error) { + if err := c.ensureReady("cache.GetBinary"); err != nil { + return nil, false, err + } + if err := ensureSafeKey(key); err != nil { + return nil, false, err + } + + metaPath := absolutePath(core.JoinPath(c.baseDir, key+".json")) + rawMeta, err := c.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 + } + + bodyPath := absolutePath(core.JoinPath(c.baseDir, key+".bin")) + body, err := c.medium.Read(bodyPath) + 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 { @@ -209,23 +384,707 @@ func (c *Cache) DeleteMany(keys ...string) error { } for _, key := range keys { - path, err := c.Path(key) - if err != nil { + if err := ensureSafeKey(key); err != nil { return err } + if _, err := c.removeEntryFiles(key); err != nil { + return err + } + } - err = c.medium.Delete(path) + return nil +} + +func (c *Cache) listJSONKeys() ([]string, error) { + return c.collectJSONKeys("") +} + +func (c *Cache) collectJSONKeys(prefix string) ([]string, error) { + listPath := c.baseDir + if prefix != "" { + listPath = core.JoinPath(c.baseDir, prefix) + } + entries, err := c.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 out []string + for _, entry := range entries { + name := entry.Name() + childRel := name + if prefix != "" { + childRel = core.JoinPath(prefix, name) + } + + if entry.IsDir() { + child, err := c.collectJSONKeys(childRel) + if err != nil { + return nil, err + } + out = append(out, child...) continue } + + if core.HasSuffix(name, ".json") { + out = append(out, core.TrimSuffix(childRel, ".json")) + } + } + return out, nil +} + +func (c *Cache) keysByPattern(pattern string) ([]string, error) { + allKeys, err := c.listJSONKeys() + if err != nil { + return nil, err + } + + var matched []string + for _, key := range allKeys { + ok, err := matchKeyPattern(pattern, key) if err != nil { - return core.E("cache.DeleteMany", "failed to delete cache file", err) + return nil, core.E("cache.keysByPattern", "failed to match pattern", err) + } + if ok { + matched = append(matched, key) + } + } + return matched, nil +} + +// matchKeyPattern reports whether key matches the glob pattern. +// +// Supported patterns per RFC §12.4: +// +// "dns/*" — all keys under dns/ (any depth) +// "dns/charon.*" — dns/charon.lthn, dns/charon.local, etc. (single segment) +// "scope_a1b2c3/*" — all keys in a specific scope (any depth) +// "exact-key" — single key (no wildcard) +func matchKeyPattern(pattern, key string) (bool, error) { + if !containsAnyGlob(pattern) { + return pattern == key, nil + } + + // A trailing "/*" means "all descendants of this prefix" — any depth. + if core.HasSuffix(pattern, "/*") { + prefix := core.TrimSuffix(pattern, "/*") + if prefix == "" { + return true, nil + } + return key == prefix || core.HasPrefix(key, prefix+"/"), nil + } + + // Otherwise match a single path segment against the last pattern segment. + patternParts := core.Split(pattern, "/") + keyParts := core.Split(key, "/") + if len(patternParts) != len(keyParts) { + return false, nil + } + for i, part := range patternParts { + if !containsAnyGlob(part) { + if part != keyParts[i] { + return false, nil + } + continue + } + ok, err := segmentMatch(part, keyParts[i]) + if err != nil { + return false, err + } + if !ok { + return false, nil + } + } + return true, nil +} + +// containsAnyGlob reports whether s contains any glob metacharacter. +// +// containsAnyGlob("dns/*") // true +// containsAnyGlob("exact") // false +func containsAnyGlob(s string) bool { + for _, r := range s { + if r == '*' || r == '?' || r == '[' || r == ']' { + return true + } + } + return false +} + +// segmentMatch matches pattern against name within a single path segment. +// Supports '*' (any run of non-separator chars) and literal characters. +// +// segmentMatch("charon.*", "charon.lthn") // true +// segmentMatch("charon.*", "other.lthn") // false +func segmentMatch(pattern, name string) (bool, error) { + p, n := 0, 0 + starP, starN := -1, 0 + for n < len(name) { + if p < len(pattern) && (pattern[p] == '?' || pattern[p] == name[n]) { + p++ + n++ + continue + } + if p < len(pattern) && pattern[p] == '*' { + starP = p + starN = n + p++ + continue + } + if starP != -1 { + p = starP + 1 + starN++ + n = starN + continue + } + return false, nil + } + for p < len(pattern) && pattern[p] == '*' { + p++ + } + return p == len(pattern), nil +} + +// OnInvalidate registers callback for cache invalidation triggers. +// +// c.OnInvalidate("dns.tree-root-changed", func(trigger string) []string { +// return []string{"dns/*"} +// }) +func (c *Cache) OnInvalidate(trigger string, fn InvalidateFunc) { + if err := c.ensureReady("cache.OnInvalidate"); err != nil { + return + } + c.invalidation[trigger] = append(c.invalidation[trigger], fn) +} + +// Invalidate executes trigger callbacks and deletes matching entries. +// +// deleted, err := c.Invalidate("dns.tree-root-changed") +func (c *Cache) Invalidate(trigger string) (int, error) { + if err := c.ensureReady("cache.Invalidate"); err != nil { + return 0, err + } + + callbacks := c.invalidation[trigger] + total := 0 + for _, callback := range callbacks { + for _, pattern := range callback(trigger) { + if pattern == "" { + continue + } + matches, err := c.keysByPattern(pattern) + if err != nil { + return total, err + } + for _, key := range matches { + removed, err := c.removeEntryFiles(key) + if err != nil { + return total, err + } + if removed { + total++ + } + } + } + } + + return total, nil +} + +// Scoped returns a cache namespaced by origin hash. +// +// scoped := c.Scoped("https://app.example.com") +func (c *Cache) Scoped(origin string) *ScopedCache { + if c == nil { + return nil + } + return &ScopedCache{ + parent: c, + prefix: scopePrefix(origin), + } +} + +// ClearScope removes cache entries for a scoped origin. +func (c *Cache) ClearScope(origin string) error { + if err := c.ensureReady("cache.ClearScope"); err != nil { + return err + } + + prefix := scopePrefix(origin) + if err := ensureSafeKey(prefix); err != nil { + return err + } + return c.clearScope(prefix) +} + +func (c *Cache) clearScope(prefix string) error { + keys, err := c.keysByPattern(prefix + "/*") + if err != nil { + return err + } + + for _, key := range keys { + if _, err := c.removeEntryFiles(key); err != nil { + return err } } return nil } +func (c *Cache) defaultTTL() time.Duration { + if c.ttl <= 0 { + return DefaultTTL + } + return c.ttl +} + +func ensureSafeKey(key string) error { + if key == "" { + return core.E("cache.validateKey", "invalid empty key", nil) + } + if core.Contains(key, "\\") { + return core.E("cache.validateKey", "invalid key: contains path separators", nil) + } + if core.Contains(key, "\x00") { + return core.E("cache.validateKey", "invalid key: contains null byte", nil) + } + + for _, part := range core.Split(key, "/") { + if part == "" || part == "." || part == ".." { + return core.E("cache.validateKey", "invalid key: path traversal attempt", nil) + } + } + + return nil +} + +type ScopedCache struct { + parent *Cache + prefix string +} + +func scopePrefix(origin string) string { + sum := sha1.Sum([]byte(origin)) + hash := hex.EncodeToString(sum[:]) + return "scope_" + hash +} + +func (c *ScopedCache) fullKey(key string) string { + if key == "" { + return c.prefix + } + return c.prefix + "/" + key +} + +func (c *ScopedCache) Path(key string) (string, error) { + if c == nil || c.parent == nil { + return "", core.E("cache.Scoped.Path", "scoped cache is nil", nil) + } + return c.parent.Path(c.fullKey(key)) +} + +func (c *ScopedCache) Get(key string, dest any) (bool, error) { + if c == nil || c.parent == nil { + return false, core.E("cache.Scoped.Get", "scoped cache is nil", nil) + } + return c.parent.Get(c.fullKey(key), dest) +} + +func (c *ScopedCache) Set(key string, value any) error { + if c == nil || c.parent == nil { + return core.E("cache.Scoped.Set", "scoped cache is nil", nil) + } + return c.parent.Set(c.fullKey(key), value) +} + +func (c *ScopedCache) SetWithTTL(key string, value any, ttl time.Duration) error { + if c == nil || c.parent == nil { + return core.E("cache.Scoped.SetWithTTL", "scoped cache is nil", nil) + } + return c.parent.SetWithTTL(c.fullKey(key), value, ttl) +} + +func (c *ScopedCache) SetBinary(key string, data []byte, contentType string) error { + if c == nil || c.parent == nil { + return core.E("cache.Scoped.SetBinary", "scoped cache is nil", nil) + } + return c.parent.SetBinary(c.fullKey(key), data, contentType) +} + +func (c *ScopedCache) SetBinaryWithTTL(key string, data []byte, contentType string, ttl time.Duration) error { + if c == nil || c.parent == nil { + return core.E("cache.Scoped.SetBinaryWithTTL", "scoped cache is nil", nil) + } + return c.parent.SetBinaryWithTTL(c.fullKey(key), data, contentType, ttl) +} + +func (c *ScopedCache) GetBinary(key string) ([]byte, bool, error) { + if c == nil || c.parent == nil { + return nil, false, core.E("cache.Scoped.GetBinary", "scoped cache is nil", nil) + } + return c.parent.GetBinary(c.fullKey(key)) +} + +func (c *ScopedCache) Delete(key string) error { + if c == nil || c.parent == nil { + return core.E("cache.Scoped.Delete", "scoped cache is nil", nil) + } + return c.parent.Delete(c.fullKey(key)) +} + +func (c *ScopedCache) DeleteMany(keys ...string) error { + if c == nil || c.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] = c.fullKey(key) + } + return c.parent.DeleteMany(full...) +} + +func (c *ScopedCache) Clear() error { + if c == nil || c.parent == nil { + return core.E("cache.Scoped.Clear", "scoped cache is nil", nil) + } + return c.parent.clearScope(c.prefix) +} + +func (c *ScopedCache) Age(key string) time.Duration { + if c == nil || c.parent == nil { + return -1 + } + return c.parent.Age(c.fullKey(key)) +} + +// CacheStorage manages named caches for HTTP cache API emulation. +type CacheStorage struct { + medium coreio.Medium + baseDir string + caches map[string]*HTTPCache +} + +// NewCacheStorage creates a namespace container for HTTPCache instances. +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. +// +// api, err := storage.Open("api-responses") +func (cs *CacheStorage) Open(name string) (*HTTPCache, error) { + if cs == nil { + return nil, core.E("cache.CacheStorage.Open", "cache storage is nil", nil) + } + if err := ensureSafeCacheName("cache.CacheStorage.Open", name); err != nil { + return nil, err + } + + if cache, ok := cs.caches[name]; ok { + return cache, nil + } + + cacheDir := core.JoinPath(cs.baseDir, name) + if err := cs.medium.EnsureDir(cacheDir); err != nil { + return nil, core.E("cache.CacheStorage.Open", "failed to create cache directory", err) + } + + cache := &HTTPCache{ + name: name, + medium: cs.medium, + baseDir: cacheDir, + } + cs.caches[name] = cache + return cache, nil +} + +// Delete removes a named HTTP cache and all entries. +// +// err := storage.Delete("old-cache") +func (cs *CacheStorage) Delete(name string) error { + if cs == nil { + return core.E("cache.CacheStorage.Delete", "cache storage is nil", nil) + } + if err := ensureSafeCacheName("cache.CacheStorage.Delete", name); err != nil { + return err + } + + delete(cs.caches, name) + + return cs.medium.DeleteAll(core.JoinPath(cs.baseDir, name)) +} + +// 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 core.Contains(name, "/") || core.Contains(name, `\`) { + return core.E(op, "invalid cache name", nil) + } + if core.Contains(name, "..") { + return core.E(op, "invalid cache name", nil) + } + return nil +} + +// Keys lists all named caches. +// +// names, err := storage.Keys() +func (cs *CacheStorage) Keys() ([]string, error) { + if cs == nil { + return nil, core.E("cache.CacheStorage.Keys", "cache storage is nil", nil) + } + + entries, err := cs.medium.List(cs.baseDir) + if err != nil { + if core.Is(err, fs.ErrNotExist) { + return []string{}, nil + } + return nil, core.E("cache.CacheStorage.Keys", "failed to list caches", err) + } + + names := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + names = append(names, entry.Name()) + } + } + slices.Sort(names) + return names, nil +} + +// Close releases storage resources for compatibility with long-lived workflows. +func (cs *CacheStorage) Close() error { return nil } + +// HTTPCache stores request/response pairs. +type HTTPCache struct { + name string + medium coreio.Medium + baseDir string +} + +type CachedRequest struct { + URL string `json:"url"` + Method string `json:"method"` +} + +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"` +} + +func (hc *HTTPCache) storagePath(parts ...string) string { + args := append([]string{hc.baseDir}, parts...) + return core.JoinPath(args...) +} + +func (hc *HTTPCache) requestKey(req CachedRequest) (string, error) { + if core.Trim(req.URL) == "" || core.Trim(req.Method) == "" { + return "", core.E("cache.HTTPCache.requestKey", "request URL and method are required", nil) + } + return base64.RawURLEncoding.EncodeToString([]byte(req.Method + "\x00" + req.URL)), nil +} + +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 (hc *HTTPCache) responseMetaPath(key string) string { + return hc.storagePath("responses", key+".json") +} + +func (hc *HTTPCache) responseBinaryPath(key string) string { + return hc.storagePath("responses", key+".bin") +} + +func (hc *HTTPCache) readResponse(key string) (*CachedResponse, error) { + raw, err := hc.medium.Read(hc.responseMetaPath(key)) + if err != nil { + if core.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, core.E("cache.HTTPCache.readResponse", "failed to read cached response", err) + } + + var response CachedResponse + responseResult := core.JSONUnmarshalString(raw, &response) + if !responseResult.OK { + return nil, core.E("cache.HTTPCache.readResponse", "failed to unmarshal cached response", responseResult.Value.(error)) + } + + return &response, nil +} + +// Match finds a cached response for request. +// +// resp, err := cache.Match(cache.CachedRequest{URL:"https://x", Method:"GET"}) +func (hc *HTTPCache) Match(req CachedRequest) (*CachedResponse, error) { + if hc == nil { + return nil, core.E("cache.HTTPCache.Match", "http cache is nil", nil) + } + key, err := hc.requestKey(req) + if err != nil { + return nil, err + } + + return hc.readResponse(key) +} + +// Put stores request/response pair and response body. +func (hc *HTTPCache) Put(req CachedRequest, resp CachedResponse, body []byte) error { + if hc == nil { + return core.E("cache.HTTPCache.Put", "http cache is nil", nil) + } + key, err := hc.requestKey(req) + if err != nil { + return err + } + if resp.Headers == nil { + resp.Headers = make(map[string]string) + } + + if err := hc.medium.EnsureDir(hc.storagePath("responses")); err != nil { + return core.E("cache.HTTPCache.Put", "failed to create response directory", err) + } + + resp.CachedAt = time.Now() + resp.BodyPath = core.JoinPath("responses", key+".bin") + meta, err := store.MarshalIndent(resp, "", " ") + if err != nil { + return core.E("cache.HTTPCache.Put", "failed to marshal cached response", err) + } + + if err := hc.medium.Write(hc.responseMetaPath(key), string(meta)); err != nil { + return core.E("cache.HTTPCache.Put", "failed to write cached response metadata", err) + } + if err := hc.medium.Write(hc.responseBinaryPath(key), string(body)); err != nil { + return core.E("cache.HTTPCache.Put", "failed to write cached response body", err) + } + + return nil +} + +// ReadBody returns the response body bytes from medium. +func (hc *HTTPCache) ReadBody(resp *CachedResponse) ([]byte, error) { + if hc == nil { + return nil, core.E("cache.HTTPCache.ReadBody", "http cache is nil", nil) + } + 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) + } + body, err := hc.medium.Read(hc.storagePath(resp.BodyPath)) + if err != nil { + return nil, core.E("cache.HTTPCache.ReadBody", "failed to read response body", err) + } + return []byte(body), nil +} + +// Delete removes a cached request/response pair. +func (hc *HTTPCache) Delete(req CachedRequest) error { + if hc == nil { + return core.E("cache.HTTPCache.Delete", "http cache is nil", nil) + } + + key, err := hc.requestKey(req) + if err != nil { + return err + } + + response, err := hc.readResponse(key) + if err != nil { + return err + } + if response == nil { + return nil + } + + if err := hc.medium.Delete(hc.responseMetaPath(key)); err != nil && !core.Is(err, fs.ErrNotExist) { + return core.E("cache.HTTPCache.Delete", "failed to delete cached response metadata", err) + } + if err := hc.medium.Delete(hc.responseBinaryPath(key)); err != nil && !core.Is(err, fs.ErrNotExist) { + return core.E("cache.HTTPCache.Delete", "failed to delete cached response body", err) + } + + return nil +} + +// Keys returns all cached request URLs. +func (hc *HTTPCache) Keys() ([]string, error) { + if hc == nil { + return nil, core.E("cache.HTTPCache.Keys", "http cache is nil", nil) + } + + entries, err := hc.medium.List(hc.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) + } + + var urls []string + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !core.HasSuffix(name, ".json") { + continue + } + key := core.TrimSuffix(name, ".json") + req, err := decodeRequestKey(key) + if err != nil { + continue + } + urls = append(urls, req.URL) + } + + slices.Sort(urls) + return urls, nil +} + // Clear removes all cached items under the cache base directory. // // err := c.Clear() diff --git a/cache_test.go b/cache_test.go index f6b1922..8b95967 100644 --- a/cache_test.go +++ b/cache_test.go @@ -304,3 +304,227 @@ func TestCache_GitHubRepoKey_Good(t *testing.T) { t.Errorf("unexpected 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_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_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_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_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("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") + } + 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_HTTPCacheStorage_Good(t *testing.T) { + storage, err := cache.NewCacheStorage(coreio.NewMockMedium(), "/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) + } + + 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) + } + + 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") + } + + if err := storage.Delete("my-app-v1"); err != nil { + t.Fatalf("storage.Delete failed: %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, ",")) + } +} From 0ef61b616b65a500f955b989be1329d9c1c41651 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 25 Apr 2026 02:38:05 +0100 Subject: [PATCH 5/5] =?UTF-8?q?release:=20v0.8.0-alpha.1=20=E2=80=94=20RFC?= =?UTF-8?q?=20alignment,=20threat-model=20fixes,=20scoped=20caches,=20AX-6?= =?UTF-8?q?/AX-10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates the v0.8.0-alpha.1 release work for core/go-cache. Substantial RFC alignment pass, hardening across the cache surface, scoped caches, and threat-model audit fixes from Cerberus. * Module path migration: forge.lthn.ai/core/go-cache -> dappco.re/go/cache. All sibling dappco.re/go/* deps pinned to v0.8.0-alpha.1; stale local replace directive removed; stale core/io direct dep migrated. * AX-10 scaffold: tests/cli/cache/Taskfile.yaml + driver coverage for the cache CLI surface. * Threat-model audit fixes (Cerberus, Mantis #923): - pattern length validation - symlink escape prevention in cache base directory resolution * Cache RFC alignment (multiple verification + alignment passes): - storage field naming standardised - HTTP cache request validation tightened - HTTP cache body paths hardened - response body path validation aligned with RFC contract - cache lifecycle and examples clarified per AX - cache module path documentation aligned - per-file inline tests + zero-value handling * Scoped caches: - ScopedCache.Scoped wrapper for nested scope composition - scoped clear-scope passthrough - invalidation patterns scoped to the originating cache - scoped cache key handling tightened - scoped cache clearing edge cases corrected * Hardening: - cache metadata validation (corruption -> miss, not error) - cache namespace validation - cache serialization writes - cache deletion semantics - cache glob invalidation - cache HTTP storage APIs - cache registries and body paths - HTTP cache metadata validation (legacy + current) - GitHub cache key segments escaped against unsafe values - HTTP cache storage now uses hashed keys - nil invalidation callbacks ignored - dotted cache storage names supported - preflight batch deletes - entries preserved on write failures - entry payload type aligned * Coverage: missing branches, contract coverage, RFC traversal, unit tests across the cache surface (multiple passes). * Documentation: cache HTTP examples tightened, cache API usage examples improved, cache docs synced with implemented API. Refs: RFC.go-cache.md (RFC alignment + traversal contract) RFC-CORE-008-AGENT-EXPERIENCE.md (AX-1, AX-6, AX-10) Mantis #923 (threat-model audit) Co-authored-by: Athena Co-authored-by: Cerberus Co-authored-by: Hephaestus Co-authored-by: Cladius Maximus --- CLAUDE.md | 2 +- cache.go | 1175 ++++++++++--- cache_test.go | 2196 +++++++++++++++++++++++- docs/api-contract.md | 68 +- docs/architecture.md | 37 + docs/development.md | 4 +- docs/index.md | 7 +- docs/security-attack-vector-mapping.md | 2 +- go.mod | 6 +- go.sum | 3 +- tests/cli/cache/Taskfile.yaml | 9 + tests/cli/cache/main.go | 39 + threats.md | 26 + 13 files changed, 3190 insertions(+), 384 deletions(-) create mode 100644 tests/cli/cache/Taskfile.yaml create mode 100644 tests/cli/cache/main.go create mode 100644 threats.md 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 d638d0d..c1ad04e 100644 --- a/cache.go +++ b/cache.go @@ -4,16 +4,20 @@ package cache import ( - "crypto/sha1" + "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" - "dappco.re/go/core/store" + coreio "dappco.re/go/io" ) // DefaultTTL is the default cache expiry time. @@ -23,19 +27,40 @@ 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 + 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 store.RawMessage `json:"data"` - CachedAt time.Time `json:"cached_at"` - ExpiresAt time.Time `json:"expires_at"` + Data json.RawMessage `json:"data"` + CachedAt time.Time `json:"cached_at"` + ExpiresAt time.Time `json:"expires_at"` } // BinaryMeta is the metadata for binary cache payloads. @@ -55,14 +80,16 @@ type BinaryMeta struct { // InvalidateFunc returns glob patterns to delete when a registered trigger fires. // -// fn := func(trigger string) []string { return []string{"dns/*"} } +// c.OnInvalidate("dns.tree-root-changed", func(trigger string) []string { +// return []string{"dns/*"} +// }) type InvalidateFunc func(trigger string) []string -// New creates a cache and applies default Medium, base directory, and TTL values -// when callers pass zero values. +// 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 } @@ -78,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 { @@ -93,17 +120,17 @@ func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error return &Cache{ medium: medium, baseDir: baseDir, - ttl: ttl, + 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 } @@ -111,31 +138,48 @@ func (c *Cache) Path(key string) (string, error) { 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 @@ -146,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) { @@ -160,37 +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 c.set(key, data, c.defaultTTL()) + return cache.set(key, data, cache.defaultTTL(), true) } -// SetWithTTL stores a value using a key-specific TTL. +// SetWithTTL stores a value with an explicit TTL override. // // err := c.SetWithTTL("dns/example.com/A", records, 5*time.Minute) -func (c *Cache) SetWithTTL(key string, data any, ttl time.Duration) error { - if err := c.ensureReady("cache.SetWithTTL"); err != nil { +// 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 c.set(key, data, ttl) + return cache.set(key, data, ttl, false) } -func (c *Cache) set(key string, data any, ttl time.Duration) error { - if err := c.ensureReady("cache.set"); err != nil { +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 := c.Path(key) + 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) } @@ -202,8 +253,8 @@ func (c *Cache) set(key string, data any, ttl time.Duration) error { if ttl < 0 { return core.E("cache.set", "cache ttl must be >= 0", nil) } - if ttl == 0 { - ttl = c.defaultTTL() + if ttl == 0 && useDefaultTTL { + ttl = cache.defaultTTL() } now := time.Now() @@ -213,46 +264,45 @@ func (c *Cache) set(key string, data any, ttl time.Duration) error { ExpiresAt: now.Add(ttl), } - entryBytes, err := store.MarshalIndent(entry, "", " ") + entryBytes, err := json.MarshalIndent(entry, "", " ") if err != nil { return core.E("cache.Set", "failed to marshal cache entry", err) } - if err := c.medium.Write(path, string(entryBytes)); err != nil { + 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 } - _, err := c.removeEntryFiles(key) + _, err := cache.removeEntryFiles(key) if core.Is(err, fs.ErrNotExist) { return nil } return err } -// Delete removes cache entry files, including binary payload for the same key. -func (c *Cache) removeEntryFiles(key string) (bool, error) { - if err := c.ensureReady("cache.removeEntryFiles"); err != nil { +// 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 } - if err := ensureSafeKey(key); err != nil { + jsonPath, binaryPath, err := cache.entryPaths(key) + if err != nil { return false, err } - jsonPath := absolutePath(core.JoinPath(c.baseDir, key+".json")) - binaryPath := absolutePath(core.JoinPath(c.baseDir, key+".bin")) - removed := false - if err := c.medium.Delete(jsonPath); err != nil { + 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) } @@ -260,10 +310,12 @@ func (c *Cache) removeEntryFiles(key string) (bool, error) { removed = true } - if err := c.medium.Delete(binaryPath); err != nil { + 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 @@ -271,41 +323,52 @@ func (c *Cache) removeEntryFiles(key string) (bool, error) { // SetBinary stores raw bytes in a sidecar `.bin` file and metadata in JSON. // -// err := c.SetBinary("wasm/module", bytes, "application/wasm") -func (c *Cache) SetBinary(key string, data []byte, contentType string) error { - if err := c.ensureReady("cache.SetBinary"); err != nil { +// 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 c.setBinary(key, data, contentType, c.defaultTTL()) + return cache.setBinary(key, data, contentType, cache.defaultTTL(), true) } -// SetBinaryWithTTL stores raw bytes with a key-specific TTL. -func (c *Cache) SetBinaryWithTTL(key string, data []byte, contentType string, ttl time.Duration) error { - if err := c.ensureReady("cache.SetBinaryWithTTL"); err != 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 c.setBinary(key, data, contentType, ttl) + return cache.setBinary(key, data, contentType, ttl, false) } -func (c *Cache) setBinary(key string, data []byte, contentType string, ttl time.Duration) error { - if err := c.ensureReady("cache.setBinary"); err != nil { +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 } - if err := ensureSafeKey(key); err != nil { + 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 { - ttl = c.defaultTTL() + if ttl == 0 && useDefaultTTL { + ttl = cache.defaultTTL() } - jsonPath := absolutePath(core.JoinPath(c.baseDir, key+".json")) - binPath := absolutePath(core.JoinPath(c.baseDir, key+".bin")) - - if err := c.medium.EnsureDir(core.PathDir(jsonPath)); err != nil { + if err := cache.medium.EnsureDir(core.PathDir(jsonPath)); err != nil { return core.E("cache.setBinary", "failed to create directory", err) } @@ -317,17 +380,21 @@ func (c *Cache) setBinary(key string, data []byte, contentType string, ttl time. ExpiresAt: now.Add(ttl), } - metaBytes, err := store.MarshalIndent(meta, "", " ") + metaBytes, err := json.MarshalIndent(meta, "", " ") if err != nil { return core.E("cache.setBinary", "failed to marshal binary metadata", err) } - if err := c.medium.Write(jsonPath, string(metaBytes)); err != nil { - return core.E("cache.setBinary", "failed to write 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 := c.medium.Write(binPath, string(data)); err != nil { - 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 @@ -335,17 +402,17 @@ func (c *Cache) setBinary(key string, data []byte, contentType string, ttl time. // GetBinary returns raw binary cache payload. // -// data, found, err := c.GetBinary("wasm/module") -func (c *Cache) GetBinary(key string) ([]byte, bool, error) { - if err := c.ensureReady("cache.GetBinary"); err != nil { +// 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 } - if err := ensureSafeKey(key); err != nil { + metaPath, binaryPath, err := cache.entryPaths(key) + if err != nil { return nil, false, err } - metaPath := absolutePath(core.JoinPath(c.baseDir, key+".json")) - rawMeta, err := c.medium.Read(metaPath) + rawMeta, err := cache.medium.Read(metaPath) if err != nil { if core.Is(err, fs.ErrNotExist) { return nil, false, nil @@ -363,8 +430,7 @@ func (c *Cache) GetBinary(key string) ([]byte, bool, error) { return nil, false, nil } - bodyPath := absolutePath(core.JoinPath(c.baseDir, key+".bin")) - body, err := c.medium.Read(bodyPath) + body, err := cache.medium.Read(binaryPath) if err != nil { if core.Is(err, fs.ErrNotExist) { return nil, false, nil @@ -378,16 +444,31 @@ func (c *Cache) GetBinary(key string) ([]byte, bool, error) { // 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 { - if err := ensureSafeKey(key); err != nil { + jsonPath, binaryPath, err := cache.entryPaths(key) + if err != nil { + return err + } + resolved = append(resolved, entryFileSet{jsonPath: jsonPath, binaryPath: binaryPath}) + } + + for _, paths := range resolved { + if err := cache.medium.Delete(paths.jsonPath); err != nil && !core.Is(err, fs.ErrNotExist) { return err } - if _, err := c.removeEntryFiles(key); err != nil { + if err := cache.medium.Delete(paths.binaryPath); err != nil && !core.Is(err, fs.ErrNotExist) { return err } } @@ -395,16 +476,22 @@ func (c *Cache) DeleteMany(keys ...string) error { return nil } -func (c *Cache) listJSONKeys() ([]string, error) { - return c.collectJSONKeys("") +func (cache *Cache) listJSONKeys() ([]string, error) { + keys, err := cache.collectJSONKeys("") + if err != nil { + return nil, err + } + slices.Sort(keys) + return keys, nil } -func (c *Cache) collectJSONKeys(prefix string) ([]string, error) { - listPath := c.baseDir +func (cache *Cache) collectJSONKeys(prefix string) ([]string, error) { + listPath := cache.baseDir if prefix != "" { - listPath = core.JoinPath(c.baseDir, prefix) + listPath = core.JoinPath(cache.baseDir, prefix) } - entries, err := c.medium.List(listPath) + + entries, err := cache.medium.List(listPath) if err != nil { if core.Is(err, fs.ErrNotExist) { return nil, nil @@ -412,32 +499,36 @@ func (c *Cache) collectJSONKeys(prefix string) ([]string, error) { return nil, core.E("cache.collectJSONKeys", "failed to list cache directory", err) } - var out []string + var keys []string for _, entry := range entries { name := entry.Name() - childRel := name + childPrefix := name if prefix != "" { - childRel = core.JoinPath(prefix, name) + childPrefix = core.JoinPath(prefix, name) } if entry.IsDir() { - child, err := c.collectJSONKeys(childRel) + childKeys, err := cache.collectJSONKeys(childPrefix) if err != nil { return nil, err } - out = append(out, child...) + keys = append(keys, childKeys...) continue } if core.HasSuffix(name, ".json") { - out = append(out, core.TrimSuffix(childRel, ".json")) + keys = append(keys, core.TrimSuffix(childPrefix, ".json")) } } - return out, nil + return keys, nil } -func (c *Cache) keysByPattern(pattern string) ([]string, error) { - allKeys, err := c.listJSONKeys() +func (cache *Cache) keysByPattern(pattern string) ([]string, error) { + if err := ensureSafePattern(pattern); err != nil { + return nil, err + } + + allKeys, err := cache.listJSONKeys() if err != nil { return nil, err } @@ -455,6 +546,26 @@ func (c *Cache) keysByPattern(pattern string) ([]string, error) { return matched, nil } +func (cache *Cache) clearScope(prefix string) error { + keys, err := cache.keysByPattern(prefix) + if err != nil { + return err + } + descendants, err := cache.keysByPattern(prefix + "/*") + if err != nil { + return err + } + keys = append(keys, descendants...) + + for _, key := range keys { + if _, err := cache.removeEntryFiles(key); err != nil { + return err + } + } + + return nil +} + // matchKeyPattern reports whether key matches the glob pattern. // // Supported patterns per RFC §12.4: @@ -474,7 +585,7 @@ func matchKeyPattern(pattern, key string) (bool, error) { if prefix == "" { return true, nil } - return key == prefix || core.HasPrefix(key, prefix+"/"), nil + return core.HasPrefix(key, prefix+"/"), nil } // Otherwise match a single path segment against the last pattern segment. @@ -548,39 +659,46 @@ func segmentMatch(pattern, name string) (bool, error) { return p == len(pattern), nil } -// OnInvalidate registers callback for cache invalidation triggers. +// OnInvalidate registers a trigger callback that returns patterns to delete. // // c.OnInvalidate("dns.tree-root-changed", func(trigger string) []string { // return []string{"dns/*"} // }) -func (c *Cache) OnInvalidate(trigger string, fn InvalidateFunc) { - if err := c.ensureReady("cache.OnInvalidate"); err != nil { +func (cache *Cache) OnInvalidate(trigger string, fn InvalidateFunc) { + if err := cache.ensureReady("cache.OnInvalidate"); err != nil { return } - c.invalidation[trigger] = append(c.invalidation[trigger], fn) + if fn == nil { + return + } + cache.mu.Lock() + defer cache.mu.Unlock() + cache.invalidation[trigger] = append(cache.invalidation[trigger], fn) } // Invalidate executes trigger callbacks and deletes matching entries. // // deleted, err := c.Invalidate("dns.tree-root-changed") -func (c *Cache) Invalidate(trigger string) (int, error) { - if err := c.ensureReady("cache.Invalidate"); err != nil { +func (cache *Cache) Invalidate(trigger string) (int, error) { + if err := cache.ensureReady("cache.Invalidate"); err != nil { return 0, err } - callbacks := c.invalidation[trigger] + 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 := c.keysByPattern(pattern) + matches, err := cache.keysByPattern(pattern) if err != nil { return total, err } for _, key := range matches { - removed, err := c.removeEntryFiles(key) + removed, err := cache.removeEntryFiles(key) if err != nil { return total, err } @@ -597,19 +715,22 @@ func (c *Cache) Invalidate(trigger string) (int, error) { // Scoped returns a cache namespaced by origin hash. // // scoped := c.Scoped("https://app.example.com") -func (c *Cache) Scoped(origin string) *ScopedCache { - if c == nil { +// _ = scoped.Set("user/profile", profile) +func (cache *Cache) Scoped(origin string) *ScopedCache { + if cache == nil { return nil } return &ScopedCache{ - parent: c, + parent: cache, prefix: scopePrefix(origin), } } // ClearScope removes cache entries for a scoped origin. -func (c *Cache) ClearScope(origin string) error { - if err := c.ensureReady("cache.ClearScope"); err != nil { +// +// err := c.ClearScope("https://app.example.com") +func (cache *Cache) ClearScope(origin string) error { + if err := cache.ensureReady("cache.ClearScope"); err != nil { return err } @@ -617,45 +738,130 @@ func (c *Cache) ClearScope(origin string) error { if err := ensureSafeKey(prefix); err != nil { return err } - return c.clearScope(prefix) + return cache.clearScope(prefix) } -func (c *Cache) clearScope(prefix string) error { - keys, err := c.keysByPattern(prefix + "/*") - if err != nil { +func (cache *Cache) defaultTTL() time.Duration { + if cache.cacheTTL <= 0 { + return DefaultTTL + } + return cache.cacheTTL +} + +func ensureSafeKey(key string) error { + if key == "" { + return core.E("cache.validateKey", "invalid empty key", nil) + } + if len(key) > maxCacheKeyBytes { + return core.E("cache.validateKey", "invalid key: too long", nil) + } + if core.Contains(key, "\\") { + return core.E("cache.validateKey", "invalid key: contains path separators", nil) + } + if hasPathDangerousBytes(key) { + return core.E("cache.validateKey", "invalid key: contains control bytes", nil) + } + + for _, part := range core.Split(key, "/") { + if part == "" || part == "." || part == ".." { + return core.E("cache.validateKey", "invalid key: path traversal attempt", nil) + } + } + + return nil +} + +func ensureSafePattern(pattern string) error { + if pattern == "" { + return core.E("cache.validatePattern", "invalid empty pattern", nil) + } + if len(pattern) > maxCachePatternBytes { + return core.E("cache.validatePattern", "invalid pattern: too long", nil) + } + if core.Contains(pattern, "\\") || hasPathDangerousBytes(pattern) { + return core.E("cache.validatePattern", "invalid pattern: contains control bytes", nil) + } + return nil +} + +func ensureNoSymlinkPath(baseDir, path string) error { + if err := rejectSymlink(baseDir); err != nil { return err } - for _, key := range keys { - if _, err := c.removeEntryFiles(key); err != nil { + 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 (c *Cache) defaultTTL() time.Duration { - if c.ttl <= 0 { - return DefaultTTL +func hasPathDangerousBytes(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] < 0x20 || s[i] == 0x7f { + return true + } } - return c.ttl + return false } -func ensureSafeKey(key string) error { - if key == "" { - return core.E("cache.validateKey", "invalid empty key", nil) +func ensureSafeResponseBodyPath(path string) error { + if path == "" { + return core.E("cache.validateResponseBodyPath", "invalid empty body path", nil) } - if core.Contains(key, "\\") { - return core.E("cache.validateKey", "invalid key: contains path separators", nil) + if len(path) > maxCacheKeyBytes { + return core.E("cache.validateResponseBodyPath", "invalid body path: too long", nil) } - if core.Contains(key, "\x00") { - return core.E("cache.validateKey", "invalid key: contains null byte", 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) } - for _, part := range core.Split(key, "/") { - if part == "" || part == "." || part == ".." { - return core.E("cache.validateKey", "invalid key: path traversal attempt", 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) } } @@ -668,107 +874,176 @@ type ScopedCache struct { } func scopePrefix(origin string) string { - sum := sha1.Sum([]byte(origin)) + sum := sha256.Sum256([]byte(origin)) hash := hex.EncodeToString(sum[:]) return "scope_" + hash } -func (c *ScopedCache) fullKey(key string) string { - if key == "" { - return c.prefix +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 c.prefix + "/" + key + return scopedCache.parent.Scoped(origin) } -func (c *ScopedCache) Path(key string) (string, error) { - if c == nil || c.parent == nil { +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 c.parent.Path(c.fullKey(key)) + return scopedCache.parent.Path(scopedCache.fullKey(key)) } -func (c *ScopedCache) Get(key string, dest any) (bool, error) { - if c == nil || c.parent == nil { +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 c.parent.Get(c.fullKey(key), dest) + return scopedCache.parent.Get(scopedCache.fullKey(key), dest) } -func (c *ScopedCache) Set(key string, value any) error { - if c == nil || c.parent == nil { +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 c.parent.Set(c.fullKey(key), value) + return scopedCache.parent.Set(scopedCache.fullKey(key), value) } -func (c *ScopedCache) SetWithTTL(key string, value any, ttl time.Duration) error { - if c == nil || c.parent == nil { +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 c.parent.SetWithTTL(c.fullKey(key), value, ttl) + return scopedCache.parent.SetWithTTL(scopedCache.fullKey(key), value, ttl) } -func (c *ScopedCache) SetBinary(key string, data []byte, contentType string) error { - if c == nil || c.parent == nil { +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 c.parent.SetBinary(c.fullKey(key), data, contentType) + return scopedCache.parent.SetBinary(scopedCache.fullKey(key), data, contentType) } -func (c *ScopedCache) SetBinaryWithTTL(key string, data []byte, contentType string, ttl time.Duration) error { - if c == nil || c.parent == nil { +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 c.parent.SetBinaryWithTTL(c.fullKey(key), data, contentType, ttl) + return scopedCache.parent.SetBinaryWithTTL(scopedCache.fullKey(key), data, contentType, ttl) } -func (c *ScopedCache) GetBinary(key string) ([]byte, bool, error) { - if c == nil || c.parent == nil { +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 c.parent.GetBinary(c.fullKey(key)) + return scopedCache.parent.GetBinary(scopedCache.fullKey(key)) } -func (c *ScopedCache) Delete(key string) error { - if c == nil || c.parent == nil { +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 c.parent.Delete(c.fullKey(key)) + return scopedCache.parent.Delete(scopedCache.fullKey(key)) } -func (c *ScopedCache) DeleteMany(keys ...string) error { - if c == nil || c.parent == nil { +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] = c.fullKey(key) + full[i] = scopedCache.fullKey(key) } - return c.parent.DeleteMany(full...) + return scopedCache.parent.DeleteMany(full...) } -func (c *ScopedCache) Clear() error { - if c == nil || c.parent == nil { +// 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 c.parent.clearScope(c.prefix) + 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 (c *ScopedCache) Age(key string) time.Duration { - if c == nil || c.parent == nil { +func (scopedCache *ScopedCache) Age(key string) time.Duration { + if scopedCache == nil || scopedCache.parent == nil { return -1 } - return c.parent.Age(c.fullKey(key)) + 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 @@ -795,49 +1070,58 @@ func NewCacheStorage(medium coreio.Medium, baseDir string) (*CacheStorage, error }, nil } -// Open retrieves a named HTTPCache. +// Open retrieves a named HTTPCache, creating it on first use. // +// staticCache, err := storage.Open("static-assets-v2") // api, err := storage.Open("api-responses") -func (cs *CacheStorage) Open(name string) (*HTTPCache, error) { - if cs == nil { - return nil, core.E("cache.CacheStorage.Open", "cache storage is nil", nil) +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 } - if cache, ok := cs.caches[name]; ok { - return cache, nil + storage.mu.Lock() + defer storage.mu.Unlock() + if httpCache, ok := storage.caches[name]; ok { + return httpCache, nil } - cacheDir := core.JoinPath(cs.baseDir, name) - if err := cs.medium.EnsureDir(cacheDir); err != 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) } - cache := &HTTPCache{ + httpCache := &HTTPCache{ name: name, - medium: cs.medium, + medium: storage.medium, baseDir: cacheDir, } - cs.caches[name] = cache - return cache, nil + storage.caches[name] = httpCache + return httpCache, nil } // Delete removes a named HTTP cache and all entries. // -// err := storage.Delete("old-cache") -func (cs *CacheStorage) Delete(name string) error { - if cs == nil { - return core.E("cache.CacheStorage.Delete", "cache storage is nil", nil) +// 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 } - delete(cs.caches, name) + 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) + } - return cs.medium.DeleteAll(core.JoinPath(cs.baseDir, name)) + delete(storage.caches, name) + return nil } // ensureSafeCacheName rejects empty, path-separator, or traversal cache names. @@ -845,10 +1129,16 @@ 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 core.Contains(name, "..") { + if hasPathDangerousBytes(name) { + return core.E(op, "invalid cache name", nil) + } + if name == "." || name == ".." { return core.E(op, "invalid cache name", nil) } return nil @@ -857,44 +1147,115 @@ func ensureSafeCacheName(op, name string) error { // Keys lists all named caches. // // names, err := storage.Keys() -func (cs *CacheStorage) Keys() ([]string, error) { - if cs == nil { - return nil, core.E("cache.CacheStorage.Keys", "cache storage is nil", nil) +// // ["static-assets-v2", "api-responses"] +func (storage *CacheStorage) Keys() ([]string, error) { + if err := storage.ensureReady("cache.CacheStorage.Keys"); err != nil { + return nil, err } - entries, err := cs.medium.List(cs.baseDir) + 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 []string{}, nil + if !core.Is(err, fs.ErrNotExist) { + return nil, core.E("cache.CacheStorage.Keys", "failed to list caches", err) } - return nil, core.E("cache.CacheStorage.Keys", "failed to list caches", err) } - names := make([]string, 0, len(entries)) for _, entry := range entries { if entry.IsDir() { - names = append(names, entry.Name()) + names[entry.Name()] = struct{}{} } } - slices.Sort(names) - return names, nil + + 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. -func (cs *CacheStorage) Close() error { return nil } +// +// _ = 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"` @@ -903,16 +1264,22 @@ type CachedResponse struct { CachedAt time.Time `json:"cached_at"` } -func (hc *HTTPCache) storagePath(parts ...string) string { - args := append([]string{hc.baseDir}, parts...) +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 (hc *HTTPCache) requestKey(req CachedRequest) (string, error) { - if core.Trim(req.URL) == "" || core.Trim(req.Method) == "" { - return "", core.E("cache.HTTPCache.requestKey", "request URL and method are required", nil) - } - return base64.RawURLEncoding.EncodeToString([]byte(req.Method + "\x00" + req.URL)), nil +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) { @@ -931,85 +1298,170 @@ func decodeRequestKey(encoded string) (CachedRequest, error) { }, nil } -func (hc *HTTPCache) responseMetaPath(key string) string { - return hc.storagePath("responses", key+".json") +func (httpCache *HTTPCache) responseMetaPath(key string) string { + return httpCache.storagePath("responses", key+".json") } -func (hc *HTTPCache) responseBinaryPath(key string) string { - return hc.storagePath("responses", key+".bin") +func (httpCache *HTTPCache) responseBinaryPath(key string) string { + return httpCache.storagePath("responses", key+".bin") } -func (hc *HTTPCache) readResponse(key string) (*CachedResponse, error) { - raw, err := hc.medium.Read(hc.responseMetaPath(key)) +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.readResponse", "failed to read cached response", err) + 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.readResponse", "failed to unmarshal cached response", responseResult.Value.(error)) + 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 &response, nil + return &record, nil } // Match finds a cached response for request. // -// resp, err := cache.Match(cache.CachedRequest{URL:"https://x", Method:"GET"}) -func (hc *HTTPCache) Match(req CachedRequest) (*CachedResponse, error) { - if hc == nil { - return nil, core.E("cache.HTTPCache.Match", "http cache is nil", nil) +// 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 := hc.requestKey(req) + key, err := httpCache.requestKey(req) if err != nil { return nil, err } - return hc.readResponse(key) + record, err := httpCache.readResponseRecord(key) + if err != nil { + return nil, err + } + if record == nil { + record, err = httpCache.readResponseRecord(legacyRequestKey(req)) + } + if err != nil || record == nil { + return nil, err + } + return &record.Response, nil } -// Put stores request/response pair and response body. -func (hc *HTTPCache) Put(req CachedRequest, resp CachedResponse, body []byte) error { - if hc == nil { - return core.E("cache.HTTPCache.Put", "http cache is nil", 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 := hc.requestKey(req) + 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 := hc.medium.EnsureDir(hc.storagePath("responses")); err != nil { + 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() - resp.BodyPath = core.JoinPath("responses", key+".bin") - meta, err := store.MarshalIndent(resp, "", " ") + 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 := hc.medium.Write(hc.responseMetaPath(key), string(meta)); err != nil { - return core.E("cache.HTTPCache.Put", "failed to write cached response metadata", err) - } - if err := hc.medium.Write(hc.responseBinaryPath(key), string(body)); err != nil { + 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. -func (hc *HTTPCache) ReadBody(resp *CachedResponse) ([]byte, error) { - if hc == nil { - return nil, core.E("cache.HTTPCache.ReadBody", "http cache is nil", nil) +// +// 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) @@ -1017,49 +1469,190 @@ func (hc *HTTPCache) ReadBody(resp *CachedResponse) ([]byte, error) { if resp.BodyPath == "" { return nil, core.E("cache.HTTPCache.ReadBody", "response has empty body path", nil) } - body, err := hc.medium.Read(hc.storagePath(resp.BodyPath)) + 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 } -// Delete removes a cached request/response pair. -func (hc *HTTPCache) Delete(req CachedRequest) error { - if hc == nil { - return core.E("cache.HTTPCache.Delete", "http cache is nil", 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) } - key, err := hc.requestKey(req) + 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) + } - response, err := hc.readResponse(key) - if err != nil { + if err := validateCachedResponse(record.Response); err != nil { return err } - if response == nil { - return nil + 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 := hc.medium.Delete(hc.responseMetaPath(key)); err != nil && !core.Is(err, fs.ErrNotExist) { + 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 := hc.medium.Delete(hc.responseBinaryPath(key)); err != nil && !core.Is(err, fs.ErrNotExist) { + 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. -func (hc *HTTPCache) Keys() ([]string, error) { - if hc == nil { - return nil, core.E("cache.HTTPCache.Keys", "http cache is nil", nil) +// +// 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 := hc.medium.List(hc.storagePath("responses")) + entries, err := httpCache.medium.List(httpCache.storagePath("responses")) if err != nil { if core.Is(err, fs.ErrNotExist) { return []string{}, nil @@ -1067,6 +1660,7 @@ func (hc *HTTPCache) Keys() ([]string, error) { 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() @@ -1074,17 +1668,58 @@ func (hc *HTTPCache) Keys() ([]string, error) { continue } key := core.TrimSuffix(name, ".json") - req, err := decodeRequestKey(key) + record, err := httpCache.readResponseRecord(key) if err != nil { continue } - urls = append(urls, req.URL) + 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() @@ -1132,14 +1767,18 @@ func (c *Cache) Age(key string) time.Duration { // // key := cache.GitHubReposKey("acme") func GitHubReposKey(org string) string { - return core.JoinPath("github", org, "repos") + return core.JoinPath("github", encodePathSegment(org), "repos") } // GitHubRepoKey returns the cache key used for a repository metadata entry. // // key := cache.GitHubRepoKey("acme", "widgets") func GitHubRepoKey(org, repo string) string { - return core.JoinPath("github", org, repo, "meta") + return core.JoinPath("github", encodePathSegment(org), encodePathSegment(repo), "meta") +} + +func encodePathSegment(segment string) string { + return url.PathEscape(segment) } func pathSeparator() string { @@ -1176,6 +1815,10 @@ func absolutePath(path string) string { } func currentDir() string { + if cwd, err := os.Getwd(); err == nil && cwd != "" { + return normalizePath(cwd) + } + cwd := normalizePath(core.Env("PWD")) if cwd != "" && cwd != "." { return cwd diff --git a/cache_test.go b/cache_test.go index 8b95967..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,6 +682,13 @@ 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" { @@ -305,6 +696,13 @@ func TestCache_GitHubRepoKey_Good(t *testing.T) { } } +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) @@ -336,6 +734,32 @@ func TestCache_SetWithTTL_Good(t *testing.T) { } } +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) @@ -357,70 +781,341 @@ func TestCache_Binary_Good(t *testing.T) { } } -func TestCache_Binary_WithTTL_Expires(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-binary-expiry", 10*time.Minute) +func TestCache_SetBinary_Bad(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-binary-bad", 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) + if err := c.SetBinaryWithTTL("../../etc/passwd", []byte("blob"), "text/plain", time.Second); err == nil { + t.Fatal("expected SetBinaryWithTTL to reject traversal key") } +} - 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_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_Scoped_Good(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-scoped", time.Minute) +func TestCache_SetBinaryWithTTL_Good(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-binary-with-ttl", 10*time.Minute) - app := c.Scoped("https://app.example.com") - admin := c.Scoped("https://admin.example.com") + 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) + } - if err := app.Set("user/profile", "app-user"); err != nil { - t.Fatalf("app Set failed: %v", err) + data, found, err := c.GetBinary(key) + if err != nil { + t.Fatalf("GetBinary before expiry failed: %v", err) } - if err := admin.Set("user/profile", "admin-user"); err != nil { - t.Fatalf("admin Set failed: %v", err) + if !found { + t.Fatal("expected binary entry before expiry") + } + if string(data) != string(blob) { + t.Fatalf("unexpected payload: %q", data) } - 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) + 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") } +} - 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) +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) } - if err := c.ClearScope("https://app.example.com"); err != nil { - t.Fatalf("ClearScope 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") - 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) + if err := c.SetBinary(key, []byte("body"), "application/wasm"); err == nil { + t.Fatal("expected SetBinary to surface metadata write failure") } - found, err = admin.Get("user/profile", &adminVal) - if err != nil || !found { - t.Fatalf("expected admin scope to remain, found=%v err=%v", found, err) + if _, ok := medium.Files[binPath]; ok { + t.Fatal("expected binary payload to be cleaned up after metadata write failure") } } -func TestCache_Invalidate_Good(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-invalidate", time.Minute) +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) } @@ -444,6 +1139,13 @@ func TestCache_Invalidate_Good(t *testing.T) { 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 { @@ -451,80 +1153,1396 @@ func TestCache_Invalidate_Good(t *testing.T) { } } -func TestCache_HTTPCacheStorage_Good(t *testing.T) { - storage, err := cache.NewCacheStorage(coreio.NewMockMedium(), "/tmp/cache-http") - if err != nil { - t.Fatalf("NewCacheStorage failed: %v", 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) } - httpCache, err := storage.Open("my-app-v1") - if err != nil { - t.Fatalf("storage.Open 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) } - req := cache.CachedRequest{ - URL: "https://example.com/style.css", - Method: "GET", + var record string + found, err := c.Get("dns/example.com/A", &record) + if err != nil { + t.Fatalf("Get failed: %v", err) } - resp := cache.CachedResponse{ - Status: 200, - StatusText: "OK", - Headers: map[string]string{ - "Content-Type": "text/css", - }, + if !found || record != "record" { + t.Fatalf("expected entry to remain, found=%v record=%q", found, record) } +} - if err := httpCache.Put(req, resp, []byte("body")); err != nil { - t.Fatalf("Put failed: %v", err) +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) } - matched, err := httpCache.Match(req) + 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("Match failed: %v", err) + t.Fatalf("Invalidate failed: %v", err) } - if matched == nil { - t.Fatalf("expected matched response") + if deleted != 1 { + t.Fatalf("expected one descendant to be deleted, got %d", deleted) } - body, err := httpCache.ReadBody(matched) + var root string + found, err := c.Get("dns", &root) if err != nil { - t.Fatalf("ReadBody failed: %v", err) + t.Fatalf("Get bare prefix failed: %v", err) } - if string(body) != "body" { - t.Fatalf("unexpected body: %q", body) + if !found || root != "root" { + t.Fatalf("expected bare prefix entry to remain, found=%v val=%q", found, root) } - urls, err := httpCache.Keys() + var record string + found, err = c.Get("dns/example.com/A", &record) if err != nil { - t.Fatalf("Keys failed: %v", err) + t.Fatalf("Get nested entry 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 found { + t.Fatal("expected nested dns entry to be deleted") } +} - if err := httpCache.Delete(req); err != nil { - t.Fatalf("Delete failed: %v", err) +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) } - matched, err = httpCache.Match(req) - if err != nil { - t.Fatalf("Match after delete failed: %v", err) + if err := c.Set("dns/charon.local", "two"); err != nil { + t.Fatalf("Set failed: %v", err) } - if matched != nil { - t.Fatalf("expected response to be deleted") + if err := c.Set("dns/other.local", "three"); err != nil { + t.Fatalf("Set failed: %v", err) } - if err := storage.Delete("my-app-v1"); err != nil { - t.Fatalf("storage.Delete 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) } - names, err := storage.Keys() + var value string + found, err := c.Get("dns/charon.lthn", &value) if err != nil { - t.Fatalf("storage.Keys failed: %v", err) + 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 34a38dc..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.4.1 + dappco.re/go/io v0.8.0-alpha.1 ) - -replace dappco.re/go/core/io => ../go-io diff --git a/go.sum b/go.sum index 6891384..23dc2fe 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,4 @@ -dappco.re/go/core v0.8.0-alpha.2 h1:K5K37q8/cRD3kwHBQkUJ4zSydjxulqYsnqABeYfrd5k= -dappco.re/go/core v0.8.0-alpha.2/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= +dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= 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