From 2199bd19fef038c8c318e43624b40208f165e774 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 30 Apr 2026 21:44:36 +0100 Subject: [PATCH] refactor(go): restructure to /go/ subtree + audit COMPLIANT (Mantis #1227) Bring go-cache to v0.9.0 audit-COMPLIANT verdict (every counter at 0). Layout migration: - Moved flat module into go/ subtree - Added go.work at repo root with use ./go + ./external/{go,go-io} - Added 2 git submodules (external/go, external/go-io) on dev branch - go/go.mod declares `module dappco.re/go/cache` Contract migration: - Banned-import cleanup - Result-discard propagation - err-shape-funcs migrated to core.Result - AX-7 triplet + Example coverage backfilled - 0 gaming patterns Verification: build / vet / test / audit.sh all clean. Closes tasks.lthn.sh/view.php?id=1227 Co-authored-by: Codex --- .gitmodules | 8 + AGENTS.md | 11 + README.md | 20 + cache.go | 2258 ------------- cache_ax7_test.go | 1310 -------- cache_test.go | 3145 ------------------- external/go | 1 + external/go-io | 1 + go.work | 15 + go/cache.go | 1879 +++++++++++ go/cache_example_test.go | 270 ++ go/cache_test.go | 1081 +++++++ go.mod => go/go.mod | 0 go.sum => go/go.sum | 0 {tests => go/tests}/cli/cache/Taskfile.yaml | 0 {tests => go/tests}/cli/cache/main.go | 26 +- 16 files changed, 3299 insertions(+), 6726 deletions(-) create mode 100644 .gitmodules create mode 100644 AGENTS.md create mode 100644 README.md delete mode 100644 cache.go delete mode 100644 cache_ax7_test.go delete mode 100644 cache_test.go create mode 160000 external/go create mode 160000 external/go-io create mode 100644 go.work create mode 100644 go/cache.go create mode 100644 go/cache_example_test.go create mode 100644 go/cache_test.go rename go.mod => go/go.mod (100%) rename go.sum => go/go.sum (100%) rename {tests => go/tests}/cli/cache/Taskfile.yaml (100%) rename {tests => go/tests}/cli/cache/main.go (55%) diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..bf761e2 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,8 @@ +[submodule "external/go"] + path = external/go + url = https://github.com/dappcore/go.git + branch = dev +[submodule "external/go-io"] + path = external/go-io + url = https://github.com/dappcore/go-io.git + branch = dev diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fde5550 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# go-cache Agent Notes + +This repository follows the core/go v0.9 layout: + +- Go module source lives under `go/`. +- Root `go.work` lists `./go` and local dependency checkouts under `external/`. +- Do not edit `.core/` runtime configuration. +- Do not edit source inside `external/`; only submodule pointers should change. + +Use `core.Result` for fallible package APIs and core/go wrappers for filesystem, +JSON, process, string, and environment operations. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d7d7417 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# go-cache + +`dappco.re/go/cache` provides a storage-agnostic cache backed by +`dappco.re/go/io` media. It stores JSON entries, binary sidecars, scoped caches, +and request/response metadata for HTTP cache workflows. + +## Development + +The module lives in `go/`. + +```sh +cd go +GOWORK=off GOPROXY=direct GOSUMDB=off go test -count=1 -short ./... +``` + +The repository is v0.9.0 audit-driven; the release contract is: + +```sh +bash /Users/snider/Code/core/go/tests/cli/v090-upgrade/audit.sh . +``` diff --git a/cache.go b/cache.go deleted file mode 100644 index 33096cb..0000000 --- a/cache.go +++ /dev/null @@ -1,2258 +0,0 @@ -// SPDX-License-Identifier: EUPL-1.2 - -// Package cache provides a storage-agnostic, JSON-based cache backed by any io.Medium. -package cache - -import ( - // Note: AX-6 — structural: coreio.Medium surfaces fs.ErrNotExist/fs.DirEntry, and Lstat symlink checks use fs.ModeSymlink. - "io/fs" - // Note: AX-6 — intrinsic: coreio.Medium has no no-follow Lstat primitive or dynamic cwd lookup. - "os" - "slices" - "strings" - "sync" // Note: AX-6 — structural concurrency primitive for entry-level write serialisation. - // Note: AX-6 — no core equivalent for durations or wall-clock timestamps. - "time" - - core "dappco.re/go" - coreio "dappco.re/go/io" -) - -// DefaultTTL is the default cache expiry time. -// -// Usage example: -// -// 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 -) - -const ( - cacheStorageDirName = "cache-storage" - responsesDirName = "responses" - responsesPathPrefix = responsesDirName + "/" - - opCacheNew = "cache.New" - opCachePath = "cache.Path" - opCacheGet = "cache.Get" - opCacheSet = "cache.Set" - opCacheSetInternal = "cache.set" - opCacheRemoveEntryFiles = "cache.removeEntryFiles" - opCacheSetBinary = "cache.setBinary" - opCacheGetBinary = "cache.GetBinary" - opCacheValidateKey = "cache.validateKey" - opCacheValidatePattern = "cache.validatePattern" - opCacheValidateResponseBodyPath = "cache.validateResponseBodyPath" - opCacheStorageOpen = "cache.CacheStorage.Open" - opCacheStorageDelete = "cache.CacheStorage.Delete" - opCacheRawBase64URLDecode = "cache.rawBase64URLDecode" - opHTTPCacheReadResponseRecord = "cache.HTTPCache.readResponseRecord" - opHTTPCachePut = "cache.HTTPCache.Put" - opHTTPCacheReadBody = "cache.HTTPCache.ReadBody" - opHTTPCacheValidateCachedResponseRecord = "cache.HTTPCache.validateCachedResponseRecord" - opHTTPCacheValidateCachedRequest = "cache.HTTPCache.validateCachedRequest" - opHTTPCacheValidateCachedResponse = "cache.HTTPCache.validateCachedResponse" - opHTTPCacheDelete = "cache.HTTPCache.Delete" - - msgScopedCacheNil = "scoped cache is nil" - msgInvalidCacheName = "invalid cache name" - msgInvalidCachedRequest = "invalid cached request" - msgFailedUnmarshalCachedResponse = "failed to unmarshal cached response" -) - -// Cache stores JSON-encoded entries in a Medium-backed cache rooted at baseDir. -// -// c, err := cache.New(coreio.Local, "/tmp/cache", 5*time.Minute) -type Cache struct { - medium coreio.Medium - baseDir string - cacheTTL time.Duration - invalidation map[string][]InvalidateFunc - entryMu sync.RWMutex - runtime *core.Core -} - -// Entry is the serialized cache record written to the backing Medium. -// -// entry := cache.Entry{ -// Data: []byte(`{"foo":"bar"}`), -// CachedAt: time.Now(), -// ExpiresAt: time.Now().Add(time.Hour), -// } -type Entry struct { - Data rawJSON `json:"data"` - CachedAt time.Time `json:"cached_at"` - ExpiresAt time.Time `json:"expires_at"` -} - -type rawJSON []byte - -func (raw rawJSON) MarshalJSON() ([]byte, error) { - if raw == nil { - return []byte("null"), nil - } - return raw, nil -} - -func (raw *rawJSON) UnmarshalJSON(data []byte) error { - if raw == nil { - return core.E("cache.rawJSON.UnmarshalJSON", "target is nil", nil) - } - *raw = append((*raw)[0:0], data...) - return nil -} - -func marshalPrettyJSON(value any) (string, error) { - result := core.JSONMarshal(value) - if !result.OK { - return "", result.Value.(error) - } - return indentJSON([]byte(core.JSONMarshalString(value))), nil -} - -func indentJSON(data []byte) string { - var indenter jsonIndenter - for i, c := range data { - indenter.writeByte(data, i, c) - } - - return indenter.String() -} - -type jsonIndenter struct { - builder strings.Builder - indent int - inString bool - escaped bool -} - -func (indenter *jsonIndenter) String() string { - return indenter.builder.String() -} - -func (indenter *jsonIndenter) writeByte(data []byte, index int, c byte) { - if indenter.inString { - indenter.writeStringByte(c) - return - } - - indenter.writeValueByte(data, index, c) -} - -func (indenter *jsonIndenter) writeStringByte(c byte) { - indenter.builder.WriteByte(c) - if indenter.escaped { - indenter.escaped = false - return - } - switch c { - case '\\': - indenter.escaped = true - case '"': - indenter.inString = false - } -} - -func (indenter *jsonIndenter) writeValueByte(data []byte, index int, c byte) { - switch c { - case '"': - indenter.inString = true - indenter.builder.WriteByte(c) - case '{', '[': - indenter.writeOpeningToken(data, index, c) - case '}', ']': - indenter.writeClosingToken(data, index, c) - case ',': - indenter.builder.WriteByte(c) - indenter.writeNewline() - case ':': - indenter.builder.WriteString(": ") - default: - if !isJSONSpace(c) { - indenter.builder.WriteByte(c) - } - } -} - -func (indenter *jsonIndenter) writeOpeningToken(data []byte, index int, c byte) { - indenter.builder.WriteByte(c) - if isEmptyJSONContainer(data, index, c) { - return - } - indenter.indent++ - indenter.writeNewline() -} - -func (indenter *jsonIndenter) writeClosingToken(data []byte, index int, c byte) { - if isEmptyJSONContainerClose(data, index, c) { - indenter.builder.WriteByte(c) - return - } - if indenter.indent > 0 { - indenter.indent-- - } - indenter.writeNewline() - indenter.builder.WriteByte(c) -} - -func (indenter *jsonIndenter) writeNewline() { - indenter.builder.WriteByte('\n') - indenter.writeIndent() -} - -func (indenter *jsonIndenter) writeIndent() { - for i := 0; i < indenter.indent; i++ { - indenter.builder.WriteString(" ") - } -} - -func isEmptyJSONContainer(data []byte, index int, open byte) bool { - next := nextNonJSONSpace(data, index+1) - return next >= 0 && ((open == '{' && data[next] == '}') || (open == '[' && data[next] == ']')) -} - -func isEmptyJSONContainerClose(data []byte, index int, close byte) bool { - previous := previousNonJSONSpace(data, index-1) - return previous >= 0 && ((close == '}' && data[previous] == '{') || (close == ']' && data[previous] == '[')) -} - -func nextNonJSONSpace(data []byte, start int) int { - for i := start; i < len(data); i++ { - if !isJSONSpace(data[i]) { - return i - } - } - return -1 -} - -func previousNonJSONSpace(data []byte, start int) int { - for i := start; i >= 0; i-- { - if !isJSONSpace(data[i]) { - return i - } - } - return -1 -} - -func isJSONSpace(c byte) bool { - return c == ' ' || c == '\n' || c == '\r' || c == '\t' -} - -// BinaryMeta is the metadata for binary cache payloads. -// -// { -// "content_type":"application/wasm", -// "size":1048576, -// "cached_at":"2026-04-14T00:00:00Z", -// "expires_at":"2026-04-15T00:00:00Z" -// } -type BinaryMeta struct { - ContentType string `json:"content_type"` - Size int64 `json:"size"` - CachedAt time.Time `json:"cached_at"` - ExpiresAt time.Time `json:"expires_at"` -} - -// InvalidateFunc returns glob patterns to delete when a registered trigger fires. -// -// c.OnInvalidate("dns.tree-root-changed", func(trigger string) []string { -// return []string{"dns/*"} -// }) -type InvalidateFunc func(trigger string) []string - -// New creates a cache with explicit storage, root directory, and TTL. -// -// c, err := cache.New(coreio.Local, "/tmp/cache", 5*time.Minute) -// c, err = cache.New(nil, "", 0) // uses Local, .core/cache, and DefaultTTL -func New(medium coreio.Medium, baseDir string, cacheTTL time.Duration) (*Cache, error) { - if medium == nil { - medium = coreio.Local - } - - if baseDir == "" { - cwd := currentDir() - if cwd == "" || cwd == "." { - return nil, core.E(opCacheNew, "failed to resolve current working directory", nil) - } - - baseDir = normalizePath(core.JoinPath(cwd, ".core", "cache")) - } else { - baseDir = absolutePath(baseDir) - } - - if cacheTTL < 0 { - return nil, core.E(opCacheNew, "ttl must be >= 0", nil) - } - - if cacheTTL == 0 { - cacheTTL = DefaultTTL - } - - if err := medium.EnsureDir(baseDir); err != nil { - return nil, core.E(opCacheNew, "failed to create cache directory", err) - } - - return &Cache{ - medium: medium, - baseDir: baseDir, - cacheTTL: cacheTTL, - invalidation: make(map[string][]InvalidateFunc), - runtime: core.New(), - }, nil -} - -// Path resolves the on-disk JSON path for a cache key. -// -// path, err := c.Path("github/acme/repos") -// // => /tmp/cache/github/acme/repos.json -func (cache *Cache) Path(key string) (string, error) { - if err := cache.ensureConfigured(opCachePath); err != nil { - return "", err - } - - if err := ensureSafeKey(key); err != nil { - return "", err - } - - baseDir := absolutePath(cache.baseDir) - path := absolutePath(core.JoinPath(baseDir, key+".json")) - pathPrefix := normalizePath(core.Concat(baseDir, pathSeparator())) - - if path != baseDir && !core.HasPrefix(path, pathPrefix) { - return "", core.E(opCachePath, "invalid cache key: path traversal attempt", nil) - } - if err := ensureNoSymlinkPath(baseDir, path); err != nil { - return "", core.E(opCachePath, "invalid cache key: symlink escape attempt", err) - } - - return path, nil -} - -// entryPaths resolves the JSON and binary file paths for a cache key. -// -// jsonPath, binPath, err := c.entryPaths("github/acme/repos") -func (cache *Cache) entryPaths(key string) (string, string, error) { - jsonPath, err := cache.Path(key) - if err != nil { - return "", "", err - } - - baseDir := absolutePath(cache.baseDir) - binaryPath := absolutePath(core.JoinPath(baseDir, key+".bin")) - return jsonPath, binaryPath, nil -} - -// Get unmarshals the cached item into dest if it exists and has not expired. -// -// found, err := c.Get("github/acme/repos", &repos) -func (cache *Cache) Get(key string, dest any) (bool, error) { - if err := cache.ensureReady(opCacheGet); err != nil { - return false, err - } - - cache.entryMu.RLock() - defer cache.entryMu.RUnlock() - - path, err := cache.Path(key) - if err != nil { - return false, err - } - - dataStr, err := cache.medium.Read(path) - if err != nil { - if core.Is(err, fs.ErrNotExist) { - return false, nil - } - return false, core.E(opCacheGet, "failed to read cache file", err) - } - - var entry Entry - entryResult := core.JSONUnmarshalString(dataStr, &entry) - if !entryResult.OK { - return false, core.E(opCacheGet, "failed to unmarshal cache entry", entryResult.Value.(error)) - } - - if time.Now().After(entry.ExpiresAt) { - return false, nil - } - - if err := core.JSONUnmarshal(entry.Data, dest); !err.OK { - return false, core.E(opCacheGet, "failed to unmarshal cached data", err.Value.(error)) - } - - return true, nil -} - -// Set stores a value using the cache's default TTL. -// -// err := c.Set("github/acme/repos", repos) -// err = c.Set("config/theme", "dark") -func (cache *Cache) Set(key string, data any) error { - if err := cache.ensureReady(opCacheSet); err != nil { - return err - } - return cache.set(key, data, cache.defaultTTL(), true) -} - -// SetWithTTL stores a value with an explicit TTL override. -// -// err := c.SetWithTTL("dns/example.com/A", records, 5*time.Minute) -// err = c.SetWithTTL("session/token", token, 30*time.Second) -func (cache *Cache) SetWithTTL(key string, data any, ttl time.Duration) error { - if err := cache.ensureReady("cache.SetWithTTL"); err != nil { - return err - } - return cache.set(key, data, ttl, false) -} - -func (cache *Cache) set(key string, data any, ttl time.Duration, useDefaultTTL bool) error { - if err := cache.ensureReady(opCacheSetInternal); err != nil { - return err - } - - cache.entryMu.Lock() - defer cache.entryMu.Unlock() - - path, _, err := cache.entryPaths(key) - if err != nil { - return err - } - - snapshot, err := readFileSnapshot(cache.medium, path) - if err != nil { - return core.E(opCacheSetInternal, "failed to inspect existing cache entry", err) - } - - if err := cache.medium.EnsureDir(core.PathDir(path)); err != nil { - return core.E(opCacheSet, "failed to create directory", err) - } - - dataResult := core.JSONMarshal(data) - if !dataResult.OK { - return core.E(opCacheSet, "failed to marshal cache data", dataResult.Value.(error)) - } - - if ttl < 0 { - return core.E(opCacheSetInternal, "cache ttl must be >= 0", nil) - } - if ttl == 0 && useDefaultTTL { - ttl = cache.defaultTTL() - } - - now := time.Now() - entry := Entry{ - Data: rawJSON(dataResult.Value.([]byte)), - CachedAt: now, - ExpiresAt: now.Add(ttl), - } - - entryJSON, err := marshalPrettyJSON(entry) - if err != nil { - return core.E(opCacheSet, "failed to marshal cache entry", err) - } - - if err := cache.medium.Write(path, entryJSON); err != nil { - if restoreErr := restoreFileSnapshot(cache.medium, snapshot); restoreErr != nil { - return core.E(opCacheSetInternal, "failed to restore cache file after write failure", core.ErrorJoin(err, restoreErr)) - } - return core.E(opCacheSetInternal, "failed to write cache file", err) - } - return nil -} - -// Delete removes one cached entry. -// -// err := c.Delete("github/acme/repos") -func (cache *Cache) Delete(key string) error { - if err := cache.ensureReady("cache.Delete"); err != nil { - return err - } - - _, err := cache.removeEntryFiles(key) - if core.Is(err, fs.ErrNotExist) { - return nil - } - return err -} - -// removeEntryFiles deletes both the JSON metadata and sidecar binary payload for a key. -func (cache *Cache) removeEntryFiles(key string) (bool, error) { - if err := cache.ensureReady(opCacheRemoveEntryFiles); err != nil { - return false, err - } - - cache.entryMu.Lock() - defer cache.entryMu.Unlock() - - jsonPath, binaryPath, err := cache.entryPaths(key) - if err != nil { - return false, err - } - - removed := false - if err := cache.medium.Delete(jsonPath); err != nil { - if !core.Is(err, fs.ErrNotExist) { - return removed, core.E(opCacheRemoveEntryFiles, "failed to delete cache json file", err) - } - } else { - removed = true - } - - if err := cache.medium.Delete(binaryPath); err != nil { - if !core.Is(err, fs.ErrNotExist) { - return removed, core.E(opCacheRemoveEntryFiles, "failed to delete cache binary file", err) - } - } else { - removed = true - } - - return removed, nil -} - -// SetBinary stores raw bytes in a sidecar `.bin` file and metadata in JSON. -// -// err := c.SetBinary("wasm/my-module", wasmBytes, "application/wasm") -// err = c.SetBinary("artifacts/logo", pngBytes, "image/png") -func (cache *Cache) SetBinary(key string, data []byte, contentType string) error { - if err := cache.ensureReady("cache.SetBinary"); err != nil { - return err - } - return cache.setBinary(key, data, contentType, cache.defaultTTL(), true) -} - -// SetBinaryWithTTL stores raw bytes with an explicit TTL override. -// -// err := c.SetBinaryWithTTL("responses/temp", body, "text/html", 10*time.Minute) -// err = c.SetBinaryWithTTL("dns/example.com/AAAA", raw, "application/octet-stream", 15*time.Second) -func (cache *Cache) SetBinaryWithTTL(key string, data []byte, contentType string, ttl time.Duration) error { - if err := cache.ensureReady("cache.SetBinaryWithTTL"); err != nil { - return err - } - return cache.setBinary(key, data, contentType, ttl, false) -} - -func (cache *Cache) setBinary(key string, data []byte, contentType string, ttl time.Duration, useDefaultTTL bool) error { - if err := cache.ensureReady(opCacheSetBinary); err != nil { - return err - } - - cache.entryMu.Lock() - defer cache.entryMu.Unlock() - - jsonPath, binaryPath, err := cache.entryPaths(key) - if err != nil { - return err - } - - jsonSnapshot, binarySnapshot, err := readBinarySnapshots(cache.medium, jsonPath, binaryPath) - if err != nil { - return err - } - - if ttl < 0 { - return core.E(opCacheSetBinary, "cache ttl must be >= 0", nil) - } - if ttl == 0 && useDefaultTTL { - ttl = cache.defaultTTL() - } - - if err := cache.medium.EnsureDir(core.PathDir(jsonPath)); err != nil { - return core.E(opCacheSetBinary, "failed to create directory", err) - } - - now := time.Now() - meta := BinaryMeta{ - ContentType: contentType, - Size: int64(len(data)), - CachedAt: now, - ExpiresAt: now.Add(ttl), - } - - metaJSON, err := marshalPrettyJSON(meta) - if err != nil { - return core.E(opCacheSetBinary, "failed to marshal binary metadata", err) - } - - if err := writeFileWithRollback(cache.medium, binaryPath, string(data), opCacheSetBinary, "failed to write binary payload", - snapshotRestore{snapshot: jsonSnapshot, message: "failed to restore binary metadata after payload write failure"}, - snapshotRestore{snapshot: binarySnapshot, message: "failed to restore binary payload after payload write failure"}, - ); err != nil { - return err - } - - if err := writeFileWithRollback(cache.medium, jsonPath, metaJSON, opCacheSetBinary, "failed to write binary metadata", - snapshotRestore{snapshot: binarySnapshot, message: "failed to restore binary payload after metadata write failure"}, - snapshotRestore{snapshot: jsonSnapshot, message: "failed to restore binary metadata after metadata write failure"}, - ); err != nil { - return err - } - - return nil -} - -// GetBinary returns raw binary cache payload. -// -// data, found, err := c.GetBinary("wasm/my-module") -func (cache *Cache) GetBinary(key string) ([]byte, bool, error) { - if err := cache.ensureReady(opCacheGetBinary); err != nil { - return nil, false, err - } - - cache.entryMu.RLock() - defer cache.entryMu.RUnlock() - - metaPath, binaryPath, err := cache.entryPaths(key) - if err != nil { - return nil, false, err - } - - rawMeta, err := cache.medium.Read(metaPath) - if err != nil { - if core.Is(err, fs.ErrNotExist) { - return nil, false, nil - } - return nil, false, core.E(opCacheGetBinary, "failed to read binary metadata", err) - } - - var meta BinaryMeta - metaResult := core.JSONUnmarshalString(rawMeta, &meta) - if !metaResult.OK { - return nil, false, core.E(opCacheGetBinary, "failed to unmarshal binary metadata", metaResult.Value.(error)) - } - - if time.Now().After(meta.ExpiresAt) { - return nil, false, nil - } - - body, err := cache.medium.Read(binaryPath) - if err != nil { - if core.Is(err, fs.ErrNotExist) { - return nil, false, nil - } - return nil, false, core.E(opCacheGetBinary, "failed to read binary data", err) - } - - return []byte(body), true, nil -} - -// DeleteMany removes several entries in one call. Missing keys are ignored. -// -// err := c.DeleteMany("github/acme/repos", "github/acme/meta") -// err = c.DeleteMany("dns/example.com/A", "dns/example.com/AAAA") -func (cache *Cache) DeleteMany(keys ...string) error { - if err := cache.ensureReady("cache.DeleteMany"); err != nil { - return err - } - - cache.entryMu.Lock() - defer cache.entryMu.Unlock() - - type entryFileSet struct { - jsonPath string - binaryPath string - } - - resolved := make([]entryFileSet, 0, len(keys)) - for _, key := range keys { - jsonPath, binaryPath, err := cache.entryPaths(key) - if err != nil { - return err - } - resolved = append(resolved, entryFileSet{jsonPath: jsonPath, binaryPath: binaryPath}) - } - - for _, paths := range resolved { - if err := cache.medium.Delete(paths.jsonPath); err != nil && !core.Is(err, fs.ErrNotExist) { - return err - } - if err := cache.medium.Delete(paths.binaryPath); err != nil && !core.Is(err, fs.ErrNotExist) { - return err - } - } - - return nil -} - -func (cache *Cache) listJSONKeys() ([]string, error) { - keys, err := cache.collectJSONKeys("") - if err != nil { - return nil, err - } - slices.Sort(keys) - return keys, nil -} - -func (cache *Cache) collectJSONKeys(prefix string) ([]string, error) { - listPath := cache.baseDir - if prefix != "" { - listPath = core.JoinPath(cache.baseDir, prefix) - } - - entries, err := cache.medium.List(listPath) - if err != nil { - if core.Is(err, fs.ErrNotExist) { - return nil, nil - } - return nil, core.E("cache.collectJSONKeys", "failed to list cache directory", err) - } - - var keys []string - for _, entry := range entries { - name := entry.Name() - childPrefix := name - if prefix != "" { - childPrefix = core.JoinPath(prefix, name) - } - - if entry.IsDir() { - childKeys, err := cache.collectJSONKeys(childPrefix) - if err != nil { - return nil, err - } - keys = append(keys, childKeys...) - continue - } - - if core.HasSuffix(name, ".json") { - keys = append(keys, core.TrimSuffix(childPrefix, ".json")) - } - } - return keys, nil -} - -func (cache *Cache) keysByPattern(pattern string) ([]string, error) { - if err := ensureSafePattern(pattern); err != nil { - return nil, err - } - - cache.entryMu.RLock() - defer cache.entryMu.RUnlock() - - allKeys, err := cache.listJSONKeys() - if err != nil { - return nil, err - } - - var matched []string - for _, key := range allKeys { - ok, err := matchKeyPattern(pattern, key) - if err != nil { - return nil, core.E("cache.keysByPattern", "failed to match pattern", err) - } - if ok { - matched = append(matched, key) - } - } - return matched, nil -} - -func (cache *Cache) clearScope(prefix string) error { - keys, err := cache.keysByPattern(prefix) - if err != nil { - return err - } - descendants, err := cache.keysByPattern(prefix + "/*") - if err != nil { - return err - } - keys = append(keys, descendants...) - - for _, key := range keys { - if _, err := cache.removeEntryFiles(key); err != nil { - return err - } - } - - return nil -} - -// matchKeyPattern reports whether key matches the glob pattern. -// -// Supported patterns per RFC §12.4: -// -// "dns/*" — all keys under dns/ (any depth) -// "dns/charon.*" — dns/charon.lthn, dns/charon.local, etc. (single segment) -// "scope_a1b2c3/*" — all keys in a specific scope (any depth) -// "exact-key" — single key (no wildcard) -func matchKeyPattern(pattern, key string) (bool, error) { - if !containsAnyGlob(pattern) { - return pattern == key, nil - } - - // A trailing "/*" means "all descendants of this prefix" — any depth. - if core.HasSuffix(pattern, "/*") { - prefix := core.TrimSuffix(pattern, "/*") - if prefix == "" { - return true, nil - } - return core.HasPrefix(key, prefix+"/"), nil - } - - // Otherwise match a single path segment against the last pattern segment. - patternParts := core.Split(pattern, "/") - keyParts := core.Split(key, "/") - if len(patternParts) != len(keyParts) { - return false, nil - } - for i, part := range patternParts { - if !containsAnyGlob(part) { - if part != keyParts[i] { - return false, nil - } - continue - } - ok, err := segmentMatch(part, keyParts[i]) - if err != nil { - return false, err - } - if !ok { - return false, nil - } - } - return true, nil -} - -// containsAnyGlob reports whether s contains any glob metacharacter. -// -// containsAnyGlob("dns/*") // true -// containsAnyGlob("exact") // false -func containsAnyGlob(s string) bool { - for _, r := range s { - if r == '*' || r == '?' || r == '[' || r == ']' { - return true - } - } - return false -} - -// segmentMatch matches pattern against name within a single path segment. -// Supports '*' (any run of non-separator chars) and literal characters. -// -// segmentMatch("charon.*", "charon.lthn") // true -// segmentMatch("charon.*", "other.lthn") // false -func segmentMatch(pattern, name string) (bool, error) { - p, n := 0, 0 - starP, starN := -1, 0 - for n < len(name) { - if p < len(pattern) && (pattern[p] == '?' || pattern[p] == name[n]) { - p++ - n++ - continue - } - if p < len(pattern) && pattern[p] == '*' { - starP = p - starN = n - p++ - continue - } - if starP != -1 { - p = starP + 1 - starN++ - n = starN - continue - } - return false, nil - } - for p < len(pattern) && pattern[p] == '*' { - p++ - } - return p == len(pattern), nil -} - -// OnInvalidate registers a trigger callback that returns patterns to delete. -// -// c.OnInvalidate("dns.tree-root-changed", func(trigger string) []string { -// return []string{"dns/*"} -// }) -func (cache *Cache) OnInvalidate(trigger string, fn InvalidateFunc) { - if err := cache.ensureReady("cache.OnInvalidate"); err != nil { - return - } - if fn == nil { - return - } - lock := cache.runtime.Lock("cache") - lock.Mutex.Lock() - defer lock.Mutex.Unlock() - if cache.invalidation == nil { - cache.invalidation = make(map[string][]InvalidateFunc) - } - cache.invalidation[trigger] = append(cache.invalidation[trigger], fn) -} - -// Invalidate executes trigger callbacks and deletes matching entries. -// -// deleted, err := c.Invalidate("dns.tree-root-changed") -func (cache *Cache) Invalidate(trigger string) (int, error) { - if err := cache.ensureReady("cache.Invalidate"); err != nil { - return 0, err - } - - callbacks := cache.invalidationCallbacks(trigger) - total := 0 - for _, callback := range callbacks { - deleted, err := cache.invalidatePatterns(callback(trigger)) - total += deleted - if err != nil { - return total, err - } - } - - return total, nil -} - -func (cache *Cache) invalidationCallbacks(trigger string) []InvalidateFunc { - lock := cache.runtime.Lock("cache") - lock.Mutex.RLock() - callbacks := append([]InvalidateFunc(nil), cache.invalidation[trigger]...) - lock.Mutex.RUnlock() - return callbacks -} - -func (cache *Cache) invalidatePatterns(patterns []string) (int, error) { - total := 0 - for _, pattern := range patterns { - deleted, err := cache.invalidatePattern(pattern) - total += deleted - if err != nil { - return total, err - } - } - return total, nil -} - -func (cache *Cache) invalidatePattern(pattern string) (int, error) { - if pattern == "" { - return 0, nil - } - matches, err := cache.keysByPattern(pattern) - if err != nil { - return 0, err - } - - total := 0 - for _, key := range matches { - removed, err := cache.removeEntryFiles(key) - if err != nil { - return total, err - } - if removed { - total++ - } - } - return total, nil -} - -// Scoped returns a cache namespaced by origin hash. -// -// scoped := c.Scoped("https://app.example.com") -// _ = scoped.Set("user/profile", profile) -func (cache *Cache) Scoped(origin string) *ScopedCache { - if cache == nil { - return nil - } - return &ScopedCache{ - parent: cache, - prefix: scopePrefix(origin), - } -} - -// ClearScope removes cache entries for a scoped origin. -// -// err := c.ClearScope("https://app.example.com") -func (cache *Cache) ClearScope(origin string) error { - if err := cache.ensureReady("cache.ClearScope"); err != nil { - return err - } - - prefix := scopePrefix(origin) - if err := ensureSafeKey(prefix); err != nil { - return err - } - return cache.clearScope(prefix) -} - -func (cache *Cache) defaultTTL() time.Duration { - if cache.cacheTTL <= 0 { - return DefaultTTL - } - return cache.cacheTTL -} - -func ensureSafeKey(key string) error { - if key == "" { - return core.E(opCacheValidateKey, "invalid empty key", nil) - } - if len(key) > maxCacheKeyBytes { - return core.E(opCacheValidateKey, "invalid key: too long", nil) - } - if core.Contains(key, "\\") { - return core.E(opCacheValidateKey, "invalid key: contains path separators", nil) - } - if hasPathDangerousBytes(key) { - return core.E(opCacheValidateKey, "invalid key: contains control bytes", nil) - } - - for _, part := range core.Split(key, "/") { - if part == "" || part == "." || part == ".." { - return core.E(opCacheValidateKey, "invalid key: path traversal attempt", nil) - } - } - - return nil -} - -func ensureSafePattern(pattern string) error { - if pattern == "" { - return core.E(opCacheValidatePattern, "invalid empty pattern", nil) - } - if len(pattern) > maxCachePatternBytes { - return core.E(opCacheValidatePattern, "invalid pattern: too long", nil) - } - if core.Contains(pattern, "\\") || hasPathDangerousBytes(pattern) { - return core.E(opCacheValidatePattern, "invalid pattern: contains control bytes", nil) - } - return nil -} - -func ensureNoSymlinkPath(baseDir, path string) error { - if err := rejectSymlink(baseDir); err != nil { - return err - } - - if path == baseDir { - return nil - } - - rel := core.TrimPrefix(path, normalizePath(core.Concat(baseDir, pathSeparator()))) - if rel == path { - return nil - } - - current := baseDir - for _, part := range core.Split(rel, pathSeparator()) { - if part == "" { - continue - } - current = core.JoinPath(current, part) - if err := rejectSymlink(current); err != nil { - return err - } - } - return nil -} - -func rejectSymlink(path string) error { - info, err := os.Lstat(path) - if err != nil { - if core.Is(err, fs.ErrNotExist) { - return nil - } - return err - } - if info.Mode()&fs.ModeSymlink != 0 { - return core.E("cache.validatePath", "path contains symlink", nil) - } - return nil -} - -func hasPathDangerousBytes(s string) bool { - return hasDangerousBytes(s) -} - -func hasDangerousBytes(s string) bool { - for i := 0; i < len(s); i++ { - if s[i] < 0x20 || s[i] == 0x7f { - return true - } - } - return false -} - -func ensureSafeResponseBodyPath(path string) error { - if path == "" { - return core.E(opCacheValidateResponseBodyPath, "invalid empty body path", nil) - } - if len(path) > maxCacheKeyBytes { - return core.E(opCacheValidateResponseBodyPath, "invalid body path: too long", nil) - } - if core.PathIsAbs(path) { - return core.E(opCacheValidateResponseBodyPath, "invalid body path: absolute paths are not allowed", nil) - } - if core.Contains(path, "\\") || hasPathDangerousBytes(path) { - return core.E(opCacheValidateResponseBodyPath, "invalid body path: contains control bytes", nil) - } - - normalized := normalizePath(path) - if !core.HasPrefix(normalized, responsesPathPrefix) || !core.HasSuffix(normalized, ".bin") { - return core.E(opCacheValidateResponseBodyPath, "invalid body path: expected responses/.bin", nil) - } - - rel := core.TrimPrefix(normalized, responsesPathPrefix) - rel = core.TrimSuffix(rel, ".bin") - if rel == "" { - return core.E(opCacheValidateResponseBodyPath, "invalid body path", nil) - } - - for _, segment := range core.Split(rel, "/") { - if err := ensureSafeKey(segment); err != nil { - return core.E(opCacheValidateResponseBodyPath, "invalid body path", err) - } - } - - return nil -} - -type ScopedCache struct { - parent *Cache - prefix string -} - -func scopePrefix(origin string) string { - return "scope_" + core.SHA256Hex([]byte(origin)) -} - -func (scopedCache *ScopedCache) fullKey(key string) string { - return scopedCache.prefix + "/" + key -} - -// Scoped returns a cache namespaced by a different origin. -// -// admin := scoped.Scoped("https://admin.example.com") -// _ = admin.Set("user/profile", profile) -func (scopedCache *ScopedCache) Scoped(origin string) *ScopedCache { - if scopedCache == nil || scopedCache.parent == nil { - return nil - } - return scopedCache.parent.Scoped(origin) -} - -func (scopedCache *ScopedCache) Path(key string) (string, error) { - if scopedCache == nil || scopedCache.parent == nil { - return "", core.E("cache.Scoped.Path", msgScopedCacheNil, nil) - } - return scopedCache.parent.Path(scopedCache.fullKey(key)) -} - -func (scopedCache *ScopedCache) Get(key string, dest any) (bool, error) { - if scopedCache == nil || scopedCache.parent == nil { - return false, core.E("cache.Scoped.Get", msgScopedCacheNil, nil) - } - return scopedCache.parent.Get(scopedCache.fullKey(key), dest) -} - -func (scopedCache *ScopedCache) Set(key string, value any) error { - if scopedCache == nil || scopedCache.parent == nil { - return core.E("cache.Scoped.Set", msgScopedCacheNil, nil) - } - return scopedCache.parent.Set(scopedCache.fullKey(key), value) -} - -func (scopedCache *ScopedCache) SetWithTTL(key string, value any, ttl time.Duration) error { - if scopedCache == nil || scopedCache.parent == nil { - return core.E("cache.Scoped.SetWithTTL", msgScopedCacheNil, nil) - } - return scopedCache.parent.SetWithTTL(scopedCache.fullKey(key), value, ttl) -} - -func (scopedCache *ScopedCache) SetBinary(key string, data []byte, contentType string) error { - if scopedCache == nil || scopedCache.parent == nil { - return core.E("cache.Scoped.SetBinary", msgScopedCacheNil, nil) - } - return scopedCache.parent.SetBinary(scopedCache.fullKey(key), data, contentType) -} - -func (scopedCache *ScopedCache) SetBinaryWithTTL(key string, data []byte, contentType string, ttl time.Duration) error { - if scopedCache == nil || scopedCache.parent == nil { - return core.E("cache.Scoped.SetBinaryWithTTL", msgScopedCacheNil, nil) - } - return scopedCache.parent.SetBinaryWithTTL(scopedCache.fullKey(key), data, contentType, ttl) -} - -func (scopedCache *ScopedCache) GetBinary(key string) ([]byte, bool, error) { - if scopedCache == nil || scopedCache.parent == nil { - return nil, false, core.E("cache.Scoped.GetBinary", msgScopedCacheNil, nil) - } - return scopedCache.parent.GetBinary(scopedCache.fullKey(key)) -} - -func (scopedCache *ScopedCache) Delete(key string) error { - if scopedCache == nil || scopedCache.parent == nil { - return core.E("cache.Scoped.Delete", msgScopedCacheNil, nil) - } - return scopedCache.parent.Delete(scopedCache.fullKey(key)) -} - -func (scopedCache *ScopedCache) DeleteMany(keys ...string) error { - if scopedCache == nil || scopedCache.parent == nil { - return core.E("cache.Scoped.DeleteMany", msgScopedCacheNil, nil) - } - full := make([]string, len(keys)) - for i, key := range keys { - full[i] = scopedCache.fullKey(key) - } - return scopedCache.parent.DeleteMany(full...) -} - -// Clear removes all entries in the scope. -// -// err := scoped.Clear() -func (scopedCache *ScopedCache) Clear() error { - if scopedCache == nil || scopedCache.parent == nil { - return core.E("cache.Scoped.Clear", msgScopedCacheNil, nil) - } - return scopedCache.parent.clearScope(scopedCache.prefix) -} - -// ClearScope removes cache entries for a scoped origin. -// -// err := scoped.ClearScope("https://app.example.com") -func (scopedCache *ScopedCache) ClearScope(origin string) error { - if scopedCache == nil || scopedCache.parent == nil { - return core.E("cache.Scoped.ClearScope", msgScopedCacheNil, 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", msgScopedCacheNil, nil) - } - return scopedCache.parent.Invalidate(trigger) -} - -func (scopedCache *ScopedCache) Age(key string) time.Duration { - if scopedCache == nil || scopedCache.parent == nil { - return -1 - } - return scopedCache.parent.Age(scopedCache.fullKey(key)) -} - -func scopePattern(prefix, pattern string) string { - pattern = core.TrimPrefix(pattern, "/") - if pattern == "" { - return prefix - } - return prefix + "/" + pattern -} - -// CacheStorage manages named caches for HTTP cache API emulation. -// -// storage, _ := cache.NewCacheStorage(coreio.Local, "/tmp/cache-storage") -// appCache, err := storage.Open("my-app-v1") -// defer storage.Close() -type CacheStorage struct { - medium coreio.Medium - baseDir string - caches map[string]*HTTPCache - runtime *core.Core -} - -// NewCacheStorage creates a namespace container for HTTPCache instances. -// -// storage, err := cache.NewCacheStorage(coreio.Local, "/tmp/cache-storage") -func NewCacheStorage(medium coreio.Medium, baseDir string) (*CacheStorage, error) { - if medium == nil { - medium = coreio.Local - } - - if baseDir == "" { - cwd := currentDir() - if cwd == "" || cwd == "." { - return nil, core.E("cache.NewCacheStorage", "failed to resolve current working directory", nil) - } - baseDir = normalizePath(core.JoinPath(cwd, ".core", cacheStorageDirName)) - } else { - baseDir = absolutePath(baseDir) - } - - if err := medium.EnsureDir(baseDir); err != nil { - return nil, core.E("cache.NewCacheStorage", "failed to create cache storage directory", err) - } - - return &CacheStorage{ - medium: medium, - baseDir: baseDir, - caches: make(map[string]*HTTPCache), - runtime: core.New(), - }, nil -} - -// Open retrieves a named HTTPCache, creating it on first use. -// -// staticCache, err := storage.Open("static-assets-v2") -// api, err := storage.Open("api-responses") -func (storage *CacheStorage) Open(name string) (*HTTPCache, error) { - if err := storage.ensureReady(opCacheStorageOpen); err != nil { - return nil, err - } - if err := ensureSafeCacheName(opCacheStorageOpen, name); err != nil { - return nil, err - } - - lock := storage.runtime.Lock(cacheStorageDirName) - lock.Mutex.Lock() - defer lock.Mutex.Unlock() - if httpCache, ok := storage.caches[name]; ok { - return httpCache, nil - } - - cacheDir := core.JoinPath(storage.baseDir, name) - if err := storage.medium.EnsureDir(cacheDir); err != nil { - return nil, core.E(opCacheStorageOpen, "failed to create cache directory", err) - } - - httpCache := &HTTPCache{ - name: name, - medium: storage.medium, - baseDir: cacheDir, - } - storage.caches[name] = httpCache - return httpCache, nil -} - -// Delete removes a named HTTP cache and all entries. -// -// err := storage.Delete("static-assets-v1") -// err = storage.Delete("old-cache") -func (storage *CacheStorage) Delete(name string) error { - if err := storage.ensureReady(opCacheStorageDelete); err != nil { - return err - } - if err := ensureSafeCacheName(opCacheStorageDelete, name); err != nil { - return err - } - - lock := storage.runtime.Lock(cacheStorageDirName) - lock.Mutex.Lock() - defer lock.Mutex.Unlock() - if err := storage.medium.DeleteAll(core.JoinPath(storage.baseDir, name)); err != nil && !core.Is(err, fs.ErrNotExist) { - return core.E(opCacheStorageDelete, "failed to delete cache directory", err) - } - - delete(storage.caches, name) - return nil -} - -// ensureSafeCacheName rejects empty, path-separator, or traversal cache names. -func ensureSafeCacheName(op, name string) error { - if name == "" { - return core.E(op, "cache name is empty", nil) - } - if len(name) > maxCacheNameBytes { - return core.E(op, "invalid cache name: too long", nil) - } - if core.Contains(name, "/") || core.Contains(name, `\`) { - return core.E(op, msgInvalidCacheName, nil) - } - if hasPathDangerousBytes(name) { - return core.E(op, msgInvalidCacheName, nil) - } - if name == "." || name == ".." { - return core.E(op, msgInvalidCacheName, nil) - } - return nil -} - -// Keys lists all named caches. -// -// names, err := storage.Keys() -// // ["static-assets-v2", "api-responses"] -func (storage *CacheStorage) Keys() ([]string, error) { - if err := storage.ensureReady("cache.CacheStorage.Keys"); err != nil { - return nil, err - } - - lock := storage.runtime.Lock(cacheStorageDirName) - lock.Mutex.RLock() - names := make(map[string]struct{}, len(storage.caches)) - for name := range storage.caches { - names[name] = struct{}{} - } - lock.Mutex.RUnlock() - - entries, err := storage.medium.List(storage.baseDir) - if err != nil { - if !core.Is(err, fs.ErrNotExist) { - return nil, core.E("cache.CacheStorage.Keys", "failed to list caches", err) - } - } - - for _, entry := range entries { - if entry.IsDir() { - names[entry.Name()] = struct{}{} - } - } - - out := make([]string, 0, len(names)) - for name := range names { - out = append(out, name) - } - slices.Sort(out) - return out, nil -} - -// Close releases storage resources for compatibility with long-lived workflows. -// -// _ = storage.Close() -// appCache, err := storage.Open("reused-cache") -func (storage *CacheStorage) Close() error { - if storage == nil { - return nil - } - if storage.runtime == nil { - storage.caches = make(map[string]*HTTPCache) - return nil - } - lock := storage.runtime.Lock(cacheStorageDirName) - lock.Mutex.Lock() - defer lock.Mutex.Unlock() - storage.caches = make(map[string]*HTTPCache) - return nil -} - -// HTTPCache stores request/response pairs. -// -// storage, _ := cache.NewCacheStorage(coreio.Local, "/tmp/cache-storage") -// appCache, _ := storage.Open("my-app-v1") -// err := appCache.Put(req, resp, body) -type HTTPCache struct { - name string - medium coreio.Medium - baseDir string -} - -func (storage *CacheStorage) ensureReady(op string) error { - if storage == nil { - return core.E(op, "cache storage is nil", nil) - } - if storage.medium == nil { - return core.E(op, "cache storage medium is nil; construct via cache.NewCacheStorage", nil) - } - if storage.baseDir == "" { - return core.E(op, "cache storage base directory is empty; construct via cache.NewCacheStorage", nil) - } - if storage.runtime == nil { - return core.E(op, "cache storage runtime is nil; construct via cache.NewCacheStorage", nil) - } - lock := storage.runtime.Lock(cacheStorageDirName) - lock.Mutex.Lock() - defer lock.Mutex.Unlock() - if storage.caches == nil { - storage.caches = make(map[string]*HTTPCache) - } - return nil -} - -func (httpCache *HTTPCache) ensureReady(op string) error { - if httpCache == nil { - return core.E(op, "http cache is nil", nil) - } - if httpCache.medium == nil { - return core.E(op, "http cache medium is nil; construct via cache.CacheStorage.Open", nil) - } - if httpCache.baseDir == "" { - return core.E(op, "http cache base directory is empty; construct via cache.CacheStorage.Open", nil) - } - return nil -} - -// CachedRequest identifies a request by URL and method. -// -// req := cache.CachedRequest{ -// URL: "https://api.example.com/users", -// Method: "GET", -// } -type CachedRequest struct { - URL string `json:"url"` - Method string `json:"method"` -} - -// CachedResponse stores HTTP metadata for a cached response body. -// -// resp := cache.CachedResponse{ -// Status: 200, -// StatusText: "OK", -// Headers: map[string]string{"Content-Type": "application/json"}, -// BodyPath: "responses/a1b2c3.bin", -// } -type CachedResponse struct { - Status int `json:"status"` - StatusText string `json:"status_text"` - Headers map[string]string `json:"headers"` - BodyPath string `json:"body_path"` - CachedAt time.Time `json:"cached_at"` -} - -type cachedResponseRecord struct { - Request CachedRequest `json:"request"` - Response CachedResponse `json:"response"` -} - -func (httpCache *HTTPCache) storagePath(parts ...string) string { - args := append([]string{httpCache.baseDir}, parts...) - return core.JoinPath(args...) -} - -func (httpCache *HTTPCache) requestKey(req CachedRequest) (string, error) { - return requestStorageKey(req) -} - -func legacyRequestKey(req CachedRequest) string { - return rawBase64URLEncode([]byte(req.Method + "\x00" + req.URL)) -} - -func rawBase64URLEncode(data []byte) string { - if len(data) == 0 { - return "" - } - - const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" - builder := core.NewBuilder() - - i := 0 - for ; i+3 <= len(data); i += 3 { - n := uint(data[i])<<16 | uint(data[i+1])<<8 | uint(data[i+2]) - builder.WriteByte(alphabet[(n>>18)&0x3f]) - builder.WriteByte(alphabet[(n>>12)&0x3f]) - builder.WriteByte(alphabet[(n>>6)&0x3f]) - builder.WriteByte(alphabet[n&0x3f]) - } - - switch len(data) - i { - case 1: - n := uint(data[i]) << 16 - builder.WriteByte(alphabet[(n>>18)&0x3f]) - builder.WriteByte(alphabet[(n>>12)&0x3f]) - case 2: - n := uint(data[i])<<16 | uint(data[i+1])<<8 - builder.WriteByte(alphabet[(n>>18)&0x3f]) - builder.WriteByte(alphabet[(n>>12)&0x3f]) - builder.WriteByte(alphabet[(n>>6)&0x3f]) - } - - return builder.String() -} - -func rawBase64URLDecode(encoded string) ([]byte, error) { - if core.Contains(encoded, "=") { - return nil, core.E(opCacheRawBase64URLDecode, "raw URL base64 must not contain padding", nil) - } - if len(encoded)%4 == 1 { - return nil, core.E(opCacheRawBase64URLDecode, "invalid raw URL base64 length", nil) - } - - out := make([]byte, 0, len(encoded)*3/4) - for i := 0; i < len(encoded); { - remaining := len(encoded) - i - chunkLen := 4 - if remaining < chunkLen { - chunkLen = remaining - } - - var values [4]byte - for j := 0; j < chunkLen; j++ { - value := rawBase64URLDecodeValue(encoded[i+j]) - if value < 0 { - return nil, core.E(opCacheRawBase64URLDecode, "invalid raw URL base64 character", nil) - } - values[j] = byte(value) - } - - out = append(out, values[0]<<2|values[1]>>4) - if chunkLen >= 3 { - out = append(out, values[1]<<4|values[2]>>2) - } - if chunkLen == 4 { - out = append(out, values[2]<<6|values[3]) - } - - i += chunkLen - } - - return out, nil -} - -func rawBase64URLDecodeValue(c byte) int { - switch { - case c >= 'A' && c <= 'Z': - return int(c - 'A') - case c >= 'a' && c <= 'z': - return int(c-'a') + 26 - case c >= '0' && c <= '9': - return int(c-'0') + 52 - case c == '-': - return 62 - case c == '_': - return 63 - default: - return -1 - } -} - -func decodeRequestKey(encoded string) (CachedRequest, error) { - raw, err := rawBase64URLDecode(encoded) - if err != nil { - return CachedRequest{}, core.E("cache.decodeRequestKey", "invalid cached request key", err) - } - parts := core.SplitN(string(raw), "\x00", 2) - if len(parts) != 2 { - return CachedRequest{}, core.E("cache.decodeRequestKey", "invalid cached request key payload", nil) - } - - return CachedRequest{ - Method: parts[0], - URL: parts[1], - }, nil -} - -func (httpCache *HTTPCache) responseMetaPath(key string) string { - return httpCache.storagePath(responsesDirName, key+".json") -} - -func (httpCache *HTTPCache) responseBinaryPath(key string) string { - return httpCache.storagePath(responsesDirName, key+".bin") -} - -func (httpCache *HTTPCache) readResponseRecord(key string) (*cachedResponseRecord, error) { - raw, err := httpCache.medium.Read(httpCache.responseMetaPath(key)) - if err != nil { - if core.Is(err, fs.ErrNotExist) { - return nil, nil - } - return nil, core.E(opHTTPCacheReadResponseRecord, "failed to read cached response", err) - } - - hasRequest, hasResponse, err := cachedResponseEnvelopeState(raw) - if err != nil { - return nil, err - } - if hasRequest || hasResponse { - return parseCachedResponseRecord(key, raw, hasRequest, hasResponse) - } - return parseLegacyCachedResponseRecord(key, raw) -} - -func cachedResponseEnvelopeState(raw string) (bool, bool, error) { - var envelope map[string]rawJSON - envelopeResult := core.JSONUnmarshalString(raw, &envelope) - if !envelopeResult.OK { - return false, false, core.E(opHTTPCacheReadResponseRecord, msgFailedUnmarshalCachedResponse, envelopeResult.Value.(error)) - } - - _, hasRequest := envelope["request"] - _, hasResponse := envelope["response"] - return hasRequest, hasResponse, nil -} - -func parseCachedResponseRecord(key, raw string, hasRequest, hasResponse bool) (*cachedResponseRecord, error) { - if !hasRequest || !hasResponse { - return nil, core.E(opHTTPCacheReadResponseRecord, "cached response envelope is incomplete", nil) - } - - var record cachedResponseRecord - recordResult := core.JSONUnmarshalString(raw, &record) - if !recordResult.OK { - return nil, core.E(opHTTPCacheReadResponseRecord, msgFailedUnmarshalCachedResponse, recordResult.Value.(error)) - } - if err := validateCachedResponseRecord(key, &record); err != nil { - return nil, err - } - return &record, nil -} - -func parseLegacyCachedResponseRecord(key, raw string) (*cachedResponseRecord, error) { - var response CachedResponse - responseResult := core.JSONUnmarshalString(raw, &response) - if !responseResult.OK { - return nil, core.E(opHTTPCacheReadResponseRecord, msgFailedUnmarshalCachedResponse, responseResult.Value.(error)) - } - - req, err := decodeRequestKey(key) - if err != nil { - return nil, err - } - - record := cachedResponseRecord{ - Request: req, - Response: response, - } - if err := validateCachedResponseRecord(key, &record); err != nil { - return nil, err - } - - return &record, nil -} - -// Match finds a cached response for request. -// -// resp, err := cache.Match(cache.CachedRequest{URL: "https://x", Method: "GET"}) -func (httpCache *HTTPCache) Match(req CachedRequest) (*CachedResponse, error) { - if err := httpCache.ensureReady("cache.HTTPCache.Match"); err != nil { - return nil, err - } - if err := validateCachedRequest(req); err != nil { - return nil, core.E("cache.HTTPCache.Match", msgInvalidCachedRequest, err) - } - key, err := httpCache.requestKey(req) - if err != nil { - return nil, err - } - - record, err := httpCache.readResponseRecord(key) - if err != nil { - return nil, err - } - if record == nil { - record, err = httpCache.readResponseRecord(legacyRequestKey(req)) - } - if err != nil || record == nil { - return nil, err - } - return &record.Response, nil -} - -// Put stores a request/response pair and its body. -// -// err := appCache.Put( -// cache.CachedRequest{URL: "https://example.com/style.css", Method: "GET"}, -// cache.CachedResponse{Status: 200, Headers: headers}, -// bodyBytes, -// ) -func (httpCache *HTTPCache) Put(req CachedRequest, resp CachedResponse, body []byte) error { - if err := httpCache.ensureReady(opHTTPCachePut); err != nil { - return err - } - key, err := httpCache.requestKey(req) - if err != nil { - return err - } - resp.BodyPath = core.JoinPath(responsesDirName, key+".bin") - if err := validateCachedRequest(req); err != nil { - return core.E(opHTTPCachePut, msgInvalidCachedRequest, err) - } - if resp.Headers == nil { - resp.Headers = make(map[string]string) - } - if err := validateCachedResponse(resp); err != nil { - return core.E(opHTTPCachePut, "invalid cached response", err) - } - - if err := httpCache.medium.EnsureDir(httpCache.storagePath(responsesDirName)); err != nil { - return core.E(opHTTPCachePut, "failed to create response directory", err) - } - - metaPath := httpCache.responseMetaPath(key) - binaryPath := httpCache.responseBinaryPath(key) - metaSnapshot, binarySnapshot, err := readCachedResponseSnapshots(httpCache.medium, metaPath, binaryPath) - if err != nil { - return err - } - - resp.CachedAt = time.Now() - record := cachedResponseRecord{ - Request: req, - Response: resp, - } - meta, err := marshalPrettyJSON(record) - if err != nil { - return core.E(opHTTPCachePut, "failed to marshal cached response", err) - } - - if err := writeFileWithRollback(httpCache.medium, binaryPath, string(body), opHTTPCachePut, "failed to write cached response body", - snapshotRestore{snapshot: metaSnapshot, message: "failed to restore response metadata after body write failure"}, - snapshotRestore{snapshot: binarySnapshot, message: "failed to restore response body after body write failure"}, - ); err != nil { - return err - } - if err := writeFileWithRollback(httpCache.medium, metaPath, meta, opHTTPCachePut, "failed to write cached response metadata", - snapshotRestore{snapshot: binarySnapshot, message: "failed to restore response body after metadata write failure"}, - snapshotRestore{snapshot: metaSnapshot, message: "failed to restore response metadata after metadata write failure"}, - ); err != nil { - return err - } - - return nil -} - -// ReadBody returns the response body bytes from medium. -// -// body, err := appCache.ReadBody(resp) -func (httpCache *HTTPCache) ReadBody(resp *CachedResponse) ([]byte, error) { - if err := httpCache.ensureReady(opHTTPCacheReadBody); err != nil { - return nil, err - } - if resp == nil { - return nil, core.E(opHTTPCacheReadBody, "response is nil", nil) - } - if resp.BodyPath == "" { - return nil, core.E(opHTTPCacheReadBody, "response has empty body path", nil) - } - if err := ensureSafeResponseBodyPath(resp.BodyPath); err != nil { - return nil, core.E(opHTTPCacheReadBody, "invalid response body path", err) - } - body, err := httpCache.medium.Read(httpCache.storagePath(resp.BodyPath)) - if err != nil { - return nil, core.E(opHTTPCacheReadBody, "failed to read response body", err) - } - return []byte(body), nil -} - -func validateCachedResponseRecord(key string, record *cachedResponseRecord) error { - if record == nil { - return core.E(opHTTPCacheValidateCachedResponseRecord, "cached response record is nil", nil) - } - - if err := validateCachedRequest(record.Request); err != nil { - return core.E(opHTTPCacheValidateCachedResponseRecord, msgInvalidCachedRequest, err) - } - - expectedKey, err := requestStorageKey(record.Request) - if err != nil { - return err - } - legacyKey := legacyRequestKey(record.Request) - if key != expectedKey && key != legacyKey { - return core.E(opHTTPCacheValidateCachedResponseRecord, "cached request metadata does not match cache key", nil) - } - - if err := validateCachedResponse(record.Response); err != nil { - return err - } - expectedBodyPaths := []string{ - core.JoinPath(responsesDirName, expectedKey+".bin"), - core.JoinPath(responsesDirName, legacyKey+".bin"), - } - if !slices.Contains(expectedBodyPaths, record.Response.BodyPath) { - return core.E(opHTTPCacheValidateCachedResponseRecord, "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", msgInvalidCachedRequest, err) - } - - return core.SHA256Hex([]byte(req.Method + "\x00" + req.URL)), nil -} - -func validateCachedRequest(req CachedRequest) error { - if core.Trim(req.URL) == "" || core.Trim(req.Method) == "" { - return core.E(opHTTPCacheValidateCachedRequest, "request URL and method are required", nil) - } - if len(req.URL) > maxCachedRequestURLBytes { - return core.E(opHTTPCacheValidateCachedRequest, "request URL is too long", nil) - } - if len(req.Method) > maxCachedRequestMethodBytes { - return core.E(opHTTPCacheValidateCachedRequest, "request method is too long", nil) - } - if hasHTTPDangerousBytes(req.URL) || hasHTTPDangerousBytes(req.Method) { - return core.E(opHTTPCacheValidateCachedRequest, "request contains control characters", nil) - } - if !isHTTPToken(req.Method) { - return core.E(opHTTPCacheValidateCachedRequest, "invalid HTTP method", nil) - } - return nil -} - -func validateCachedResponse(resp CachedResponse) error { - if resp.Status < 100 || resp.Status > 599 { - return core.E(opHTTPCacheValidateCachedResponse, "invalid HTTP status", nil) - } - if hasHTTPDangerousBytes(resp.StatusText) { - return core.E(opHTTPCacheValidateCachedResponse, "invalid HTTP status text", nil) - } - if len(resp.StatusText) > maxCachedStatusTextBytes { - return core.E(opHTTPCacheValidateCachedResponse, "HTTP status text is too long", nil) - } - if err := ensureSafeResponseBodyPath(resp.BodyPath); err != nil { - return core.E(opHTTPCacheValidateCachedResponse, "invalid response body path", err) - } - if len(resp.Headers) > maxCachedHeaderCount { - return core.E(opHTTPCacheValidateCachedResponse, "too many response headers", nil) - } - for name, value := range resp.Headers { - if len(name) > maxCachedHeaderNameBytes { - return core.E(opHTTPCacheValidateCachedResponse, "response header name is too long", nil) - } - if len(value) > maxCachedHeaderValueBytes { - return core.E(opHTTPCacheValidateCachedResponse, "response header value is too long", nil) - } - if err := validateHTTPHeaderName(name); err != nil { - return core.E(opHTTPCacheValidateCachedResponse, "invalid response header name", err) - } - if hasHTTPDangerousBytes(value) { - return core.E(opHTTPCacheValidateCachedResponse, "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 { - return hasDangerousBytes(s) -} - -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(opHTTPCacheDelete); err != nil { - return err - } - if err := validateCachedRequest(req); err != nil { - return core.E(opHTTPCacheDelete, msgInvalidCachedRequest, err) - } - - key, err := httpCache.requestKey(req) - if err != nil { - return err - } - - if err := httpCache.medium.Delete(httpCache.responseMetaPath(key)); err != nil && !core.Is(err, fs.ErrNotExist) { - return core.E(opHTTPCacheDelete, "failed to delete cached response metadata", err) - } - if err := httpCache.medium.Delete(httpCache.responseBinaryPath(key)); err != nil && !core.Is(err, fs.ErrNotExist) { - return core.E(opHTTPCacheDelete, "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(opHTTPCacheDelete, "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(opHTTPCacheDelete, "failed to delete legacy cached response body", err) - } - } - - return nil -} - -// Keys returns all cached request URLs. -// -// urls, err := appCache.Keys() -// // ["https://example.com/style.css", "https://example.com/app.js"] -func (httpCache *HTTPCache) Keys() ([]string, error) { - if err := httpCache.ensureReady("cache.HTTPCache.Keys"); err != nil { - return nil, err - } - - entries, err := httpCache.medium.List(httpCache.storagePath(responsesDirName)) - if err != nil { - if core.Is(err, fs.ErrNotExist) { - return []string{}, nil - } - return nil, core.E("cache.HTTPCache.Keys", "failed to list response entries", err) - } - - seen := make(map[string]struct{}) - var urls []string - for _, entry := range entries { - name := entry.Name() - if entry.IsDir() || !core.HasSuffix(name, ".json") { - continue - } - key := core.TrimSuffix(name, ".json") - record, err := httpCache.readResponseRecord(key) - if err != nil { - continue - } - if record == nil || record.Request.URL == "" { - continue - } - if _, ok := seen[record.Request.URL]; ok { - continue - } - seen[record.Request.URL] = struct{}{} - urls = append(urls, record.Request.URL) - } - - slices.Sort(urls) - return urls, nil -} - -type fileSnapshot struct { - path string - existed bool - content string -} - -type snapshotRestore struct { - snapshot fileSnapshot - message string -} - -func readBinarySnapshots(medium coreio.Medium, jsonPath, binaryPath string) (fileSnapshot, fileSnapshot, error) { - jsonSnapshot, err := readSnapshot(medium, jsonPath, opCacheSetBinary, "failed to inspect existing binary metadata") - if err != nil { - return fileSnapshot{}, fileSnapshot{}, err - } - binarySnapshot, err := readSnapshot(medium, binaryPath, opCacheSetBinary, "failed to inspect existing binary payload") - if err != nil { - return fileSnapshot{}, fileSnapshot{}, err - } - return jsonSnapshot, binarySnapshot, nil -} - -func readCachedResponseSnapshots(medium coreio.Medium, metaPath, binaryPath string) (fileSnapshot, fileSnapshot, error) { - metaSnapshot, err := readSnapshot(medium, metaPath, opHTTPCachePut, "failed to inspect existing cached response metadata") - if err != nil { - return fileSnapshot{}, fileSnapshot{}, err - } - binarySnapshot, err := readSnapshot(medium, binaryPath, opHTTPCachePut, "failed to inspect existing cached response body") - if err != nil { - return fileSnapshot{}, fileSnapshot{}, err - } - return metaSnapshot, binarySnapshot, nil -} - -func readSnapshot(medium coreio.Medium, path, op, message string) (fileSnapshot, error) { - snapshot, err := readFileSnapshot(medium, path) - if err != nil { - return fileSnapshot{}, core.E(op, message, err) - } - return snapshot, nil -} - -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 writeFileWithRollback(medium coreio.Medium, path, content, op, message string, restores ...snapshotRestore) error { - if err := medium.Write(path, content); err != nil { - if restoreErr := restoreSnapshotsAfterError(medium, err, op, restores...); restoreErr != nil { - return restoreErr - } - return core.E(op, message, err) - } - return nil -} - -func restoreSnapshotsAfterError(medium coreio.Medium, cause error, op string, restores ...snapshotRestore) error { - for _, restore := range restores { - if err := restoreFileSnapshot(medium, restore.snapshot); err != nil { - return core.E(op, restore.message, core.ErrorJoin(cause, err)) - } - } - return nil -} - -func restoreFileSnapshot(medium coreio.Medium, snapshot fileSnapshot) error { - if snapshot.path == "" { - return nil - } - if !snapshot.existed { - if err := medium.Delete(snapshot.path); err != nil && !core.Is(err, fs.ErrNotExist) { - return err - } - return nil - } - return medium.Write(snapshot.path, snapshot.content) -} - -// Clear removes all cached items under the cache base directory. -// -// err := c.Clear() -func (c *Cache) Clear() error { - if err := c.ensureReady("cache.Clear"); err != nil { - return err - } - - if err := c.medium.DeleteAll(c.baseDir); err != nil { - return core.E("cache.Clear", "failed to clear cache", err) - } - return nil -} - -// Age reports how long ago key was cached, or -1 if it is missing or unreadable. -// -// age := c.Age("github/acme/repos") -func (c *Cache) Age(key string) time.Duration { - if err := c.ensureReady("cache.Age"); err != nil { - return -1 - } - - path, err := c.Path(key) - if err != nil { - return -1 - } - - dataStr, err := c.medium.Read(path) - if err != nil { - return -1 - } - - var entry Entry - entryResult := core.JSONUnmarshalString(dataStr, &entry) - if !entryResult.OK { - return -1 - } - - return time.Since(entry.CachedAt) -} - -// GitHub-specific cache keys - -// GitHubReposKey returns the cache key used for an organisation's repo list. -// -// key := cache.GitHubReposKey("acme") -func GitHubReposKey(org string) string { - return core.JoinPath("github", encodePathSegment(org), "repos") -} - -// GitHubRepoKey returns the cache key used for a repository metadata entry. -// -// key := cache.GitHubRepoKey("acme", "widgets") -func GitHubRepoKey(org, repo string) string { - return core.JoinPath("github", encodePathSegment(org), encodePathSegment(repo), "meta") -} - -func encodePathSegment(segment string) string { - return core.URLPathEscape(segment) -} - -func pathSeparator() string { - if ds := core.Env("DS"); ds != "" { - return ds - } - - return "/" -} - -func normalizePath(path string) string { - ds := pathSeparator() - normalized := core.Replace(path, "\\", ds) - - if ds != "/" { - normalized = core.Replace(normalized, "/", ds) - } - - return core.CleanPath(normalized, ds) -} - -func absolutePath(path string) string { - normalized := normalizePath(path) - if core.PathIsAbs(normalized) { - return normalized - } - - cwd := currentDir() - if cwd == "" || cwd == "." { - return normalized - } - - return normalizePath(core.JoinPath(cwd, normalized)) -} - -func currentDir() string { - if cwd, err := os.Getwd(); err == nil && cwd != "" { - return normalizePath(cwd) - } - - cwd := normalizePath(core.Env("PWD")) - if cwd != "" && cwd != "." { - return cwd - } - - return normalizePath(core.Env("DIR_CWD")) -} - -func (c *Cache) ensureConfigured(op string) error { - if c == nil { - return core.E(op, "cache is nil", nil) - } - if c.baseDir == "" { - return core.E(op, "cache base directory is empty; construct with cache.New", nil) - } - if c.runtime == nil { - return core.E(op, "cache runtime is nil; construct with cache.New", nil) - } - - return nil -} - -func (c *Cache) ensureReady(op string) error { - if err := c.ensureConfigured(op); err != nil { - return err - } - if c.medium == nil { - return core.E(op, "cache medium is nil; construct with cache.New", nil) - } - - return nil -} diff --git a/cache_ax7_test.go b/cache_ax7_test.go deleted file mode 100644 index d7abd08..0000000 --- a/cache_ax7_test.go +++ /dev/null @@ -1,1310 +0,0 @@ -// SPDX-License-Identifier: EUPL-1.2 - -package cache_test - -import ( - "time" - - . "dappco.re/go" - "dappco.re/go/cache" - coreio "dappco.re/go/io" -) - -const ( - ax7TextPlain = "text/plain" - ax7AcmeWidgets = "acme/widgets" - ax7EscapeKey = "../escape" - ax7AgentProfileKey = "agent/profile" - ax7AgentMissingKey = "agent/missing" - ax7ArtifactBlobKey = "artifact/blob" - ax7AgentOneKey = "agent/one" - ax7AgentTwoKey = "agent/two" - ax7AgentKeepKey = "agent/keep" - ax7AppOrigin = "https://app.example" - ax7AdminOrigin = "https://admin.example" - ax7PrefsThemeKey = "prefs/theme" - ax7PrefsPattern = "prefs/*" - ax7ListFailed = "list failed" - ax7HTTPDataURL = "https://example.com/data" - ax7HTTPMatchUglyDir = "/tmp/ax7-http-match-ugly" -) - -func ax7Cache(t *T, baseDir string) (*cache.Cache, *coreio.MockMedium) { - t.Helper() - return newTestCache(t, baseDir, time.Minute) -} - -func ax7Storage(t *T, baseDir string) (*cache.CacheStorage, *coreio.MockMedium) { - t.Helper() - - medium := coreio.NewMockMedium() - storage, err := cache.NewCacheStorage(medium, baseDir) - RequireNoError(t, err) - return storage, medium -} - -func ax7HTTPCache(t *T, baseDir, name string) (*cache.HTTPCache, *coreio.MockMedium) { - t.Helper() - - storage, medium := ax7Storage(t, baseDir) - httpCache, err := storage.Open(name) - RequireNoError(t, err) - return httpCache, medium -} - -func ax7Request(method, url string) cache.CachedRequest { - return cache.CachedRequest{Method: method, URL: url} -} - -func ax7Response(status int) cache.CachedResponse { - return cache.CachedResponse{ - Status: status, - StatusText: "OK", - Headers: map[string]string{"Content-Type": ax7TextPlain}, - } -} - -func TestCache_New_Ugly(t *T) { - dir := t.TempDir() - c, err := cache.New(nil, dir, 0) - - AssertNoError(t, err) - AssertNotNil(t, c) - AssertNoError(t, c.Set("agent/defaults", "ready")) -} - -func TestCache_NewCacheStorage_Ugly(t *T) { - dir := t.TempDir() - storage, err := cache.NewCacheStorage(nil, dir) - - AssertNoError(t, err) - AssertNotNil(t, storage) - AssertNoError(t, storage.Close()) -} - -func TestCache_GitHubReposKey_Bad(t *T) { - key := cache.GitHubReposKey(ax7AcmeWidgets) - - AssertContains(t, key, "acme%2Fwidgets") - AssertNotContains(t, key, ax7AcmeWidgets) - AssertContains(t, key, "repos") -} - -func TestCache_GitHubReposKey_Ugly(t *T) { - key := cache.GitHubReposKey("") - - AssertEqual(t, "github//repos", key) - AssertContains(t, key, "github") - AssertContains(t, key, "repos") -} - -func TestCache_GitHubRepoKey_Bad(t *T) { - key := cache.GitHubRepoKey(ax7AcmeWidgets, "api server") - - AssertContains(t, key, "acme%2Fwidgets") - AssertContains(t, key, "api%20server") - AssertNotContains(t, key, "api server") -} - -func TestCache_GitHubRepoKey_Ugly(t *T) { - key := cache.GitHubRepoKey("", "") - - AssertEqual(t, "github///meta", key) - AssertContains(t, key, "github") - AssertContains(t, key, "meta") -} - -func TestCache_JSON_MarshalJSON_Good(t *T) { - var entry cache.Entry - r := JSONUnmarshalString(`{"data":{"agent":"codex"}}`, &entry) - RequireTrue(t, r.OK) - - out := JSONMarshalString(entry) - AssertContains(t, out, `"data":{"agent":"codex"}`) -} - -func TestCache_JSON_MarshalJSON_Bad(t *T) { - entry := cache.Entry{} - out := JSONMarshalString(entry) - - AssertContains(t, out, `"data":null`) - AssertContains(t, out, `"cached_at"`) - AssertContains(t, out, `"expires_at"`) -} - -func TestCache_JSON_MarshalJSON_Ugly(t *T) { - var entry cache.Entry - r := JSONUnmarshalString(`{"data":[1,true,null]}`, &entry) - RequireTrue(t, r.OK) - - out := JSONMarshalString(entry) - AssertContains(t, out, `"data":[1,true,null]`) -} - -func TestCache_JSON_UnmarshalJSON_Good(t *T) { - var entry cache.Entry - r := JSONUnmarshalString(`{"data":{"agent":"codex"}}`, &entry) - - AssertTrue(t, r.OK) - AssertContains(t, JSONMarshalString(entry), `"agent":"codex"`) - AssertNotContains(t, JSONMarshalString(entry), "eyJhZ2VudCI") -} - -func TestCache_JSON_UnmarshalJSON_Bad(t *T) { - var entry cache.Entry - r := JSONUnmarshalString(`{"data":`, &entry) - - AssertFalse(t, r.OK) - AssertNotNil(t, r.Value) - AssertEqual(t, "null", JSONMarshalString(entry.Data)) -} - -func TestCache_JSON_UnmarshalJSON_Ugly(t *T) { - var entry cache.Entry - r := JSONUnmarshalString(`{"data":null}`, &entry) - - AssertTrue(t, r.OK) - AssertContains(t, JSONMarshalString(entry), `"data":null`) - AssertNotContains(t, JSONMarshalString(entry), `"data":""`) -} - -func TestCache_Cache_Path_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-path-good") - path, err := c.Path("agent/session") - - AssertNoError(t, err) - AssertEqual(t, "/tmp/ax7-cache-path-good/agent/session.json", path) - AssertContains(t, path, "agent/session.json") -} - -func TestCache_Cache_Path_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-path-bad") - path, err := c.Path(ax7EscapeKey) - - AssertError(t, err) - AssertEqual(t, "", path) - AssertContains(t, err.Error(), "invalid") -} - -func TestCache_Cache_Path_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-path-ugly") - path, err := c.Path(repeatString("a", 4097)) - - AssertError(t, err) - AssertEqual(t, "", path) - AssertContains(t, err.Error(), "too long") -} - -func TestCache_Cache_Get_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-get-good") - RequireNoError(t, c.Set(ax7AgentProfileKey, map[string]string{"name": "codex"})) - - var got map[string]string - found, err := c.Get(ax7AgentProfileKey, &got) - AssertNoError(t, err) - AssertTrue(t, found) - AssertEqual(t, "codex", got["name"]) -} - -func TestCache_Cache_Get_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-get-bad") - var got map[string]string - - found, err := c.Get(ax7AgentMissingKey, &got) - AssertNoError(t, err) - AssertFalse(t, found) - AssertNil(t, got) -} - -func TestCache_Cache_Get_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-get-ugly") - RequireNoError(t, c.Set(ax7AgentProfileKey, map[string]string{"name": "codex"})) - - found, err := c.Get(ax7AgentProfileKey, nil) - AssertError(t, err) - AssertFalse(t, found) - AssertContains(t, err.Error(), "unmarshal") -} - -func TestCache_Cache_Set_Good(t *T) { - c, medium := ax7Cache(t, "/tmp/ax7-cache-set-good") - err := c.Set(ax7AgentProfileKey, map[string]string{"name": "codex"}) - RequireNoError(t, err) - - raw, err := medium.Read("/tmp/ax7-cache-set-good/agent/profile.json") - AssertNoError(t, err) - AssertContains(t, raw, `"name": "codex"`) -} - -func TestCache_Cache_Set_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-set-bad") - err := c.Set("", "missing-key") - - AssertError(t, err) - AssertContains(t, err.Error(), "empty") -} - -func TestCache_Cache_Set_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-set-ugly") - err := c.Set("agent/handler", map[string]any{"fn": func() { - // Intentionally empty: JSON marshaling rejects function values before invocation. - }}) - - AssertError(t, err) - AssertContains(t, err.Error(), "marshal") -} - -func TestCache_Cache_SetWithTTL_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-setttl-good") - RequireNoError(t, c.SetWithTTL(ax7AgentProfileKey, "codex", time.Minute)) - - var got string - found, err := c.Get(ax7AgentProfileKey, &got) - AssertNoError(t, err) - AssertTrue(t, found) - AssertEqual(t, "codex", got) -} - -func TestCache_Cache_SetWithTTL_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-setttl-bad") - err := c.SetWithTTL(ax7AgentProfileKey, "codex", -time.Second) - - AssertError(t, err) - AssertContains(t, err.Error(), "ttl") -} - -func TestCache_Cache_SetWithTTL_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-setttl-ugly") - RequireNoError(t, c.SetWithTTL(ax7AgentProfileKey, "codex", 0)) - - var got string - found, err := c.Get(ax7AgentProfileKey, &got) - AssertNoError(t, err) - AssertFalse(t, found) - AssertEqual(t, "", got) -} - -func TestCache_Cache_SetBinary_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-setbinary-good") - RequireNoError(t, c.SetBinary(ax7ArtifactBlobKey, []byte("payload"), ax7TextPlain)) - - got, found, err := c.GetBinary(ax7ArtifactBlobKey) - AssertNoError(t, err) - AssertTrue(t, found) - AssertEqual(t, []byte("payload"), got) -} - -func TestCache_Cache_SetBinary_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-setbinary-bad") - err := c.SetBinary(ax7EscapeKey, []byte("payload"), ax7TextPlain) - - AssertError(t, err) - AssertContains(t, err.Error(), "invalid") -} - -func TestCache_Cache_SetBinary_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-setbinary-ugly") - RequireNoError(t, c.SetBinary("artifact/empty", []byte{}, "")) - - got, found, err := c.GetBinary("artifact/empty") - AssertNoError(t, err) - AssertTrue(t, found) - AssertEmpty(t, got) -} - -func TestCache_Cache_SetBinaryWithTTL_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-setbinaryttl-good") - RequireNoError(t, c.SetBinaryWithTTL(ax7ArtifactBlobKey, []byte("payload"), ax7TextPlain, time.Minute)) - - got, found, err := c.GetBinary(ax7ArtifactBlobKey) - AssertNoError(t, err) - AssertTrue(t, found) - AssertEqual(t, []byte("payload"), got) -} - -func TestCache_Cache_SetBinaryWithTTL_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-setbinaryttl-bad") - err := c.SetBinaryWithTTL(ax7ArtifactBlobKey, []byte("payload"), ax7TextPlain, -time.Second) - - AssertError(t, err) - AssertContains(t, err.Error(), "ttl") -} - -func TestCache_Cache_SetBinaryWithTTL_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-setbinaryttl-ugly") - RequireNoError(t, c.SetBinaryWithTTL(ax7ArtifactBlobKey, []byte("payload"), ax7TextPlain, 0)) - - got, found, err := c.GetBinary(ax7ArtifactBlobKey) - AssertNoError(t, err) - AssertFalse(t, found) - AssertNil(t, got) -} - -func TestCache_Cache_GetBinary_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-getbinary-good") - RequireNoError(t, c.SetBinary(ax7ArtifactBlobKey, []byte("payload"), ax7TextPlain)) - - got, found, err := c.GetBinary(ax7ArtifactBlobKey) - AssertNoError(t, err) - AssertTrue(t, found) - AssertEqual(t, "payload", string(got)) -} - -func TestCache_Cache_GetBinary_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-getbinary-bad") - - got, found, err := c.GetBinary("artifact/missing") - AssertNoError(t, err) - AssertFalse(t, found) - AssertNil(t, got) -} - -func TestCache_Cache_GetBinary_Ugly(t *T) { - c, medium := ax7Cache(t, "/tmp/ax7-cache-getbinary-ugly") - RequireNoError(t, medium.Write("/tmp/ax7-cache-getbinary-ugly/artifact/blob.json", "{")) - - got, found, err := c.GetBinary(ax7ArtifactBlobKey) - AssertError(t, err) - AssertFalse(t, found) - AssertNil(t, got) -} - -func TestCache_Cache_Delete_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-delete-good") - RequireNoError(t, c.Set(ax7AgentProfileKey, "codex")) - RequireNoError(t, c.Delete(ax7AgentProfileKey)) - - var got string - found, err := c.Get(ax7AgentProfileKey, &got) - AssertNoError(t, err) - AssertFalse(t, found) - AssertEqual(t, "", got) -} - -func TestCache_Cache_Delete_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-delete-bad") - err := c.Delete(ax7EscapeKey) - - AssertError(t, err) - AssertContains(t, err.Error(), "invalid") -} - -func TestCache_Cache_Delete_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-delete-ugly") - err := c.Delete(ax7AgentMissingKey) - - AssertNoError(t, err) - secondErr := c.Delete(ax7AgentMissingKey) - AssertNoError(t, secondErr) - AssertEqual(t, time.Duration(-1), c.Age(ax7AgentMissingKey)) -} - -func TestCache_Cache_DeleteMany_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-deletemany-good") - RequireNoError(t, c.Set(ax7AgentOneKey, "one")) - RequireNoError(t, c.Set(ax7AgentTwoKey, "two")) - - err := c.DeleteMany(ax7AgentOneKey, ax7AgentTwoKey) - AssertNoError(t, err) - AssertEqual(t, time.Duration(-1), c.Age(ax7AgentOneKey)) - AssertEqual(t, time.Duration(-1), c.Age(ax7AgentTwoKey)) -} - -func TestCache_Cache_DeleteMany_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-deletemany-bad") - RequireNoError(t, c.Set(ax7AgentKeepKey, "value")) - - err := c.DeleteMany(ax7AgentKeepKey, ax7EscapeKey) - AssertError(t, err) - AssertGreaterOrEqual(t, c.Age(ax7AgentKeepKey), time.Duration(0)) - AssertContains(t, err.Error(), "invalid") -} - -func TestCache_Cache_DeleteMany_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-deletemany-ugly") - err := c.DeleteMany() - - AssertNoError(t, err) - secondErr := c.DeleteMany() - AssertNoError(t, secondErr) - AssertEqual(t, time.Duration(-1), c.Age(ax7AgentMissingKey)) -} - -func TestCache_Cache_Clear_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-clear-good") - RequireNoError(t, c.Set(ax7AgentProfileKey, "codex")) - RequireNoError(t, c.Clear()) - - var got string - found, err := c.Get(ax7AgentProfileKey, &got) - AssertNoError(t, err) - AssertFalse(t, found) - AssertEqual(t, "", got) -} - -func TestCache_Cache_Clear_Bad(t *T) { - medium := newScriptedMedium() - medium.deleteAllErr["/tmp/ax7-cache-clear-bad"] = NewError("blocked") - c, err := cache.New(medium, "/tmp/ax7-cache-clear-bad", time.Minute) - RequireNoError(t, err) - - err = c.Clear() - AssertError(t, err) - AssertContains(t, err.Error(), "blocked") -} - -func TestCache_Cache_Clear_Ugly(t *T) { - var c cache.Cache - err := c.Clear() - - AssertError(t, err) - AssertContains(t, err.Error(), "base directory") -} - -func TestCache_Cache_Age_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-age-good") - RequireNoError(t, c.Set(ax7AgentProfileKey, "codex")) - - age := c.Age(ax7AgentProfileKey) - AssertGreaterOrEqual(t, age, time.Duration(0)) - AssertLess(t, age, time.Minute) -} - -func TestCache_Cache_Age_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-age-bad") - age := c.Age(ax7AgentMissingKey) - - AssertEqual(t, time.Duration(-1), age) - AssertLess(t, age, time.Duration(0)) -} - -func TestCache_Cache_Age_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-age-ugly") - age := c.Age(ax7EscapeKey) - - AssertEqual(t, time.Duration(-1), age) - AssertLess(t, age, time.Duration(0)) -} - -func TestCache_Cache_OnInvalidate_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-oninvalidate-good") - RequireNoError(t, c.Set("dns/a", "record")) - c.OnInvalidate("flush", func(trigger string) []string { return []string{"dns/*"} }) - - deleted, err := c.Invalidate("flush") - AssertNoError(t, err) - AssertEqual(t, 1, deleted) -} - -func TestCache_Cache_OnInvalidate_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-oninvalidate-bad") - RequireNoError(t, c.Set("dns/a", "record")) - c.OnInvalidate("flush", nil) - - deleted, err := c.Invalidate("flush") - AssertNoError(t, err) - AssertEqual(t, 0, deleted) -} - -func TestCache_Cache_OnInvalidate_Ugly(t *T) { - var c *cache.Cache - AssertNotPanics(t, func() { - c.OnInvalidate("flush", func(string) []string { return []string{"dns/*"} }) - }) - AssertNil(t, c) -} - -func TestCache_Cache_Invalidate_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-invalidate-good") - RequireNoError(t, c.Set("dns/a", "record")) - c.OnInvalidate("flush", func(string) []string { return []string{"dns/*"} }) - - deleted, err := c.Invalidate("flush") - AssertNoError(t, err) - AssertEqual(t, 1, deleted) -} - -func TestCache_Cache_Invalidate_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-invalidate-bad") - RequireNoError(t, c.Set("dns/a", "record")) - - deleted, err := c.Invalidate("missing") - AssertNoError(t, err) - AssertEqual(t, 0, deleted) - AssertGreaterOrEqual(t, c.Age("dns/a"), time.Duration(0)) -} - -func TestCache_Cache_Invalidate_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-invalidate-ugly") - c.OnInvalidate("flush", func(string) []string { return []string{repeatString("a", 4097)} }) - - deleted, err := c.Invalidate("flush") - AssertError(t, err) - AssertEqual(t, 0, deleted) - AssertContains(t, err.Error(), "too long") -} - -func TestCache_Cache_Scoped_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-scoped-good") - scoped := c.Scoped(ax7AppOrigin) - - AssertNotNil(t, scoped) - AssertNoError(t, scoped.Set("profile", "codex")) - AssertGreaterOrEqual(t, scoped.Age("profile"), time.Duration(0)) -} - -func TestCache_Cache_Scoped_Bad(t *T) { - var c *cache.Cache - scoped := c.Scoped(ax7AppOrigin) - - AssertNil(t, scoped) - AssertNil(t, c) -} - -func TestCache_Cache_Scoped_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-scoped-ugly") - scoped := c.Scoped("") - - AssertNotNil(t, scoped) - AssertNoError(t, scoped.Set("empty-origin", "codex")) - AssertGreaterOrEqual(t, scoped.Age("empty-origin"), time.Duration(0)) -} - -func TestCache_Cache_ClearScope_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-clearscope-good") - scoped := c.Scoped(ax7AppOrigin) - RequireNoError(t, scoped.Set("profile", "codex")) - - err := c.ClearScope(ax7AppOrigin) - AssertNoError(t, err) - AssertEqual(t, time.Duration(-1), scoped.Age("profile")) -} - -func TestCache_Cache_ClearScope_Bad(t *T) { - var c *cache.Cache - err := c.ClearScope(ax7AppOrigin) - - AssertError(t, err) - AssertContains(t, err.Error(), "nil") -} - -func TestCache_Cache_ClearScope_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-cache-clearscope-ugly") - err := c.ClearScope("https://missing.example") - - AssertNoError(t, err) - secondErr := c.ClearScope("") - AssertNoError(t, secondErr) - AssertEqual(t, time.Duration(-1), c.Age("scope_missing")) -} - -func TestCache_ScopedCache_Path_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-path-good") - scoped := c.Scoped(ax7AppOrigin) - - path, err := scoped.Path("profile") - AssertNoError(t, err) - AssertContains(t, path, "scope_") - AssertContains(t, path, "/profile.json") -} - -func TestCache_ScopedCache_Path_Bad(t *T) { - var scoped *cache.ScopedCache - path, err := scoped.Path("profile") - - AssertError(t, err) - AssertEqual(t, "", path) - AssertContains(t, err.Error(), "nil") -} - -func TestCache_ScopedCache_Path_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-path-ugly") - scoped := c.Scoped(ax7AppOrigin) - - path, err := scoped.Path(ax7EscapeKey) - AssertError(t, err) - AssertEqual(t, "", path) - AssertContains(t, err.Error(), "invalid") -} - -func TestCache_ScopedCache_Get_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-get-good") - scoped := c.Scoped(ax7AppOrigin) - RequireNoError(t, scoped.Set("profile", "codex")) - - var got string - found, err := scoped.Get("profile", &got) - AssertNoError(t, err) - AssertTrue(t, found) - AssertEqual(t, "codex", got) -} - -func TestCache_ScopedCache_Get_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-get-bad") - scoped := c.Scoped(ax7AppOrigin) - var got string - - found, err := scoped.Get("missing", &got) - AssertNoError(t, err) - AssertFalse(t, found) - AssertEqual(t, "", got) -} - -func TestCache_ScopedCache_Get_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-get-ugly") - scoped := c.Scoped(ax7AppOrigin) - RequireNoError(t, scoped.Set("profile", "codex")) - - found, err := scoped.Get("profile", nil) - AssertError(t, err) - AssertFalse(t, found) - AssertContains(t, err.Error(), "unmarshal") -} - -func TestCache_ScopedCache_Set_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-set-good") - scoped := c.Scoped(ax7AppOrigin) - err := scoped.Set("profile", "codex") - - AssertNoError(t, err) - AssertGreaterOrEqual(t, scoped.Age("profile"), time.Duration(0)) - AssertEqual(t, time.Duration(-1), c.Age("profile")) -} - -func TestCache_ScopedCache_Set_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-set-bad") - scoped := c.Scoped(ax7AppOrigin) - err := scoped.Set(ax7EscapeKey, "codex") - - AssertError(t, err) - AssertContains(t, err.Error(), "invalid") - AssertEqual(t, time.Duration(-1), scoped.Age(ax7EscapeKey)) -} - -func TestCache_ScopedCache_Set_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-set-ugly") - scoped := c.Scoped(ax7AppOrigin) - err := scoped.Set("handler", map[string]any{"fn": func() { - // Intentionally empty: JSON marshaling rejects function values before invocation. - }}) - - AssertError(t, err) - AssertContains(t, err.Error(), "marshal") - AssertEqual(t, time.Duration(-1), scoped.Age("handler")) -} - -func TestCache_ScopedCache_SetWithTTL_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-setttl-good") - scoped := c.Scoped(ax7AppOrigin) - RequireNoError(t, scoped.SetWithTTL("profile", "codex", time.Minute)) - - var got string - found, err := scoped.Get("profile", &got) - AssertNoError(t, err) - AssertTrue(t, found) - AssertEqual(t, "codex", got) -} - -func TestCache_ScopedCache_SetWithTTL_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-setttl-bad") - scoped := c.Scoped(ax7AppOrigin) - err := scoped.SetWithTTL("profile", "codex", -time.Second) - - AssertError(t, err) - AssertContains(t, err.Error(), "ttl") - AssertEqual(t, time.Duration(-1), scoped.Age("profile")) -} - -func TestCache_ScopedCache_SetWithTTL_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-setttl-ugly") - scoped := c.Scoped(ax7AppOrigin) - RequireNoError(t, scoped.SetWithTTL("profile", "codex", 0)) - - var got string - found, err := scoped.Get("profile", &got) - AssertNoError(t, err) - AssertFalse(t, found) - AssertEqual(t, "", got) -} - -func TestCache_ScopedCache_SetBinary_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-setbinary-good") - scoped := c.Scoped(ax7AppOrigin) - RequireNoError(t, scoped.SetBinary("artifact", []byte("payload"), ax7TextPlain)) - - got, found, err := scoped.GetBinary("artifact") - AssertNoError(t, err) - AssertTrue(t, found) - AssertEqual(t, []byte("payload"), got) -} - -func TestCache_ScopedCache_SetBinary_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-setbinary-bad") - scoped := c.Scoped(ax7AppOrigin) - err := scoped.SetBinary(ax7EscapeKey, []byte("payload"), ax7TextPlain) - - AssertError(t, err) - AssertContains(t, err.Error(), "invalid") - AssertEqual(t, time.Duration(-1), scoped.Age(ax7EscapeKey)) -} - -func TestCache_ScopedCache_SetBinary_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-setbinary-ugly") - scoped := c.Scoped(ax7AppOrigin) - RequireNoError(t, scoped.SetBinary("artifact", []byte{}, "")) - - got, found, err := scoped.GetBinary("artifact") - AssertNoError(t, err) - AssertTrue(t, found) - AssertEmpty(t, got) -} - -func TestCache_ScopedCache_SetBinaryWithTTL_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-setbinaryttl-good") - scoped := c.Scoped(ax7AppOrigin) - RequireNoError(t, scoped.SetBinaryWithTTL("artifact", []byte("payload"), ax7TextPlain, time.Minute)) - - got, found, err := scoped.GetBinary("artifact") - AssertNoError(t, err) - AssertTrue(t, found) - AssertEqual(t, []byte("payload"), got) -} - -func TestCache_ScopedCache_SetBinaryWithTTL_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-setbinaryttl-bad") - scoped := c.Scoped(ax7AppOrigin) - err := scoped.SetBinaryWithTTL("artifact", []byte("payload"), ax7TextPlain, -time.Second) - - AssertError(t, err) - AssertContains(t, err.Error(), "ttl") - AssertEqual(t, time.Duration(-1), scoped.Age("artifact")) -} - -func TestCache_ScopedCache_SetBinaryWithTTL_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-setbinaryttl-ugly") - scoped := c.Scoped(ax7AppOrigin) - RequireNoError(t, scoped.SetBinaryWithTTL("artifact", []byte("payload"), ax7TextPlain, 0)) - - got, found, err := scoped.GetBinary("artifact") - AssertNoError(t, err) - AssertFalse(t, found) - AssertNil(t, got) -} - -func TestCache_ScopedCache_GetBinary_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-getbinary-good") - scoped := c.Scoped(ax7AppOrigin) - RequireNoError(t, scoped.SetBinary("artifact", []byte("payload"), ax7TextPlain)) - - got, found, err := scoped.GetBinary("artifact") - AssertNoError(t, err) - AssertTrue(t, found) - AssertEqual(t, "payload", string(got)) -} - -func TestCache_ScopedCache_GetBinary_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-getbinary-bad") - scoped := c.Scoped(ax7AppOrigin) - - got, found, err := scoped.GetBinary("missing") - AssertNoError(t, err) - AssertFalse(t, found) - AssertNil(t, got) -} - -func TestCache_ScopedCache_GetBinary_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-getbinary-ugly") - scoped := c.Scoped(ax7AppOrigin) - - got, found, err := scoped.GetBinary(ax7EscapeKey) - AssertError(t, err) - AssertFalse(t, found) - AssertNil(t, got) -} - -func TestCache_ScopedCache_Delete_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-delete-good") - scoped := c.Scoped(ax7AppOrigin) - RequireNoError(t, scoped.Set("profile", "codex")) - - err := scoped.Delete("profile") - AssertNoError(t, err) - AssertEqual(t, time.Duration(-1), scoped.Age("profile")) -} - -func TestCache_ScopedCache_Delete_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-delete-bad") - scoped := c.Scoped(ax7AppOrigin) - err := scoped.Delete(ax7EscapeKey) - - AssertError(t, err) - AssertContains(t, err.Error(), "invalid") - AssertEqual(t, time.Duration(-1), scoped.Age(ax7EscapeKey)) -} - -func TestCache_ScopedCache_Delete_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-delete-ugly") - scoped := c.Scoped(ax7AppOrigin) - err := scoped.Delete("missing") - - AssertNoError(t, err) - secondErr := scoped.Delete("missing") - AssertNoError(t, secondErr) - AssertEqual(t, time.Duration(-1), scoped.Age("missing")) -} - -func TestCache_ScopedCache_DeleteMany_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-deletemany-good") - scoped := c.Scoped(ax7AppOrigin) - RequireNoError(t, scoped.Set("one", "1")) - RequireNoError(t, scoped.Set("two", "2")) - - err := scoped.DeleteMany("one", "two") - AssertNoError(t, err) - AssertEqual(t, time.Duration(-1), scoped.Age("one")) - AssertEqual(t, time.Duration(-1), scoped.Age("two")) -} - -func TestCache_ScopedCache_DeleteMany_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-deletemany-bad") - scoped := c.Scoped(ax7AppOrigin) - RequireNoError(t, scoped.Set("keep", "value")) - - err := scoped.DeleteMany("keep", ax7EscapeKey) - AssertError(t, err) - AssertGreaterOrEqual(t, scoped.Age("keep"), time.Duration(0)) - AssertContains(t, err.Error(), "invalid") -} - -func TestCache_ScopedCache_DeleteMany_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-deletemany-ugly") - scoped := c.Scoped(ax7AppOrigin) - err := scoped.DeleteMany() - - AssertNoError(t, err) - secondErr := scoped.DeleteMany() - AssertNoError(t, secondErr) - AssertEqual(t, time.Duration(-1), scoped.Age("missing")) -} - -func TestCache_ScopedCache_Clear_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-clear-good") - scoped := c.Scoped(ax7AppOrigin) - RequireNoError(t, scoped.Set("profile", "codex")) - RequireNoError(t, c.Set("profile", "root")) - - err := scoped.Clear() - AssertNoError(t, err) - AssertEqual(t, time.Duration(-1), scoped.Age("profile")) - AssertGreaterOrEqual(t, c.Age("profile"), time.Duration(0)) -} - -func TestCache_ScopedCache_Clear_Bad(t *T) { - var scoped *cache.ScopedCache - err := scoped.Clear() - - AssertError(t, err) - AssertContains(t, err.Error(), "nil") -} - -func TestCache_ScopedCache_Clear_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-clear-ugly") - scoped := c.Scoped(ax7AppOrigin) - err := scoped.Clear() - - AssertNoError(t, err) - secondErr := scoped.Clear() - AssertNoError(t, secondErr) - AssertEqual(t, time.Duration(-1), scoped.Age("missing")) -} - -func TestCache_ScopedCache_ClearScope_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-clearscope-good") - scoped := c.Scoped(ax7AppOrigin) - admin := scoped.Scoped(ax7AdminOrigin) - RequireNoError(t, admin.Set("profile", "admin")) - - err := scoped.ClearScope(ax7AdminOrigin) - AssertNoError(t, err) - AssertEqual(t, time.Duration(-1), admin.Age("profile")) -} - -func TestCache_ScopedCache_ClearScope_Bad(t *T) { - var scoped *cache.ScopedCache - err := scoped.ClearScope(ax7AdminOrigin) - - AssertError(t, err) - AssertContains(t, err.Error(), "nil") -} - -func TestCache_ScopedCache_ClearScope_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-clearscope-ugly") - scoped := c.Scoped(ax7AppOrigin) - err := scoped.ClearScope("") - - AssertNoError(t, err) - secondErr := scoped.ClearScope("") - AssertNoError(t, secondErr) - AssertEqual(t, time.Duration(-1), scoped.Age("missing")) -} - -func TestCache_ScopedCache_OnInvalidate_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-oninvalidate-good") - scoped := c.Scoped(ax7AppOrigin) - RequireNoError(t, scoped.Set(ax7PrefsThemeKey, "dark")) - scoped.OnInvalidate("flush", func(string) []string { return []string{ax7PrefsPattern} }) - - deleted, err := c.Invalidate("flush") - AssertNoError(t, err) - AssertEqual(t, 1, deleted) -} - -func TestCache_ScopedCache_OnInvalidate_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-oninvalidate-bad") - scoped := c.Scoped(ax7AppOrigin) - RequireNoError(t, scoped.Set(ax7PrefsThemeKey, "dark")) - scoped.OnInvalidate("flush", nil) - - deleted, err := c.Invalidate("flush") - AssertNoError(t, err) - AssertEqual(t, 0, deleted) -} - -func TestCache_ScopedCache_OnInvalidate_Ugly(t *T) { - var scoped *cache.ScopedCache - AssertNotPanics(t, func() { - scoped.OnInvalidate("flush", func(string) []string { return []string{ax7PrefsPattern} }) - }) - AssertNil(t, scoped) -} - -func TestCache_ScopedCache_Invalidate_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-invalidate-good") - scoped := c.Scoped(ax7AppOrigin) - RequireNoError(t, scoped.Set(ax7PrefsThemeKey, "dark")) - scoped.OnInvalidate("flush", func(string) []string { return []string{ax7PrefsPattern} }) - - deleted, err := scoped.Invalidate("flush") - AssertNoError(t, err) - AssertEqual(t, 1, deleted) -} - -func TestCache_ScopedCache_Invalidate_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-invalidate-bad") - scoped := c.Scoped(ax7AppOrigin) - RequireNoError(t, scoped.Set(ax7PrefsThemeKey, "dark")) - - deleted, err := scoped.Invalidate("missing") - AssertNoError(t, err) - AssertEqual(t, 0, deleted) -} - -func TestCache_ScopedCache_Invalidate_Ugly(t *T) { - var scoped *cache.ScopedCache - deleted, err := scoped.Invalidate("flush") - - AssertError(t, err) - AssertEqual(t, 0, deleted) - AssertContains(t, err.Error(), "nil") -} - -func TestCache_ScopedCache_Age_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-age-good") - scoped := c.Scoped(ax7AppOrigin) - RequireNoError(t, scoped.Set("profile", "codex")) - - age := scoped.Age("profile") - AssertGreaterOrEqual(t, age, time.Duration(0)) - AssertLess(t, age, time.Minute) -} - -func TestCache_ScopedCache_Age_Bad(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-age-bad") - scoped := c.Scoped(ax7AppOrigin) - age := scoped.Age("missing") - - AssertEqual(t, time.Duration(-1), age) - AssertLess(t, age, time.Duration(0)) -} - -func TestCache_ScopedCache_Age_Ugly(t *T) { - var scoped *cache.ScopedCache - age := scoped.Age("profile") - - AssertEqual(t, time.Duration(-1), age) - AssertLess(t, age, time.Duration(0)) -} - -func TestCache_ScopedCache_Scoped_Good(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-scoped-good") - scoped := c.Scoped(ax7AppOrigin) - admin := scoped.Scoped(ax7AdminOrigin) - - AssertNotNil(t, admin) - AssertNoError(t, admin.Set("profile", "admin")) - AssertEqual(t, time.Duration(-1), scoped.Age("profile")) -} - -func TestCache_ScopedCache_Scoped_Bad(t *T) { - var scoped *cache.ScopedCache - admin := scoped.Scoped(ax7AdminOrigin) - - AssertNil(t, admin) - AssertNil(t, scoped) -} - -func TestCache_ScopedCache_Scoped_Ugly(t *T) { - c, _ := ax7Cache(t, "/tmp/ax7-scoped-scoped-ugly") - scoped := c.Scoped(ax7AppOrigin) - empty := scoped.Scoped("") - - AssertNotNil(t, empty) - AssertNoError(t, empty.Set("profile", "empty")) - AssertGreaterOrEqual(t, empty.Age("profile"), time.Duration(0)) -} - -func TestCache_CacheStorage_Open_Good(t *T) { - storage, _ := ax7Storage(t, "/tmp/ax7-storage-open-good") - first, err := storage.Open("static") - RequireNoError(t, err) - - second, err := storage.Open("static") - AssertNoError(t, err) - AssertSame(t, first, second) - AssertNotNil(t, second) -} - -func TestCache_CacheStorage_Open_Bad(t *T) { - storage, _ := ax7Storage(t, "/tmp/ax7-storage-open-bad") - httpCache, err := storage.Open(ax7EscapeKey) - - AssertError(t, err) - AssertNil(t, httpCache) - AssertContains(t, err.Error(), "invalid") -} - -func TestCache_CacheStorage_Open_Ugly(t *T) { - var storage cache.CacheStorage - httpCache, err := storage.Open("static") - - AssertError(t, err) - AssertNil(t, httpCache) - AssertContains(t, err.Error(), "medium") -} - -func TestCache_CacheStorage_Delete_Good(t *T) { - storage, _ := ax7Storage(t, "/tmp/ax7-storage-delete-good") - _, err := storage.Open("static") - RequireNoError(t, err) - - err = storage.Delete("static") - AssertNoError(t, err) - keys, err := storage.Keys() - AssertNoError(t, err) - AssertNotContains(t, keys, "static") -} - -func TestCache_CacheStorage_Delete_Bad(t *T) { - storage, _ := ax7Storage(t, "/tmp/ax7-storage-delete-bad") - err := storage.Delete(ax7EscapeKey) - - AssertError(t, err) - AssertContains(t, err.Error(), "invalid") -} - -func TestCache_CacheStorage_Delete_Ugly(t *T) { - storage, _ := ax7Storage(t, "/tmp/ax7-storage-delete-ugly") - err := storage.Delete("missing") - - AssertNoError(t, err) - keys, err := storage.Keys() - AssertNoError(t, err) - AssertEmpty(t, keys) -} - -func TestCache_CacheStorage_Keys_Good(t *T) { - storage, _ := ax7Storage(t, "/tmp/ax7-storage-keys-good") - _, err := storage.Open("api") - RequireNoError(t, err) - _, err = storage.Open("static") - RequireNoError(t, err) - - keys, err := storage.Keys() - AssertNoError(t, err) - AssertEqual(t, []string{"api", "static"}, keys) -} - -func TestCache_CacheStorage_Keys_Bad(t *T) { - medium := newScriptedMedium() - storage, err := cache.NewCacheStorage(medium, "/tmp/ax7-storage-keys-bad") - RequireNoError(t, err) - medium.listErr["/tmp/ax7-storage-keys-bad"] = NewError(ax7ListFailed) - - keys, err := storage.Keys() - AssertError(t, err) - AssertNil(t, keys) - AssertContains(t, err.Error(), ax7ListFailed) -} - -func TestCache_CacheStorage_Keys_Ugly(t *T) { - storage, _ := ax7Storage(t, "/tmp/ax7-storage-keys-ugly") - _, err := storage.Open("static") - RequireNoError(t, err) - RequireNoError(t, storage.Close()) - - keys, err := storage.Keys() - AssertNoError(t, err) - AssertContains(t, keys, "static") -} - -func TestCache_CacheStorage_Close_Good(t *T) { - storage, _ := ax7Storage(t, "/tmp/ax7-storage-close-good") - _, err := storage.Open("static") - RequireNoError(t, err) - - err = storage.Close() - AssertNoError(t, err) - secondErr := storage.Close() - AssertNoError(t, secondErr) -} - -func TestCache_CacheStorage_Close_Bad(t *T) { - var storage *cache.CacheStorage - err := storage.Close() - - AssertNoError(t, err) - AssertNil(t, storage) - secondErr := storage.Close() - AssertNoError(t, secondErr) -} - -func TestCache_CacheStorage_Close_Ugly(t *T) { - var storage cache.CacheStorage - err := storage.Close() - - AssertNoError(t, err) - _, err = storage.Keys() - AssertError(t, err) -} - -func TestCache_HTTPCache_Match_Good(t *T) { - httpCache, _ := ax7HTTPCache(t, "/tmp/ax7-http-match-good", "api") - req := ax7Request("GET", ax7HTTPDataURL) - RequireNoError(t, httpCache.Put(req, ax7Response(200), []byte("body"))) - - resp, err := httpCache.Match(req) - AssertNoError(t, err) - AssertNotNil(t, resp) - AssertEqual(t, 200, resp.Status) -} - -func TestCache_HTTPCache_Match_Ugly(t *T) { - httpCache, medium := ax7HTTPCache(t, ax7HTTPMatchUglyDir, "api") - req := ax7Request("GET", "https://example.com/legacy") - key := legacyHTTPCacheStorageKey(req) - resp := ax7Response(203) - resp.BodyPath = JoinPath("responses", key+".bin") - RequireNoError(t, medium.Write(JoinPath(ax7HTTPMatchUglyDir, "api", "responses", key+".json"), JSONMarshalString(resp))) - RequireNoError(t, medium.Write(JoinPath(ax7HTTPMatchUglyDir, "api", "responses", key+".bin"), "legacy")) - - got, err := httpCache.Match(req) - AssertNoError(t, err) - AssertEqual(t, 203, got.Status) -} - -func TestCache_HTTPCache_Put_Good(t *T) { - httpCache, _ := ax7HTTPCache(t, "/tmp/ax7-http-put-good", "api") - req := ax7Request("POST", ax7HTTPDataURL) - err := httpCache.Put(req, ax7Response(201), []byte("created")) - - AssertNoError(t, err) - resp, err := httpCache.Match(req) - AssertNoError(t, err) - AssertEqual(t, 201, resp.Status) -} - -func TestCache_HTTPCache_ReadBody_Good(t *T) { - httpCache, _ := ax7HTTPCache(t, "/tmp/ax7-http-readbody-good", "api") - req := ax7Request("GET", ax7HTTPDataURL) - RequireNoError(t, httpCache.Put(req, ax7Response(200), []byte("body"))) - resp, err := httpCache.Match(req) - RequireNoError(t, err) - - body, err := httpCache.ReadBody(resp) - AssertNoError(t, err) - AssertEqual(t, []byte("body"), body) -} - -func TestCache_HTTPCache_ReadBody_Bad(t *T) { - httpCache, _ := ax7HTTPCache(t, "/tmp/ax7-http-readbody-bad", "api") - body, err := httpCache.ReadBody(nil) - - AssertError(t, err) - AssertNil(t, body) - AssertContains(t, err.Error(), "nil") -} - -func TestCache_HTTPCache_ReadBody_Ugly(t *T) { - httpCache, _ := ax7HTTPCache(t, "/tmp/ax7-http-readbody-ugly", "api") - resp := &cache.CachedResponse{BodyPath: "../escape.bin", Status: 200} - - body, err := httpCache.ReadBody(resp) - AssertError(t, err) - AssertNil(t, body) - AssertContains(t, err.Error(), "invalid") -} - -func TestCache_HTTPCache_Delete_Good(t *T) { - httpCache, _ := ax7HTTPCache(t, "/tmp/ax7-http-delete-good", "api") - req := ax7Request("GET", ax7HTTPDataURL) - RequireNoError(t, httpCache.Put(req, ax7Response(200), []byte("body"))) - - err := httpCache.Delete(req) - AssertNoError(t, err) - resp, err := httpCache.Match(req) - AssertNoError(t, err) - AssertNil(t, resp) -} - -func TestCache_HTTPCache_Delete_Bad(t *T) { - httpCache, _ := ax7HTTPCache(t, "/tmp/ax7-http-delete-bad", "api") - err := httpCache.Delete(cache.CachedRequest{}) - - AssertError(t, err) - AssertContains(t, err.Error(), "invalid") -} - -func TestCache_HTTPCache_Delete_Ugly(t *T) { - httpCache, _ := ax7HTTPCache(t, "/tmp/ax7-http-delete-ugly", "api") - req := ax7Request("GET", "https://example.com/missing") - err := httpCache.Delete(req) - - AssertNoError(t, err) - resp, err := httpCache.Match(req) - AssertNoError(t, err) - AssertNil(t, resp) -} - -func TestCache_HTTPCache_Keys_Bad(t *T) { - medium := newScriptedMedium() - storage, err := cache.NewCacheStorage(medium, "/tmp/ax7-http-keys-bad") - RequireNoError(t, err) - httpCache, err := storage.Open("api") - RequireNoError(t, err) - medium.listErr["/tmp/ax7-http-keys-bad/api/responses"] = NewError(ax7ListFailed) - - urls, err := httpCache.Keys() - AssertError(t, err) - AssertNil(t, urls) - AssertContains(t, err.Error(), ax7ListFailed) -} - -func TestCache_HTTPCache_Keys_Ugly(t *T) { - httpCache, medium := ax7HTTPCache(t, "/tmp/ax7-http-keys-ugly", "api") - RequireNoError(t, medium.Write("/tmp/ax7-http-keys-ugly/api/responses/bad.json", "{")) - - urls, err := httpCache.Keys() - AssertNoError(t, err) - AssertEmpty(t, urls) - AssertNotContains(t, urls, "https://example.com") -} diff --git a/cache_test.go b/cache_test.go deleted file mode 100644 index c0015ea..0000000 --- a/cache_test.go +++ /dev/null @@ -1,3145 +0,0 @@ -// SPDX-License-Identifier: EUPL-1.2 - -package cache_test - -import ( - // Note: AX-6 — test-only, replicates internal key derivation for black-box assertion. Retain. - "crypto/sha256" - // Note: AX-6 — test-only, replicates internal key derivation for black-box assertion. Retain. - "encoding/base64" - // Note: AX-6 — test-only, replicates internal key derivation for black-box assertion. Retain. - "encoding/hex" - // Note: AX-6 — test-only, replicates internal key derivation for black-box assertion. Retain. - "encoding/json" - // Note: AX-6 — test-only fs interfaces returned by scriptedMedium and fs.ErrNotExist assertions. - "io/fs" - // Note: AX-6 — test-only symlink setup; no core equivalent for os.Symlink. - "os" - "runtime" - "sync" - "sync/atomic" - "testing" - "time" - - core "dappco.re/go" - "dappco.re/go/cache" - coreio "dappco.re/go/io" -) - -const ( - testSetFailed = "Set failed: %v" - testPathFailed = "Path failed: %v" - testNewCacheStorageFailed = "NewCacheStorage failed: %v" - testOpenFailed = "Open failed: %v" - testTraversalKey = "../../etc/passwd" - testNewFailed = "New failed: %v" - testCacheKey = "test-key" - testGetFailed = "Get failed: %v" - testMalformedJSON = "{not-json" - testMarshalFailed = "Marshal failed: %v" - testTextPlain = "text/plain" - testWantDeleteBackendFailure = "expected Delete to surface backend failure" - testAppOrigin = "https://app.example.com" - testSetWithTTLFailed = "SetWithTTL failed: %v" - testApplicationWasm = "application/wasm" - testSetBinaryFailed = "SetBinary failed: %v" - testGetBinaryFailed = "GetBinary failed: %v" - testApplicationOctetStream = "application/octet-stream" - testSetBinaryWithTTLFailed = "SetBinaryWithTTL failed: %v" - testAdminOrigin = "https://admin.example.com" - testAppUser = "app-user" - testUserProfileKey = "user/profile" - testAppSetFailed = "app Set failed: %v" - testAdminUser = "admin-user" - testAdminSetFailed = "admin Set failed: %v" - testConfigThemeKey = "config/theme" - testInvalidateFailed = "Invalidate failed: %v" - testDNSExampleAKey = "dns/example.com/A" - testDNSTreeRootChanged = "dns.tree-root-changed" - testDNSChanged = "dns.changed" - testHTTPCacheName = "my-app-v1" - testStorageOpenFailed = "storage.Open failed: %v" - testStyleURL = "https://example.com/style.css" - testHeaderContentType = "Content-Type" - testTextCSS = "text/css" - testPutFailed = "Put failed: %v" - testMatchFailed = "Match failed: %v" - testResponsesPrefix = "responses/" - testExampleAURL = "https://example.com/a" - testRaceMixedKey = "race/mixed" - testRaceKey = "race/key" -) - -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() - - m := coreio.NewMockMedium() - c, err := cache.New(m, baseDir, ttl) - if err != nil { - t.Fatalf("failed to create cache: %v", err) - } - - return c, m -} - -func readEntry(t *testing.T, raw string) cache.Entry { - t.Helper() - - var entry cache.Entry - result := core.JSONUnmarshalString(raw, &entry) - if !result.OK { - t.Fatalf("failed to unmarshal cache entry: %v", result.Value) - } - - return entry -} - -func httpCacheStorageKey(req cache.CachedRequest) string { - sum := sha256.Sum256([]byte(req.Method + "\x00" + req.URL)) - return hex.EncodeToString(sum[:]) -} - -func legacyHTTPCacheStorageKey(req cache.CachedRequest) string { - return base64.RawURLEncoding.EncodeToString([]byte(req.Method + "\x00" + req.URL)) -} - -func repeatString(s string, count int) string { - builder := core.NewBuilder() - for range count { - builder.WriteString(s) - } - return builder.String() -} - -func stableTempDir(t *testing.T) string { - t.Helper() - - tmpRoot := core.JoinPath(core.Env("DIR_CWD"), ".core", "test-tmp") - if err := coreio.Local.EnsureDir(tmpRoot); err != nil { - t.Fatalf("EnsureDir temp root failed: %v", err) - } - t.Setenv("TMPDIR", tmpRoot) - return t.TempDir() -} - -func TestCache_New_Good(t *testing.T) { - tmpDir := stableTempDir(t) - t.Chdir(tmpDir) - t.Setenv("PWD", "") - t.Setenv("DIR_CWD", "") - - c, m := newTestCache(t, "", 0) - - const key = "defaults" - if err := c.Set(key, map[string]string{"foo": "bar"}); err != nil { - t.Fatalf(testSetFailed, err) - } - - path, err := c.Path(key) - if err != nil { - t.Fatalf(testPathFailed, err) - } - - wantPath := core.JoinPath(tmpDir, ".core", "cache", key+".json") - if path != wantPath { - t.Fatalf("expected default path %q, got %q", wantPath, path) - } - - raw, err := m.Read(path) - if err != nil { - t.Fatalf("Read failed: %v", err) - } - if !core.Contains(raw, "\n \"data\":") { - t.Fatalf("expected pretty-printed cache entry, got %q", raw) - } - - entry := readEntry(t, raw) - ttl := entry.ExpiresAt.Sub(entry.CachedAt) - if ttl < cache.DefaultTTL || ttl > cache.DefaultTTL+time.Second { - t.Fatalf("expected ttl near %v, got %v", cache.DefaultTTL, ttl) - } -} - -func TestCache_New_Bad(t *testing.T) { - _, err := cache.New(coreio.NewMockMedium(), "/tmp/cache-negative-ttl", -time.Second) - if err == nil { - t.Fatal("expected New to reject negative ttl, got nil") - } -} - -func TestCache_New_Bad_EnsureDirFailure(t *testing.T) { - medium := newScriptedMedium() - medium.ensureDirErr["/tmp/cache-new-backend-bad"] = core.E("cache_test", "boom", nil) - - if _, err := cache.New(medium, "/tmp/cache-new-backend-bad", time.Minute); err == nil { - t.Fatal("expected New to surface backend failure") - } -} - -func TestCache_NewCacheStorage_Good(t *testing.T) { - tmpDir := stableTempDir(t) - t.Chdir(tmpDir) - t.Setenv("PWD", "") - t.Setenv("DIR_CWD", "") - - storage, err := cache.NewCacheStorage(nil, "") - if err != nil { - t.Fatalf(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("assets-v1") - if err != nil { - t.Fatalf(testOpenFailed, err) - } - if httpCache == nil { - t.Fatal("expected Open to return a cache") - } - - wantDir := core.JoinPath(tmpDir, ".core", "cache-storage", "assets-v1") - info, err := coreio.Local.Stat(wantDir) - if err != nil { - t.Fatalf("expected default cache storage directory to exist: %v", err) - } - if !info.IsDir() { - t.Fatalf("expected %q to be a directory", wantDir) - } -} - -func TestCache_NewCacheStorage_Bad(t *testing.T) { - medium := newScriptedMedium() - medium.ensureDirErr["/tmp/cache-storage-bad"] = core.E("cache_test", "boom", nil) - - if _, err := cache.NewCacheStorage(medium, "/tmp/cache-storage-bad"); err == nil { - t.Fatal("expected NewCacheStorage to surface backend failure") - } -} - -func TestCache_SetWithTTL_Bad(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-setwithttl-bad", time.Minute) - - if err := c.SetWithTTL("session/bad", map[string]any{"handler": func() { - // Intentionally empty: JSON marshaling rejects function values before invocation. - }}, -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() { - // Intentionally empty: JSON marshaling rejects function values before invocation. - }}, 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) - - path, err := c.Path("github/acme/repos") - if err != nil { - t.Fatalf(testPathFailed, err) - } - - want := "/tmp/cache-path/github/acme/repos.json" - if path != want { - t.Fatalf("expected path %q, got %q", want, path) - } -} - -func TestCache_Path_Bad(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-traversal", time.Minute) - - tests := []struct { - name string - key string - }{ - {name: "empty", key: ""}, - {name: "traversal", key: testTraversalKey}, - {name: "dot", key: "."}, - {name: "backslash", key: `foo\bar`}, - {name: "null-byte", key: "foo\x00bar"}, - {name: "too-long", key: repeatString("a", 4097)}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if _, err := c.Path(tt.key); err == nil { - t.Fatalf("expected Path to reject %q", tt.key) - } - }) - } -} - -func TestCache_Path_PathTraversalSymlink_Bad(t *testing.T) { - tmpDir := t.TempDir() - baseDir := core.JoinPath(tmpDir, "cache") - outsideDir := core.JoinPath(tmpDir, "outside") - linkPath := core.JoinPath(baseDir, "link") - - if err := coreio.Local.EnsureDir(baseDir); err != nil { - t.Fatalf("EnsureDir base failed: %v", err) - } - if err := coreio.Local.EnsureDir(outsideDir); err != nil { - t.Fatalf("EnsureDir outside failed: %v", err) - } - if err := os.Symlink(outsideDir, linkPath); err != nil { - t.Skipf("symlink not supported: %v", err) - } - - c, err := cache.New(coreio.Local, baseDir, time.Minute) - if err != nil { - t.Fatalf(testNewFailed, err) - } - - if _, err := c.Path("link/escaped"); err == nil { - t.Fatal("expected Path to reject symlink traversal under baseDir") - } - if err := c.Set("link/escaped", "owned"); err == nil { - t.Fatal("expected Set to reject symlink traversal under baseDir") - } - if _, err := coreio.Local.Stat(core.JoinPath(outsideDir, "escaped.json")); err == nil { - t.Fatal("expected escaped file not to be written outside baseDir") - } else if !core.Is(err, fs.ErrNotExist) { - t.Fatalf("Stat outside file failed: %v", err) - } -} - -func TestCache_Get_Good(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache", time.Minute) - - key := testCacheKey - data := map[string]string{"foo": "bar"} - - if err := c.Set(key, data); err != nil { - t.Fatalf(testSetFailed, err) - } - - var retrieved map[string]string - found, err := c.Get(key, &retrieved) - if err != nil { - t.Fatalf(testGetFailed, err) - } - if !found { - t.Fatal("expected to find cached item") - } - if retrieved["foo"] != "bar" { - t.Errorf("expected foo=bar, got %v", retrieved["foo"]) - } -} - -func TestCache_Get_Ugly(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-expiry", 10*time.Millisecond) - - if err := c.Set(testCacheKey, map[string]string{"foo": "bar"}); err != nil { - t.Fatalf("Set for expiry test failed: %v", err) - } - - time.Sleep(50 * time.Millisecond) - - var retrieved map[string]string - found, err := c.Get(testCacheKey, &retrieved) - if err != nil { - t.Fatalf("Get for expired item returned an unexpected error: %v", err) - } - if found { - t.Error("expected item to be expired") - } -} - -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(testPathFailed, err) - } - m.Files[path] = testMalformedJSON - - 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(testPathFailed, 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(testMarshalFailed, 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) - - if err := c.Set(testCacheKey, map[string]string{"foo": "bar"}); err != nil { - t.Fatalf(testSetFailed, err) - } - - if age := c.Age(testCacheKey); age < 0 { - t.Errorf("expected age >= 0, got %v", age) - } -} - -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(testPathFailed, err) - } - m.Files[path] = testMalformedJSON - - 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 - - if _, err := c.Path("x"); err == nil { - t.Fatal("expected Path to fail on nil receiver") - } - - if _, err := c.Get("x", &target); err == nil { - t.Fatal("expected Get to fail on nil receiver") - } - - 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"), testTextPlain); err == nil { - t.Fatal("expected SetBinary to fail on nil receiver") - } - if err := c.SetBinaryWithTTL("x", []byte("body"), testTextPlain, 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") - } - - if err := c.Clear(); err == nil { - t.Fatal("expected Clear to fail on nil receiver") - } - - if age := c.Age("x"); age != -1 { - t.Fatalf("expected Age to return -1 on nil receiver, got %v", age) - } -} - -func TestCache_ZeroValue_Ugly(t *testing.T) { - var c cache.Cache - var target map[string]string - - if _, err := c.Path("x"); err == nil { - t.Fatal("expected Path to fail on zero-value cache") - } - - if _, err := c.Get("x", &target); err == nil { - t.Fatal("expected Get to fail on zero-value cache") - } - - 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"), testTextPlain); err == nil { - t.Fatal("expected SetBinary to fail on zero-value cache") - } - if err := c.SetBinaryWithTTL("x", []byte("body"), testTextPlain, 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") - } - - if err := c.Clear(); err == nil { - t.Fatal("expected Clear to fail on zero-value cache") - } - - if age := c.Age("x"); age != -1 { - t.Fatalf("expected Age to return -1 on zero-value cache, got %v", age) - } -} - -func TestCache_Delete_Good(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-delete", time.Minute) - - if err := c.Set(testCacheKey, map[string]string{"foo": "bar"}); err != nil { - t.Fatalf(testSetFailed, err) - } - - if err := c.Delete(testCacheKey); err != nil { - t.Fatalf("Delete failed: %v", err) - } - - var retrieved map[string]string - found, err := c.Get(testCacheKey, &retrieved) - if err != nil { - t.Fatalf("Get after delete returned an unexpected error: %v", err) - } - if found { - t.Error("expected item to be deleted") - } -} - -func TestCache_Delete_Bad(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-delete-bad", time.Minute) - - if err := c.Delete(testTraversalKey); 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(testNewFailed, err) - } - - key := "delete/backend" - path, err := c.Path(key) - if err != nil { - t.Fatalf(testPathFailed, err) - } - medium.deleteErr[path] = core.E("cache_test", "boom", nil) - - if err := c.Delete(key); err == nil { - t.Fatal(testWantDeleteBackendFailure) - } -} - -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"} - - if err := c.Set("key1", data); err != nil { - t.Fatalf("Set failed for key1: %v", err) - } - if err := c.Set("key2", data); err != nil { - t.Fatalf("Set failed for key2: %v", err) - } - if err := c.DeleteMany("key1", "missing", "key2"); err != nil { - t.Fatalf("DeleteMany failed: %v", err) - } - - var retrieved map[string]string - found, err := c.Get("key1", &retrieved) - if err != nil { - t.Fatalf("Get after DeleteMany returned an unexpected error: %v", err) - } - if found { - t.Error("expected key1 to be deleted") - } - - found, err = c.Get("key2", &retrieved) - if err != nil { - t.Fatalf("Get after DeleteMany returned an unexpected error: %v", err) - } - if found { - t.Error("expected key2 to be deleted") - } -} - -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", testTraversalKey, "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"} - - if err := c.Set("key1", data); err != nil { - t.Fatalf("Set for clear test failed for key1: %v", err) - } - if err := c.Set("key2", data); err != nil { - t.Fatalf("Set for clear test failed for key2: %v", err) - } - if err := c.Clear(); err != nil { - t.Fatalf("Clear failed: %v", err) - } - - var retrieved map[string]string - found, err := c.Get("key1", &retrieved) - if err != nil { - t.Fatalf("Get after clear returned an unexpected error: %v", err) - } - if found { - t.Error("expected key1 to be cleared") - } -} - -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(testNewFailed, err) - } - - medium.deleteAllErr["/tmp/cache-clear-bad"] = core.E("cache_test", "boom", nil) - - if err := c.Clear(); err == nil { - t.Fatal("expected Clear to surface backend failure") - } -} - -func TestCache_ClearScope_Bad_ListFailure(t *testing.T) { - medium := newScriptedMedium() - c, err := cache.New(medium, "/tmp/cache-clear-scope-bad", time.Minute) - if err != nil { - t.Fatalf(testNewFailed, err) - } - - medium.listErr["/tmp/cache-clear-scope-bad"] = core.E("cache_test", "boom", nil) - - if err := c.ClearScope(testAppOrigin); 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" { - t.Errorf("unexpected GitHubReposKey: %q", key) - } -} - -func TestCache_GitHubReposKey_EscapesUnsafeSegments(t *testing.T) { - key := cache.GitHubReposKey("my/org") - if key != "github/my%2Forg/repos" { - t.Fatalf("unexpected escaped GitHubReposKey: %q", key) - } -} - -func TestCache_GitHubRepoKey_Good(t *testing.T) { - key := cache.GitHubRepoKey("myorg", "myrepo") - if key != "github/myorg/myrepo/meta" { - t.Errorf("unexpected GitHubRepoKey: %q", key) - } -} - -func TestCache_GitHubRepoKey_EscapesUnsafeSegments(t *testing.T) { - key := cache.GitHubRepoKey("my/org", "widgets/v2") - if key != "github/my%2Forg/widgets%2Fv2/meta" { - t.Fatalf("unexpected escaped GitHubRepoKey: %q", key) - } -} - -func TestCache_SetWithTTL_Good(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-set-with-ttl", 10*time.Minute) - - key := "session/short" - err := c.SetWithTTL(key, map[string]string{"token": "abc"}, 20*time.Millisecond) - if err != nil { - t.Fatalf(testSetWithTTLFailed, err) - } - - var dest map[string]string - found, err := c.Get(key, &dest) - if err != nil { - t.Fatalf("Get before expiry failed: %v", err) - } - if !found { - t.Fatalf("expected key before expiry") - } - if dest["token"] != "abc" { - t.Fatalf("expected token=abc, got %q", dest["token"]) - } - - time.Sleep(35 * time.Millisecond) - found, err = c.Get(key, &dest) - if err != nil { - t.Fatalf("Get after expiry failed: %v", err) - } - if found { - t.Fatalf("expected key to expire") - } -} - -func TestCache_SetWithTTL_ZeroExpiresImmediately(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-set-with-ttl-zero", 10*time.Minute) - - key := "session/instant" - if err := c.SetWithTTL(key, map[string]string{"token": "abc"}, 0); err != nil { - t.Fatalf(testSetWithTTLFailed, 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() { - // Intentionally empty: JSON marshaling rejects function values before invocation. - }); err == nil { - t.Fatal("expected Set to reject unsupported JSON payload") - } -} - -func TestCache_Binary_Good(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-binary", 10*time.Minute) - - blob := []byte{0x00, 0x01, 0x02, 0x03} - err := c.SetBinary("wasm/my-module", blob, testApplicationWasm) - if err != nil { - t.Fatalf(testSetBinaryFailed, err) - } - - data, found, err := c.GetBinary("wasm/my-module") - if err != nil { - t.Fatalf(testGetBinaryFailed, err) - } - if !found { - t.Fatalf("expected binary data") - } - if string(data) != string(blob) { - t.Fatalf("unexpected binary payload: %q", data) - } -} - -func TestCache_SetBinary_Bad(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-binary-bad", 10*time.Minute) - - if err := c.SetBinaryWithTTL(testTraversalKey, []byte("blob"), testTextPlain, time.Second); err == nil { - t.Fatal("expected SetBinaryWithTTL to reject traversal key") - } -} - -func TestCache_SetBinaryWithTTL_Bad(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-binary-negative-ttl", 10*time.Minute) - - if err := c.SetBinaryWithTTL("wasm/negative-ttl", []byte("blob"), testApplicationWasm, -time.Second); err == nil { - t.Fatal("expected SetBinaryWithTTL to reject negative ttl") - } -} - -func TestCache_SetBinaryWithTTL_Good(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-binary-with-ttl", 10*time.Minute) - - key := "wasm/ttl" - blob := []byte("temporary-binary") - if err := c.SetBinaryWithTTL(key, blob, testApplicationOctetStream, 20*time.Millisecond); err != nil { - t.Fatalf(testSetBinaryWithTTLFailed, err) - } - - data, found, err := c.GetBinary(key) - if err != nil { - t.Fatalf("GetBinary before expiry failed: %v", err) - } - if !found { - t.Fatal("expected binary entry before expiry") - } - if string(data) != string(blob) { - t.Fatalf("unexpected payload: %q", data) - } - - time.Sleep(35 * time.Millisecond) - _, found, err = c.GetBinary(key) - if err != nil { - t.Fatalf("GetBinary after expiry failed: %v", err) - } - if found { - t.Fatal("expected binary entry to expire") - } -} - -func TestCache_SetBinary_Ugly(t *testing.T) { - medium := newScriptedMedium() - c, err := cache.New(medium, "/tmp/cache-binary-ugly", time.Minute) - if err != nil { - t.Fatalf(testNewFailed, err) - } - - key := "wasm/ugly" - jsonPath, err := c.Path(key) - if err != nil { - t.Fatalf(testPathFailed, err) - } - binPath := core.TrimSuffix(jsonPath, ".json") + ".bin" - medium.writeErr[jsonPath] = core.E("cache_test", "metadata boom", nil) - - if err := c.SetBinary(key, []byte("body"), testApplicationWasm); err == nil { - t.Fatal("expected SetBinary to surface metadata write failure") - } - if _, ok := medium.Files[binPath]; ok { - t.Fatal("expected binary payload to be cleaned up after metadata write failure") - } -} - -func TestCache_SetBinary_Ugly_BinaryWriteFailure(t *testing.T) { - medium := newScriptedMedium() - c, err := cache.New(medium, "/tmp/cache-binary-write-failure", time.Minute) - if err != nil { - t.Fatalf(testNewFailed, err) - } - - key := "wasm/write-failure" - jsonPath, err := c.Path(key) - if err != nil { - t.Fatalf(testPathFailed, err) - } - binPath := core.TrimSuffix(jsonPath, ".json") + ".bin" - medium.writeErr[binPath] = core.E("cache_test", "payload boom", nil) - - if err := c.SetBinary(key, []byte("body"), testApplicationWasm); 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, testApplicationOctetStream); err != nil { - t.Fatalf(testSetBinaryFailed, err) - } - - data, found, err := c.GetBinary("wasm/opaque") - if err != nil { - t.Fatalf(testGetBinaryFailed, 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, testTextPlain, 10*time.Millisecond); err != nil { - t.Fatalf(testSetBinaryWithTTLFailed, err) - } - - time.Sleep(25 * time.Millisecond) - _, found, err := c.GetBinary("temp/nonce") - if err != nil { - t.Fatalf(testGetBinaryFailed, 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, testTextPlain, 0); err != nil { - t.Fatalf(testSetBinaryWithTTLFailed, 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(testPathFailed, err) - } - m.Files[metaPath] = testMalformedJSON - - 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"), testApplicationOctetStream); err != nil { - t.Fatalf(testSetBinaryFailed, err) - } - - jsonPath, err := c.Path(key) - if err != nil { - t.Fatalf(testPathFailed, err) - } - binPath := core.TrimSuffix(jsonPath, ".json") + ".bin" - delete(m.Files, binPath) - - if data, found, err := c.GetBinary(key); err != nil || found || data != nil { - t.Fatalf("expected missing payload to be a clean miss, data=%v found=%v err=%v", data, found, err) - } -} - -func TestCache_PublicMethods_RejectTraversalKeys(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-traversal-coverage", time.Minute) - - if err := c.SetWithTTL(testTraversalKey, "value", time.Second); err == nil { - t.Fatal("expected SetWithTTL to reject traversal key") - } - - if err := c.SetBinary(testTraversalKey, []byte("blob"), testTextPlain); err == nil { - t.Fatal("expected SetBinary to reject traversal key") - } - - if _, found, err := c.GetBinary(testTraversalKey); 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(testAppOrigin) - admin := c.Scoped(testAdminOrigin) - - if err := app.Set(testUserProfileKey, testAppUser); err != nil { - t.Fatalf(testAppSetFailed, err) - } - if err := admin.Set(testUserProfileKey, testAdminUser); err != nil { - t.Fatalf(testAdminSetFailed, err) - } - - var appVal string - var adminVal string - - found, err := app.Get(testUserProfileKey, &appVal) - if err != nil || !found || appVal != testAppUser { - t.Fatalf("unexpected app scoped value: found=%v val=%q err=%v", found, appVal, err) - } - - found, err = admin.Get(testUserProfileKey, &adminVal) - if err != nil || !found || adminVal != testAdminUser { - t.Fatalf("unexpected admin scoped value: found=%v val=%q err=%v", found, adminVal, err) - } - - if err := c.ClearScope(testAppOrigin); err != nil { - t.Fatalf("ClearScope failed: %v", err) - } - - found, err = app.Get(testUserProfileKey, &appVal) - if err != nil || found { - t.Fatalf("expected app scope to be cleared, found=%v err=%v", found, err) - } - found, err = admin.Get(testUserProfileKey, &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(testAppOrigin) - admin := c.Scoped(testAdminOrigin) - - if err := app.Set(testUserProfileKey, testAppUser); err != nil { - t.Fatalf(testAppSetFailed, err) - } - if err := admin.Set(testUserProfileKey, testAdminUser); err != nil { - t.Fatalf(testAdminSetFailed, err) - } - - if err := app.ClearScope(testAppOrigin); err != nil { - t.Fatalf("scoped ClearScope failed: %v", err) - } - - var appVal string - var adminVal string - - found, err := app.Get(testUserProfileKey, &appVal) - if err != nil || found { - t.Fatalf("expected app scope to be cleared, found=%v err=%v", found, err) - } - - found, err = admin.Get(testUserProfileKey, &adminVal) - if err != nil || !found || adminVal != testAdminUser { - 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(testAppOrigin) - admin := c.Scoped(testAdminOrigin) - - if err := app.Set(testConfigThemeKey, "app-dark"); err != nil { - t.Fatalf(testAppSetFailed, err) - } - if err := admin.Set(testConfigThemeKey, "admin-dark"); err != nil { - t.Fatalf(testAdminSetFailed, err) - } - - app.OnInvalidate("config.changed", func(trigger string) []string { - return []string{"config/*"} - }) - - deleted, err := app.Invalidate("config.changed") - if err != nil { - t.Fatalf(testInvalidateFailed, 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(testConfigThemeKey, &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(testConfigThemeKey, &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(testDNSExampleAKey, 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(testConfigThemeKey, "dark"); err != nil { - t.Fatalf("Set config entry failed: %v", err) - } - - c.OnInvalidate(testDNSTreeRootChanged, func(trigger string) []string { - return []string{"dns/*"} - }) - deleted, err := c.Invalidate(testDNSTreeRootChanged) - if err != nil { - t.Fatalf(testInvalidateFailed, err) - } - if deleted == 0 { - t.Fatal("expected at least one deleted entry") - } - - var dnsValue map[string]string - found, err := c.Get(testDNSExampleAKey, &dnsValue) - if err != nil { - t.Fatalf("Get after invalidation failed: %v", err) - } - if found { - t.Fatal("expected dns entry to be deleted") - } - found, err = c.Get("dns/example.com/sub/path", &dnsValue) - if err != nil { - t.Fatalf("Get nested dns entry after invalidation failed: %v", err) - } - if found { - t.Fatal("expected nested dns entry to be deleted") - } - var theme string - found, err = c.Get(testConfigThemeKey, &theme) - if err != nil || !found { - t.Fatalf("expected config entry to remain, found=%v err=%v", found, err) - } -} - -func TestCache_Invalidate_UntrustedPatternLength_Bad(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-invalidate-pattern-length", time.Minute) - - if err := c.Set(testDNSExampleAKey, "record"); err != nil { - t.Fatalf(testSetFailed, err) - } - - c.OnInvalidate(testDNSChanged, func(trigger string) []string { - return []string{repeatString("a", 4097)} - }) - - deleted, err := c.Invalidate(testDNSChanged) - if err == nil { - t.Fatal("expected Invalidate to reject an oversized pattern") - } - if deleted != 0 { - t.Fatalf("expected no deletions after rejecting oversized pattern, got %d", deleted) - } - - var record string - found, err := c.Get(testDNSExampleAKey, &record) - if err != nil { - t.Fatalf(testGetFailed, err) - } - if !found || record != "record" { - t.Fatalf("expected entry to remain, found=%v record=%q", found, record) - } -} - -func TestCache_Invalidate_PrefixWildcardDoesNotMatchBarePrefix(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-invalidate-prefix", time.Minute) - - if err := c.Set("dns", "root"); err != nil { - t.Fatalf("Set bare prefix failed: %v", err) - } - if err := c.Set(testDNSExampleAKey, "record"); err != nil { - t.Fatalf("Set nested dns entry failed: %v", err) - } - - c.OnInvalidate(testDNSTreeRootChanged, func(trigger string) []string { - return []string{"dns/*"} - }) - deleted, err := c.Invalidate(testDNSTreeRootChanged) - if err != nil { - t.Fatalf(testInvalidateFailed, err) - } - if deleted != 1 { - t.Fatalf("expected one descendant to be deleted, got %d", deleted) - } - - var root string - found, err := c.Get("dns", &root) - if err != nil { - t.Fatalf("Get bare prefix failed: %v", err) - } - if !found || root != "root" { - t.Fatalf("expected bare prefix entry to remain, found=%v val=%q", found, root) - } - - var record string - found, err = c.Get(testDNSExampleAKey, &record) - if err != nil { - t.Fatalf("Get nested entry failed: %v", err) - } - if found { - t.Fatal("expected nested dns entry to be deleted") - } -} - -func TestCache_Invalidate_SingleSegmentWildcard_Good(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-invalidate-segment", time.Minute) - - if err := c.Set("dns/charon.lthn", "one"); err != nil { - t.Fatalf(testSetFailed, err) - } - if err := c.Set("dns/charon.local", "two"); err != nil { - t.Fatalf(testSetFailed, err) - } - if err := c.Set("dns/other.local", "three"); err != nil { - t.Fatalf(testSetFailed, err) - } - - c.OnInvalidate(testDNSChanged, func(trigger string) []string { - return []string{"dns/charon.*"} - }) - - deleted, err := c.Invalidate(testDNSChanged) - if err != nil { - t.Fatalf(testInvalidateFailed, err) - } - if deleted != 2 { - t.Fatalf("expected two wildcard matches to be deleted, got %d", deleted) - } - - var value string - found, err := c.Get("dns/charon.lthn", &value) - if err != nil { - t.Fatalf(testGetFailed, err) - } - if found { - t.Fatal("expected charon.lthn to be deleted") - } - found, err = c.Get("dns/charon.local", &value) - if err != nil { - t.Fatalf(testGetFailed, err) - } - if found { - t.Fatal("expected charon.local to be deleted") - } - found, err = c.Get("dns/other.local", &value) - if err != nil { - t.Fatalf(testGetFailed, 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(testDNSExampleAKey, "record"); err != nil { - t.Fatalf(testSetFailed, err) - } - - c.OnInvalidate(testDNSTreeRootChanged, nil) - deleted, err := c.Invalidate(testDNSTreeRootChanged) - if err != nil { - t.Fatalf(testInvalidateFailed, err) - } - if deleted != 0 { - t.Fatalf("expected nil callback to be ignored, got %d deletions", deleted) - } - - var record string - found, err := c.Get(testDNSExampleAKey, &record) - if err != nil { - t.Fatalf(testGetFailed, 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(testAppOrigin) - - if err := scoped.Set(testDNSExampleAKey, "record"); err != nil { - t.Fatalf(testSetFailed, err) - } - - scoped.OnInvalidate(testDNSTreeRootChanged, nil) - deleted, err := scoped.Invalidate(testDNSTreeRootChanged) - if err != nil { - t.Fatalf(testInvalidateFailed, err) - } - if deleted != 0 { - t.Fatalf("expected nil scoped callback to be ignored, got %d deletions", deleted) - } - - var record string - found, err := scoped.Get(testDNSExampleAKey, &record) - if err != nil { - t.Fatalf(testGetFailed, 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(testAppOrigin) - - 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"), testApplicationOctetStream); err != nil { - t.Fatalf("Scoped SetBinary failed: %v", err) - } - if err := scoped.SetBinaryWithTTL("blob-ttl", []byte("bin2"), testApplicationOctetStream, 5*time.Millisecond); err != nil { - t.Fatalf("Scoped SetBinaryWithTTL failed: %v", err) - } - - path, err := scoped.Path("value") - if err != nil { - t.Fatalf("Scoped Path failed: %v", err) - } - if !core.Contains(path, "scope_") { - t.Fatalf("expected scoped path, got %q", path) - } - - var value string - found, err := scoped.Get("value", &value) - if err != nil || !found || value != "alpha" { - t.Fatalf("unexpected scoped Get result: found=%v value=%q err=%v", found, value, err) - } - - data, found, err := scoped.GetBinary("blob") - if err != nil || !found || string(data) != "bin" { - t.Fatalf("unexpected scoped GetBinary result: found=%v data=%q err=%v", found, data, err) - } - - if age := scoped.Age("value"); age < 0 { - t.Fatalf("expected scoped Age >= 0, got %v", age) - } - - if err := scoped.Delete("value"); err != nil { - t.Fatalf("Scoped Delete failed: %v", err) - } - if err := scoped.DeleteMany("ttl", "blob-ttl"); err != nil { - t.Fatalf("Scoped DeleteMany failed: %v", err) - } - if err := scoped.Clear(); err != nil { - t.Fatalf("Scoped Clear failed: %v", err) - } -} - -func TestCache_Scoped_Scoped_Good(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-scoped-scoped", time.Minute) - - app := c.Scoped(testAppOrigin) - admin := app.Scoped(testAdminOrigin) - - if admin == nil { - t.Fatal("expected Scoped on ScopedCache to return a cache") - } - - if err := app.Set(testUserProfileKey, testAppUser); err != nil { - t.Fatalf(testAppSetFailed, err) - } - if err := admin.Set(testUserProfileKey, testAdminUser); err != nil { - t.Fatalf(testAdminSetFailed, err) - } - - var appValue string - found, err := app.Get(testUserProfileKey, &appValue) - if err != nil || !found || appValue != testAppUser { - t.Fatalf("unexpected app scoped value: found=%v value=%q err=%v", found, appValue, err) - } - - var adminValue string - found, err = admin.Get(testUserProfileKey, &adminValue) - if err != nil || !found || adminValue != testAdminUser { - 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(testUserProfileKey, &appValue) - if err != nil || !found || appValue != testAppUser { - t.Fatalf("expected app scope to remain after clearing admin, found=%v value=%q err=%v", found, appValue, err) - } - - found, err = admin.Get(testUserProfileKey, &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(testAppOrigin) != 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"), testTextPlain); err == nil { - t.Fatal("expected scoped SetBinary to fail on nil receiver") - } - if err := scoped.SetBinaryWithTTL("x", []byte("v"), testTextPlain, 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(testAppOrigin); 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(testNewCacheStorageFailed, err) - } - - tests := []struct { - name string - fn func() error - }{ - { - name: "open-empty", - fn: func() error { - _, err := storage.Open("") - return err - }, - }, - { - name: "open-dot", - fn: func() error { - _, err := storage.Open(".") - return err - }, - }, - { - name: "open-traversal", - fn: func() error { - _, err := storage.Open("../evil") - return err - }, - }, - { - name: "delete-backslash", - fn: func() error { - return storage.Delete(`bad\cache`) - }, - }, - { - name: "open-too-long", - fn: func() error { - _, err := storage.Open(repeatString("a", 256)) - return err - }, - }, - { - name: "open-newline", - fn: func() error { - _, err := storage.Open("cache\nname") - return err - }, - }, - { - name: "open-null-byte", - fn: func() error { - _, err := storage.Open("cache\x00name") - return err - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if err := tt.fn(); err == nil { - t.Fatalf("expected %s to be rejected", tt.name) - } - }) - } -} - -func TestCache_HTTPCacheStorage_NilReceiver_Bad(t *testing.T) { - var storage *cache.CacheStorage - - if _, err := storage.Open("x"); err == nil { - t.Fatal("expected Open to fail on nil storage") - } - if err := storage.Delete("x"); err == nil { - t.Fatal("expected Delete to fail on nil storage") - } - if _, err := storage.Keys(); err == nil { - t.Fatal("expected Keys to fail on nil storage") - } - if err := storage.Close(); err != nil { - t.Fatalf("Close on nil storage should be a no-op: %v", err) - } -} - -func TestCache_HTTPCacheStorage_Good(t *testing.T) { - const baseDir = "/tmp/cache-http" - - medium := coreio.NewMockMedium() - storage, err := cache.NewCacheStorage(medium, baseDir) - if err != nil { - t.Fatalf(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open(testHTTPCacheName) - if err != nil { - t.Fatalf(testStorageOpenFailed, err) - } - assertStorageOpenReusesHTTPCache(t, storage, httpCache) - - req := cache.CachedRequest{ - URL: testStyleURL, - Method: "GET", - } - resp := cache.CachedResponse{ - Status: 200, - StatusText: "OK", - Headers: map[string]string{ - testHeaderContentType: testTextCSS, - }, - } - - if err := httpCache.Put(req, resp, []byte("body")); err != nil { - t.Fatalf(testPutFailed, err) - } - - assertStoredHTTPMetadata(t, medium, baseDir, req, resp) - assertHTTPCacheMatchAndBody(t, httpCache, req) - assertHTTPCacheKeys(t, httpCache, testStyleURL) - assertHTTPStorageDeleteLifecycle(t, storage, httpCache, req) -} - -func assertStorageOpenReusesHTTPCache(t *testing.T, storage *cache.CacheStorage, want *cache.HTTPCache) { - t.Helper() - - again, err := storage.Open(testHTTPCacheName) - if err != nil { - t.Fatalf("storage.Open reuse failed: %v", err) - } - if again != want { - t.Fatal("expected Open to reuse the existing cache instance") - } -} - -func assertStoredHTTPMetadata(t *testing.T, medium *coreio.MockMedium, baseDir string, req cache.CachedRequest, resp cache.CachedResponse) { - t.Helper() - - rawMeta := readStoredHTTPMetadata(t, medium, baseDir) - 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) - } -} - -func readStoredHTTPMetadata(t *testing.T, medium *coreio.MockMedium, baseDir string) string { - t.Helper() - - metaPath := storedHTTPMetadataPath(t, medium, baseDir) - rawMeta, err := medium.Read(metaPath) - if err != nil { - t.Fatalf("Read response metadata failed: %v", err) - } - return rawMeta -} - -func storedHTTPMetadataPath(t *testing.T, medium *coreio.MockMedium, baseDir string) string { - t.Helper() - - responseDir := core.JoinPath(baseDir, testHTTPCacheName, "responses") - metaEntries, err := medium.List(responseDir) - if err != nil { - t.Fatalf("List response metadata failed: %v", err) - } - for _, entry := range metaEntries { - if core.HasSuffix(entry.Name(), ".json") { - return core.JoinPath(responseDir, entry.Name()) - } - } - t.Fatal("expected response metadata file") - return "" -} - -func assertHTTPCacheMatchAndBody(t *testing.T, httpCache *cache.HTTPCache, req cache.CachedRequest) { - t.Helper() - - matched, err := httpCache.Match(req) - if err != nil { - t.Fatalf(testMatchFailed, 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) - } -} - -func assertHTTPCacheKeys(t *testing.T, httpCache *cache.HTTPCache, wantURL string) { - t.Helper() - - 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] != wantURL { - t.Fatalf("unexpected url: %q", urls[0]) - } -} - -func assertHTTPStorageDeleteLifecycle(t *testing.T, storage *cache.CacheStorage, httpCache *cache.HTTPCache, req cache.CachedRequest) { - t.Helper() - - 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] != testHTTPCacheName { - t.Fatalf("expected cache name to be listed, got %v", core.Join(",", names...)) - } - - if err := storage.Delete(testHTTPCacheName); err != nil { - t.Fatalf("storage.Delete failed: %v", err) - } - - if err := storage.Delete(testHTTPCacheName); err != nil { - t.Fatalf("storage.Delete on missing cache should be a no-op, got %v", err) - } - - names, err = storage.Keys() - if err != nil { - t.Fatalf("storage.Keys failed: %v", err) - } - if len(names) != 0 { - t.Fatalf("expected cache name removed, got %v", core.Join(",", names...)) - } -} - -func TestCache_HTTPCacheStorage_Good_LongURLUsesFixedWidthStorageKey(t *testing.T) { - medium := coreio.NewMockMedium() - storage, err := cache.NewCacheStorage(medium, "/tmp/cache-http-long-url") - if err != nil { - t.Fatalf(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("long-url") - if err != nil { - t.Fatalf(testOpenFailed, err) - } - - req := cache.CachedRequest{ - URL: "https://example.com/" + repeatString("a", 4000), - Method: "GET", - } - if err := httpCache.Put(req, cache.CachedResponse{Status: 200, StatusText: "OK"}, []byte("body")); err != nil { - t.Fatalf(testPutFailed, 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(testMatchFailed, 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(testNewCacheStorageFailed, 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(testNewCacheStorageFailed, err) - } - - medium.listErr["/tmp/cache-http-keys-bad"] = core.E("cache_test", "boom", nil) - - if _, err := storage.Keys(); err == nil { - t.Fatal("expected Keys to surface backend list failure") - } -} - -func TestCache_HTTPCacheStorage_Delete_Bad_BackendFailure(t *testing.T) { - medium := newScriptedMedium() - storage, err := cache.NewCacheStorage(medium, "/tmp/cache-http-delete-storage-bad") - if err != nil { - t.Fatalf(testNewCacheStorageFailed, err) - } - - medium.deleteAllErr["/tmp/cache-http-delete-storage-bad/blocked"] = core.E("cache_test", "boom", nil) - - if err := storage.Delete("blocked"); err == nil { - t.Fatal(testWantDeleteBackendFailure) - } -} - -func TestCache_HTTPCacheStorage_Close_Good(t *testing.T) { - storage, err := cache.NewCacheStorage(coreio.NewMockMedium(), "/tmp/cache-http-close") - if err != nil { - t.Fatalf(testNewCacheStorageFailed, 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(testNewCacheStorageFailed, 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(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("api.v2-cache") - if err != nil { - t.Fatalf(testStorageOpenFailed, 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(testPutFailed, err) - } - - names, err := storage.Keys() - if err != nil { - t.Fatalf("storage.Keys failed: %v", err) - } - if len(names) != 1 || names[0] != "api.v2-cache" { - t.Fatalf("expected dotted cache name to be listed, got %v", core.Join(",", names...)) - } -} - -func TestCache_HTTPCacheDeleteMissing_Good(t *testing.T) { - storage, err := cache.NewCacheStorage(coreio.NewMockMedium(), "/tmp/cache-http-delete-missing") - if err != nil { - t.Fatalf(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("missing-delete") - if err != nil { - t.Fatalf(testStorageOpenFailed, 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(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("keys-empty") - if err != nil { - t.Fatalf(testStorageOpenFailed, 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(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("keys-list-bad") - if err != nil { - t.Fatalf(testStorageOpenFailed, err) - } - - medium.listErr["/tmp/cache-http-keys-list-bad/keys-list-bad/responses"] = core.E("cache_test", "boom", nil) - - if _, err := httpCache.Keys(); err == nil { - t.Fatal("expected Keys to surface backend list failure") - } -} - -func TestCache_HTTPCacheReadBody_Bad(t *testing.T) { - storage, err := cache.NewCacheStorage(coreio.NewMockMedium(), "/tmp/cache-http-body-safety") - if err != nil { - t.Fatalf(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("body-safety") - if err != nil { - t.Fatalf(testStorageOpenFailed, 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: testTraversalKey}}, - {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: testResponsesPrefix + repeatString("a", 4097) + ".bin"}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if _, err := httpCache.ReadBody(tt.resp); err == nil { - t.Fatalf("expected ReadBody to reject %s body path", tt.name) - } - }) - } -} - -func TestCache_HTTPCacheReadBody_Bad_MissingPayload(t *testing.T) { - medium := newScriptedMedium() - storage, err := cache.NewCacheStorage(medium, "/tmp/cache-http-body-missing") - if err != nil { - t.Fatalf(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("body-missing") - if err != nil { - t.Fatalf(testStorageOpenFailed, 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(testPutFailed, 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(testMatchFailed, 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(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("delete-bad") - if err != nil { - t.Fatalf(testOpenFailed, err) - } - - req := cache.CachedRequest{ - URL: testStyleURL, - Method: "GET", - } - key := httpCacheStorageKey(req) - metaPath := "/tmp/cache-http-delete-bad/delete-bad/responses/" + key + ".json" - medium.deleteErr[metaPath] = core.E("cache_test", "boom", nil) - - if err := httpCache.Delete(req); err == nil { - t.Fatal(testWantDeleteBackendFailure) - } -} - -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(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("match-incomplete") - if err != nil { - t.Fatalf(testOpenFailed, err) - } - - req := cache.CachedRequest{ - URL: testStyleURL, - Method: "GET", - } - key := legacyHTTPCacheStorageKey(req) - metaPath := "/tmp/cache-http-match-incomplete/match-incomplete/responses/" + key + ".json" - medium.Files[metaPath] = `{"request":{"url":testStyleURL,"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(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("match-empty") - if err != nil { - t.Fatalf(testOpenFailed, 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(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("legacy") - if err != nil { - t.Fatalf(testOpenFailed, err) - } - - req := cache.CachedRequest{ - URL: testStyleURL, - 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{testHeaderContentType: testTextCSS}, - BodyPath: testResponsesPrefix + key + ".bin", - CachedAt: time.Now(), - } - raw, err := json.Marshal(legacy) - if err != nil { - t.Fatalf(testMarshalFailed, err) - } - medium.Files[metaPath] = string(raw) - medium.Files[binPath] = "body" - - matched, err := httpCache.Match(req) - if err != nil { - t.Fatalf(testMatchFailed, 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(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("put-bad") - if err != nil { - t.Fatalf(testOpenFailed, 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(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("put-request-bad") - if err != nil { - t.Fatalf(testOpenFailed, err) - } - - tests := []struct { - name string - req cache.CachedRequest - }{ - { - name: "invalid-method", - req: cache.CachedRequest{ - URL: testStyleURL, - 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: testStyleURL, - Method: "GET\r\nX-Injected: yes", - }, - }, - { - name: "url-too-long", - req: cache.CachedRequest{ - URL: "https://example.com/" + repeatString("a", 8193), - Method: "GET", - }, - }, - { - name: "method-too-long", - req: cache.CachedRequest{ - URL: testStyleURL, - Method: repeatString("G", 33), - }, - }, - } - - resp := cache.CachedResponse{Status: 200, StatusText: "OK"} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if err := httpCache.Put(tt.req, resp, []byte("body")); err == nil { - t.Fatalf("expected Put to reject %s request metadata", tt.name) - } - }) - } -} - -func TestCache_HTTPCache_Put_Bad_HTTPMetadata(t *testing.T) { - storage, err := cache.NewCacheStorage(coreio.NewMockMedium(), "/tmp/cache-http-put-metadata-bad") - if err != nil { - t.Fatalf(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("put-metadata-bad") - if err != nil { - t.Fatalf(testOpenFailed, err) - } - - req := cache.CachedRequest{ - URL: testStyleURL, - 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{testHeaderContentType: "text/plain\r\nX-Injected: yes"}, - }, - }, - { - name: "status-text", - resp: cache.CachedResponse{Status: 200, StatusText: "OK\r\nInjected"}, - }, - { - name: "status-text-too-long", - resp: cache.CachedResponse{Status: 200, StatusText: repeatString("O", 1025)}, - }, - { - name: "header-name-too-long", - resp: cache.CachedResponse{ - Status: 200, - StatusText: "OK", - Headers: map[string]string{repeatString("X", 257): "value"}, - }, - }, - { - name: "header-value-too-long", - resp: cache.CachedResponse{ - Status: 200, - StatusText: "OK", - Headers: map[string]string{testHeaderContentType: repeatString("a", 8193)}, - }, - }, - { - name: "too-many-headers", - resp: func() cache.CachedResponse { - headers := make(map[string]string, 129) - for i := 0; i < 129; i++ { - headers[core.Concat("X-Test-", string(rune('a'+(i%26))), "-", string(rune('0'+((i/26)%10))))] = "value" - } - return cache.CachedResponse{ - Status: 200, - StatusText: "OK", - Headers: headers, - } - }(), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if err := httpCache.Put(req, tt.resp, []byte("body")); err == nil { - t.Fatalf("expected Put to reject %s metadata", tt.name) - } - }) - } -} - -func TestCache_HTTPCache_Put_Ugly(t *testing.T) { - medium := newScriptedMedium() - storage, err := cache.NewCacheStorage(medium, "/tmp/cache-http-put-ugly") - if err != nil { - t.Fatalf(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("put-ugly") - if err != nil { - t.Fatalf(testOpenFailed, err) - } - - req := cache.CachedRequest{URL: testStyleURL, Method: "GET"} - key := httpCacheStorageKey(req) - metaPath := "/tmp/cache-http-put-ugly/put-ugly/responses/" + key + ".json" - binPath := "/tmp/cache-http-put-ugly/put-ugly/responses/" + key + ".bin" - medium.writeErr[metaPath] = core.E("cache_test", "metadata boom", nil) - - if err := httpCache.Put(req, cache.CachedResponse{}, []byte("body")); err == nil { - t.Fatal("expected Put to surface metadata write failure") - } - if _, ok := medium.Files[binPath]; ok { - t.Fatal("expected response body to be cleaned up after metadata write failure") - } -} - -func TestCache_HTTPCache_Match_Bad_RequestMismatch(t *testing.T) { - medium := newScriptedMedium() - storage, err := cache.NewCacheStorage(medium, "/tmp/cache-http-match-mismatch") - if err != nil { - t.Fatalf(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("match-mismatch") - if err != nil { - t.Fatalf(testOpenFailed, err) - } - - req := cache.CachedRequest{ - URL: testStyleURL, - 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{testHeaderContentType: testTextCSS}, - BodyPath: testResponsesPrefix + key + ".bin", - }, - } - - raw, err := json.Marshal(record) - if err != nil { - t.Fatalf(testMarshalFailed, 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(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("match-body-path") - if err != nil { - t.Fatalf(testOpenFailed, err) - } - - req := cache.CachedRequest{ - URL: testStyleURL, - 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{testHeaderContentType: testTextCSS}, - BodyPath: "config/secret.bin", - }, - } - - raw, err := json.Marshal(record) - if err != nil { - t.Fatalf(testMarshalFailed, 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(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("match-body-path-mismatch") - if err != nil { - t.Fatalf(testOpenFailed, err) - } - - req := cache.CachedRequest{ - URL: testStyleURL, - 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{testHeaderContentType: testTextCSS}, - BodyPath: "responses/other.bin", - }, - } - - raw, err := json.Marshal(record) - if err != nil { - t.Fatalf(testMarshalFailed, 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(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("match-tampered") - if err != nil { - t.Fatalf(testOpenFailed, err) - } - - req := cache.CachedRequest{ - URL: testStyleURL, - 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: testResponsesPrefix + key + ".bin", - }, - } - - raw, err := json.Marshal(record) - if err != nil { - t.Fatalf(testMarshalFailed, 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(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("keys") - if err != nil { - t.Fatalf(testOpenFailed, err) - } - - body := []byte("body") - if err := httpCache.Put(cache.CachedRequest{URL: testExampleAURL, Method: "GET"}, cache.CachedResponse{Status: 200, StatusText: "OK"}, body); err != nil { - t.Fatalf(testPutFailed, err) - } - if err := httpCache.Put(cache.CachedRequest{URL: testExampleAURL, 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(testPutFailed, 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] != testExampleAURL || 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(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("match-bad") - if err != nil { - t.Fatalf(testOpenFailed, err) - } - - matched, err := httpCache.Match(cache.CachedRequest{URL: "https://example.com/missing", Method: "GET"}) - if err != nil { - t.Fatalf("Match returned unexpected error: %v", err) - } - if matched != nil { - t.Fatal("expected missing cached response to return nil") - } -} - -func TestCache_ThreatUntrustedKeyDoS_RejectsOversizedKeysOnWritePaths(t *testing.T) { - c, medium := newTestCache(t, "/tmp/cache-threat-untrusted-key", time.Minute) - key := repeatString("a", 4097) - - tests := []struct { - name string - fn func() error - }{ - { - name: "set", - fn: func() error { - return c.Set(key, "value") - }, - }, - { - name: "set-with-ttl", - fn: func() error { - return c.SetWithTTL(key, "value", time.Minute) - }, - }, - { - name: "set-binary", - fn: func() error { - return c.SetBinary(key, []byte("value"), testTextPlain) - }, - }, - { - name: "set-binary-with-ttl", - fn: func() error { - return c.SetBinaryWithTTL(key, []byte("value"), testTextPlain, time.Minute) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if err := tt.fn(); err == nil { - t.Fatalf("expected %s to reject oversized cache key", tt.name) - } - }) - } - - if len(medium.Files) != 0 { - t.Fatalf("oversized rejected keys should not write cache files, got %d", len(medium.Files)) - } -} - -func TestCache_ThreatPathTraversal_ScopedOriginIsHashedAndKeysStillValidated(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-threat-scoped-path", time.Minute) - scoped := c.Scoped("../../evil\norigin") - if scoped == nil { - t.Fatal("expected scoped cache") - } - - if err := scoped.Set("safe-key", "value"); err != nil { - t.Fatalf("scoped Set with hostile origin failed: %v", err) - } - path, err := scoped.Path("safe-key") - if err != nil { - t.Fatalf("scoped Path failed: %v", err) - } - if core.Contains(path, "evil") || core.Contains(path, "..") || core.Contains(path, "\n") { - t.Fatalf("expected scoped path to omit raw origin, got %q", path) - } - - if err := scoped.Set("../../escape", "value"); err == nil { - t.Fatal("expected scoped Set to reject traversal key") - } -} - -func TestCache_ThreatPathTraversal_HTTPCacheUsesHashedRequestStorageKeys(t *testing.T) { - medium := coreio.NewMockMedium() - storage, err := cache.NewCacheStorage(medium, "/tmp/cache-threat-http-path") - if err != nil { - t.Fatalf(testNewCacheStorageFailed, err) - } - - httpCache, err := storage.Open("assets") - if err != nil { - t.Fatalf(testOpenFailed, err) - } - - req := cache.CachedRequest{ - URL: "https://example.com/../../secret.css?file=../secret", - Method: "GET", - } - resp := cache.CachedResponse{Status: 200, StatusText: "OK"} - if err := httpCache.Put(req, resp, []byte("body")); err != nil { - t.Fatalf(testPutFailed, err) - } - - key := httpCacheStorageKey(req) - if _, ok := medium.Files["/tmp/cache-threat-http-path/assets/responses/"+key+".json"]; !ok { - t.Fatal("expected HTTP metadata to be stored under hashed request key") - } - if _, ok := medium.Files["/tmp/cache-threat-http-path/assets/responses/"+key+".bin"]; !ok { - t.Fatal("expected HTTP body to be stored under hashed request key") - } - for path := range medium.Files { - if core.Contains(path, "..") || core.Contains(path, "secret.css") { - t.Fatalf("expected stored path to omit raw request URL, got %q", path) - } - } - - matched, err := httpCache.Match(req) - if err != nil { - t.Fatalf(testMatchFailed, err) - } - if matched == nil { - t.Fatal("expected cached response to match") - } - if _, err := httpCache.ReadBody(&cache.CachedResponse{BodyPath: "../../escape"}); err == nil { - t.Fatal("expected ReadBody to reject traversal body path") - } -} - -func TestCache_ThreatTOCTOU_InvalidateOnInvalidateRegistrationIsSnapshotRaceClean(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-threat-invalidate-snapshot", time.Minute) - - if err := c.Set("victim", "old"); err != nil { - t.Fatalf("Set victim failed: %v", err) - } - if err := c.Set("late", "new"); err != nil { - t.Fatalf("Set late failed: %v", err) - } - - var registerOnce sync.Once - var lateCalls int64 - c.OnInvalidate("reload", func(trigger string) []string { - registerOnce.Do(func() { - c.OnInvalidate(trigger, func(string) []string { - atomic.AddInt64(&lateCalls, 1) - return []string{"late"} - }) - }) - runtime.Gosched() - return []string{"victim"} - }) - - deleted, err := c.Invalidate("reload") - if err != nil { - t.Fatalf(testInvalidateFailed, err) - } - if deleted != 1 { - t.Fatalf("expected first invalidation to delete snapshot callback match only, got %d", deleted) - } - if got := atomic.LoadInt64(&lateCalls); got != 0 { - t.Fatalf("newly registered callback should not run in same invalidation pass, got %d calls", got) - } - - deleted, err = c.Invalidate("reload") - if err != nil { - t.Fatalf("second Invalidate failed: %v", err) - } - if deleted != 1 { - t.Fatalf("expected second invalidation to delete late callback match, got %d", deleted) - } - if got := atomic.LoadInt64(&lateCalls); got != 1 { - t.Fatalf("expected late callback to run once on next invalidation pass, got %d calls", got) - } -} - -func TestCache_ThreatTOCTOU_InvalidateConcurrentRegistrationRaceClean(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-threat-invalidate-race", time.Minute) - c.OnInvalidate("reload", func(string) []string { - runtime.Gosched() - return nil - }) - - const workers = 16 - const registrationsPerWorker = 16 - start := make(chan struct{}) - errCh := make(chan error, workers) - - var done sync.WaitGroup - done.Add(workers * 2) - for range workers { - go func() { - defer done.Done() - <-start - for range registrationsPerWorker { - c.OnInvalidate("reload", func(string) []string { - runtime.Gosched() - return nil - }) - } - }() - } - for range workers { - go func() { - defer done.Done() - <-start - for range registrationsPerWorker { - if _, err := c.Invalidate("reload"); err != nil { - errCh <- err - return - } - } - }() - } - - close(start) - done.Wait() - close(errCh) - - for err := range errCh { - t.Errorf(testInvalidateFailed, err) - } -} - -func TestCache_ThreatTOCTOU_ExpiredGetConcurrentReadersReturnNotFound(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-threat-expired-get", time.Minute) - if err := c.SetWithTTL("ttl/race", map[string]string{"state": "expired"}, time.Nanosecond); err != nil { - t.Fatalf(testSetWithTTLFailed, err) - } - time.Sleep(2 * time.Millisecond) - - const readers = 64 - start := make(chan struct{}) - errCh := make(chan string, readers) - - var done sync.WaitGroup - done.Add(readers) - for range readers { - go func() { - defer done.Done() - <-start - - got := map[string]string{"state": "sentinel"} - found, err := c.Get("ttl/race", &got) - if err != nil { - errCh <- err.Error() - return - } - if found { - errCh <- "expected expired Get to return found=false" - return - } - if got["state"] != "sentinel" { - errCh <- "expired Get unmarshaled stale data into destination" - } - }() - } - - close(start) - done.Wait() - close(errCh) - - for msg := range errCh { - t.Error(msg) - } -} - -func TestCache_ThreatTOCTOU_ConcurrentSetRandomKeysRaceClean(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-threat-concurrent-random-set", time.Minute) - - const workers = 100 - keys := make([]string, workers) - for i := range workers { - keys[i] = "race/random/" + core.Itoa((i*37+11)%workers) - } - - start := make(chan struct{}) - errCh := make(chan string, workers) - - var done sync.WaitGroup - done.Add(workers) - for i, key := range keys { - go func(value int, key string) { - defer done.Done() - <-start - if err := c.Set(key, map[string]int{"writer": value}); err != nil { - errCh <- err.Error() - } - }(i, key) - } - - close(start) - done.Wait() - close(errCh) - - for msg := range errCh { - t.Error(msg) - } - - foundCount := 0 - for i, key := range keys { - var got map[string]int - found, err := c.Get(key, &got) - if err != nil { - t.Fatalf("Get %q failed: %v", key, err) - } - if !found { - continue - } - foundCount++ - if got["writer"] != i { - t.Fatalf("expected %q writer %d, got %d", key, i, got["writer"]) - } - } - if foundCount != workers { - t.Fatalf("expected %d entries after concurrent Set calls, got %d", workers, foundCount) - } -} - -func TestCache_ThreatTOCTOU_ConcurrentSetSameKeyRaceClean(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-threat-concurrent-same-set", time.Minute) - - const workers = 100 - written := make(map[int]struct{}, workers) - for i := range workers { - written[i] = struct{}{} - } - - start := make(chan struct{}) - errCh := make(chan string, workers) - - var done sync.WaitGroup - done.Add(workers) - for i := range workers { - go func(value int) { - defer done.Done() - <-start - if err := c.Set("race/same", map[string]int{"writer": value}); err != nil { - errCh <- err.Error() - } - }(i) - } - - close(start) - done.Wait() - close(errCh) - - for msg := range errCh { - t.Error(msg) - } - - var got map[string]int - found, err := c.Get("race/same", &got) - if err != nil { - t.Fatalf("final Get failed: %v", err) - } - if !found { - t.Fatal("expected final cache entry to exist") - } - if _, ok := written[got["writer"]]; !ok { - t.Fatalf("final writer %d was not one of the concurrent writers", got["writer"]) - } -} - -func TestCache_ThreatTOCTOU_ConcurrentGetSetDeleteSameKeyRaceClean(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-threat-concurrent-mixed", time.Minute) - if err := c.Set(testRaceMixedKey, map[string]int{"writer": -1}); err != nil { - t.Fatalf("initial Set failed: %v", err) - } - - const workers = 100 - const operations = 10 - start := make(chan struct{}) - errCh := make(chan string, workers*operations) - - var done sync.WaitGroup - done.Add(workers) - for i := range workers { - go runMixedRaceWorker(c, start, &done, errCh, i, workers, operations) - } - - close(start) - done.Wait() - close(errCh) - - for msg := range errCh { - t.Error(msg) - } -} - -func runMixedRaceWorker(c *cache.Cache, start <-chan struct{}, done *sync.WaitGroup, errCh chan<- string, value, workers, operations int) { - defer done.Done() - <-start - for op := range operations { - runMixedRaceOperation(c, errCh, value, op, workers) - } -} - -func runMixedRaceOperation(c *cache.Cache, errCh chan<- string, value, op, workers int) { - switch (value + op) % 3 { - case 0: - checkMixedRaceRead(c, errCh, workers) - case 1: - if err := c.Set(testRaceMixedKey, map[string]int{"writer": value}); err != nil { - errCh <- err.Error() - } - default: - if err := c.Delete(testRaceMixedKey); err != nil { - errCh <- err.Error() - } - } -} - -func checkMixedRaceRead(c *cache.Cache, errCh chan<- string, workers int) { - var got map[string]int - found, err := c.Get(testRaceMixedKey, &got) - if err != nil { - errCh <- err.Error() - return - } - if found && (got["writer"] < -1 || got["writer"] >= workers) { - errCh <- "Get returned a writer outside the written range" - } -} - -func TestCache_ThreatTOCTOU_GetThenSetSerializesEntryWrites(t *testing.T) { - medium := &raceProbeMedium{MockMedium: coreio.NewMockMedium()} - c, err := cache.New(medium, "/tmp/cache-threat-toctou", time.Minute) - if err != nil { - t.Fatalf(testNewFailed, err) - } - - const workers = 32 - start := make(chan struct{}) - writes := make(chan struct{}) - errCh := make(chan string, workers*3) - - var reads sync.WaitGroup - reads.Add(workers) - var done sync.WaitGroup - done.Add(workers) - - for i := range workers { - go func(value int) { - defer done.Done() - <-start - - var got map[string]int - found, err := c.Get(testRaceKey, &got) - if err != nil { - errCh <- err.Error() - } - if found { - errCh <- "expected initial Get to miss" - } - reads.Done() - - <-writes - if err := c.Set(testRaceKey, map[string]int{"writer": value}); err != nil { - errCh <- err.Error() - } - }(i) - } - - close(start) - reads.Wait() - close(writes) - done.Wait() - close(errCh) - - for msg := range errCh { - t.Error(msg) - } - - var got map[string]int - found, err := c.Get(testRaceKey, &got) - if err != nil { - t.Fatalf("final Get failed: %v", err) - } - if !found { - t.Fatal("expected final cache entry to exist") - } - if medium.probedWrites == 0 { - t.Fatal("expected probe medium to observe writes") - } -} - -type raceProbeMedium struct { - *coreio.MockMedium - probedWrites int -} - -func (m *raceProbeMedium) Write(path, content string) error { - m.probedWrites++ - runtime.Gosched() - m.probedWrites++ - return m.MockMedium.Write(path, content) -} diff --git a/external/go b/external/go new file mode 160000 index 0000000..d661b70 --- /dev/null +++ b/external/go @@ -0,0 +1 @@ +Subproject commit d661b703e16183b3cbab101de189f688888a1174 diff --git a/external/go-io b/external/go-io new file mode 160000 index 0000000..8d72624 --- /dev/null +++ b/external/go-io @@ -0,0 +1 @@ +Subproject commit 8d726243d1018ca85b7a55767f08c6d6f7dd9607 diff --git a/go.work b/go.work new file mode 100644 index 0000000..a457927 --- /dev/null +++ b/go.work @@ -0,0 +1,15 @@ +go 1.26.2 + +// Workspace mode for development: pulls fresh code from external/ submodules. +// +// Devs: git clone --recursive -> go.work picks up local sources, latest dev. +// CI: GOWORK=off -> go.mod tags drive resolution, reproducible. +// +// Submodule pins live in .gitmodules + the recorded SHA per submodule entry. +// Bump a single dep: git submodule update --remote external/ + +use ( + ./go + ./external/go + ./external/go-io +) diff --git a/go/cache.go b/go/cache.go new file mode 100644 index 0000000..c0f2e53 --- /dev/null +++ b/go/cache.go @@ -0,0 +1,1879 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Package cache provides a storage-agnostic, JSON-based cache backed by any io.Medium. +package cache + +import ( + "io/fs" + "slices" + "sync" + "time" + + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +// DefaultTTL is the default cache expiry time. +const DefaultTTL = time.Hour + +const ( + maxCacheKeyBytes = 4096 + maxCachePatternBytes = 4096 + maxCacheNameBytes = 255 + maxCachedRequestURLBytes = 8192 + maxCachedRequestMethodBytes = 32 + maxCachedStatusTextBytes = 1024 + maxCachedHeaderNameBytes = 256 + maxCachedHeaderValueBytes = 8192 + maxCachedHeaderCount = 128 +) + +const ( + cacheStorageDirName = "cache-storage" + responsesDirName = "responses" + responsesPathPrefix = responsesDirName + "/" + + opCacheNew = "cache.New" + opCachePath = "cache.Path" + opCacheGet = "cache.Get" + opCacheSet = "cache.Set" + opCacheSetInternal = "cache.set" + opCacheRemoveEntryFiles = "cache.removeEntryFiles" + opCacheSetBinary = "cache.setBinary" + opCacheGetBinary = "cache.GetBinary" + opCacheValidateKey = "cache.validateKey" + opCacheValidatePattern = "cache.validatePattern" + opCacheValidateResponseBodyPath = "cache.validateResponseBodyPath" + opCacheStorageOpen = "cache.CacheStorage.Open" + opCacheStorageDelete = "cache.CacheStorage.Delete" + opCacheRawBase64URLDecode = "cache.rawBase64URLDecode" + opHTTPCacheReadResponseRecord = "cache.HTTPCache.readResponseRecord" + opHTTPCachePut = "cache.HTTPCache.Put" + opHTTPCacheReadBody = "cache.HTTPCache.ReadBody" + opHTTPCacheValidateCachedResponseRecord = "cache.HTTPCache.validateCachedResponseRecord" + opHTTPCacheValidateCachedRequest = "cache.HTTPCache.validateCachedRequest" + opHTTPCacheValidateCachedResponse = "cache.HTTPCache.validateCachedResponse" + opHTTPCacheDelete = "cache.HTTPCache.Delete" + + msgScopedCacheNil = "scoped cache is nil" + msgInvalidCacheName = "invalid cache name" + msgInvalidCachedRequest = "invalid cached request" + msgFailedUnmarshalCachedResponse = "failed to unmarshal cached response" +) + +// Cache stores JSON-encoded entries in a Medium-backed cache rooted at baseDir. +type Cache struct { + medium coreio.Medium + baseDir string + cacheTTL time.Duration + invalidation map[string][]InvalidateFunc + entryMu sync.RWMutex + runtime *core.Core +} + +// Entry is the serialized cache record written to the backing Medium. +type Entry struct { + Data any `json:"data"` + CachedAt time.Time `json:"cached_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +// BinaryMeta is the metadata for binary cache payloads. +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. +type InvalidateFunc func(trigger string) []string + +type entryPathSet struct { + jsonPath string + binaryPath string +} + +type fileSnapshot struct { + path string + existed bool + content string +} + +type snapshotRestore struct { + snapshot fileSnapshot + message string +} + +// New creates a cache with explicit storage, root directory, and TTL. +func New(medium coreio.Medium, baseDir string, cacheTTL time.Duration) core.Result { + if medium == nil { + medium = coreio.Local + } + + if baseDir == "" { + cwd := currentDir() + if cwd == "" || cwd == "." { + return failure(opCacheNew, "failed to resolve current working directory", nil) + } + baseDir = normalizePath(core.JoinPath(cwd, ".core", "cache")) + } else { + baseDir = absolutePath(baseDir) + } + + if cacheTTL < 0 { + return failure(opCacheNew, "ttl must be >= 0", nil) + } + if cacheTTL == 0 { + cacheTTL = DefaultTTL + } + if err := medium.EnsureDir(baseDir); err != nil { + return failure(opCacheNew, "failed to create cache directory", err) + } + + return core.Ok(&Cache{ + medium: medium, + baseDir: baseDir, + cacheTTL: cacheTTL, + invalidation: make(map[string][]InvalidateFunc), + runtime: core.New(), + }) +} + +// Path resolves the on-disk JSON path for a cache key. +func (cache *Cache) Path(key string) core.Result { + if r := cache.ensureConfigured(opCachePath); !r.OK { + return r + } + if r := ensureSafeKey(key); !r.OK { + return r + } + + 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 failure(opCachePath, "invalid cache key: path traversal attempt", nil) + } + if r := ensureNoSymlinkPath(baseDir, path); !r.OK { + return failure(opCachePath, "invalid cache key: symlink escape attempt", resultCause(r).Value.(error)) + } + return core.Ok(path) +} + +func (cache *Cache) entryPaths(key string) core.Result { + pathResult := cache.Path(key) + if !pathResult.OK { + return pathResult + } + jsonPath := pathResult.Value.(string) + baseDir := absolutePath(cache.baseDir) + return core.Ok(entryPathSet{ + jsonPath: jsonPath, + binaryPath: absolutePath(core.JoinPath(baseDir, key+".bin")), + }) +} + +// Get unmarshals the cached item into dest if it exists and has not expired. +func (cache *Cache) Get(key string, dest any) core.Result { + if r := cache.ensureReady(opCacheGet); !r.OK { + return r + } + + cache.entryMu.RLock() + defer cache.entryMu.RUnlock() + + pathResult := cache.Path(key) + if !pathResult.OK { + return pathResult + } + + dataStr, err := cache.medium.Read(pathResult.Value.(string)) + if err != nil { + if core.Is(err, fs.ErrNotExist) { + return core.Ok(false) + } + return failure(opCacheGet, "failed to read cache file", err) + } + + var entry Entry + if r := core.JSONUnmarshalString(dataStr, &entry); !r.OK { + return failure(opCacheGet, "failed to unmarshal cache entry", resultCause(r).Value.(error)) + } + if time.Now().After(entry.ExpiresAt) { + return core.Ok(false) + } + + payload := core.JSONMarshal(entry.Data) + if !payload.OK { + return failure(opCacheGet, "failed to marshal cached data", resultCause(payload).Value.(error)) + } + if r := core.JSONUnmarshal(payload.Value.([]byte), dest); !r.OK { + return failure(opCacheGet, "failed to unmarshal cached data", resultCause(r).Value.(error)) + } + return core.Ok(true) +} + +// Set stores a value using the cache's default TTL. +func (cache *Cache) Set(key string, data any) core.Result { + if r := cache.ensureReady(opCacheSet); !r.OK { + return r + } + return cache.set(key, data, cache.defaultTTL(), true) +} + +// SetWithTTL stores a value with an explicit TTL override. +func (cache *Cache) SetWithTTL(key string, data any, ttl time.Duration) core.Result { + if r := cache.ensureReady("cache.SetWithTTL"); !r.OK { + return r + } + return cache.set(key, data, ttl, false) +} + +func (cache *Cache) set(key string, data any, ttl time.Duration, useDefaultTTL bool) core.Result { + if r := cache.ensureReady(opCacheSetInternal); !r.OK { + return r + } + + cache.entryMu.Lock() + defer cache.entryMu.Unlock() + + pathsResult := cache.entryPaths(key) + if !pathsResult.OK { + return pathsResult + } + paths := pathsResult.Value.(entryPathSet) + + snapshotResult := readFileSnapshot(cache.medium, paths.jsonPath) + if !snapshotResult.OK { + return failure(opCacheSetInternal, "failed to inspect existing cache entry", resultCause(snapshotResult).Value.(error)) + } + + if err := cache.medium.EnsureDir(core.PathDir(paths.jsonPath)); err != nil { + return failure(opCacheSet, "failed to create directory", err) + } + if ttl < 0 { + return failure(opCacheSetInternal, "cache ttl must be >= 0", nil) + } + if ttl == 0 && useDefaultTTL { + ttl = cache.defaultTTL() + } + + now := time.Now() + entry := Entry{Data: data, CachedAt: now, ExpiresAt: now.Add(ttl)} + entryJSON := marshalPrettyJSON(entry) + if !entryJSON.OK { + return failure(opCacheSet, "failed to marshal cache entry", resultCause(entryJSON).Value.(error)) + } + + if err := cache.medium.Write(paths.jsonPath, entryJSON.Value.(string)); err != nil { + restoreResult := restoreFileSnapshot(cache.medium, snapshotResult.Value.(fileSnapshot)) + if !restoreResult.OK { + return failure(opCacheSetInternal, "failed to restore cache file after write failure", core.ErrorJoin(err, resultCause(restoreResult).Value.(error))) + } + return failure(opCacheSetInternal, "failed to write cache file", err) + } + return core.Ok(nil) +} + +// Delete removes one cached entry. +func (cache *Cache) Delete(key string) core.Result { + if r := cache.ensureReady("cache.Delete"); !r.OK { + return r + } + r := cache.removeEntryFiles(key) + if !r.OK && core.Is(resultCause(r).Value.(error), fs.ErrNotExist) { + return core.Ok(nil) + } + if !r.OK { + return r + } + return core.Ok(nil) +} + +func (cache *Cache) removeEntryFiles(key string) core.Result { + if r := cache.ensureReady(opCacheRemoveEntryFiles); !r.OK { + return r + } + + cache.entryMu.Lock() + defer cache.entryMu.Unlock() + + pathsResult := cache.entryPaths(key) + if !pathsResult.OK { + return pathsResult + } + paths := pathsResult.Value.(entryPathSet) + + removed := false + if err := cache.medium.Delete(paths.jsonPath); err != nil { + if !core.Is(err, fs.ErrNotExist) { + return failure(opCacheRemoveEntryFiles, "failed to delete cache json file", err) + } + } else { + removed = true + } + if err := cache.medium.Delete(paths.binaryPath); err != nil { + if !core.Is(err, fs.ErrNotExist) { + return failure(opCacheRemoveEntryFiles, "failed to delete cache binary file", err) + } + } else { + removed = true + } + return core.Ok(removed) +} + +// SetBinary stores raw bytes in a sidecar .bin file and metadata in JSON. +func (cache *Cache) SetBinary(key string, data []byte, contentType string) core.Result { + if r := cache.ensureReady("cache.SetBinary"); !r.OK { + return r + } + return cache.setBinary(key, data, contentType, cache.defaultTTL(), true) +} + +// SetBinaryWithTTL stores raw bytes with an explicit TTL override. +func (cache *Cache) SetBinaryWithTTL(key string, data []byte, contentType string, ttl time.Duration) core.Result { + if r := cache.ensureReady("cache.SetBinaryWithTTL"); !r.OK { + return r + } + return cache.setBinary(key, data, contentType, ttl, false) +} + +func (cache *Cache) setBinary(key string, data []byte, contentType string, ttl time.Duration, useDefaultTTL bool) core.Result { + if r := cache.ensureReady(opCacheSetBinary); !r.OK { + return r + } + + cache.entryMu.Lock() + defer cache.entryMu.Unlock() + + pathsResult := cache.entryPaths(key) + if !pathsResult.OK { + return pathsResult + } + paths := pathsResult.Value.(entryPathSet) + + snapshots := readBinarySnapshots(cache.medium, paths.jsonPath, paths.binaryPath) + if !snapshots.OK { + return snapshots + } + pair := snapshots.Value.([2]fileSnapshot) + + if ttl < 0 { + return failure(opCacheSetBinary, "cache ttl must be >= 0", nil) + } + if ttl == 0 && useDefaultTTL { + ttl = cache.defaultTTL() + } + if err := cache.medium.EnsureDir(core.PathDir(paths.jsonPath)); err != nil { + return failure(opCacheSetBinary, "failed to create directory", err) + } + + now := time.Now() + metaJSON := marshalPrettyJSON(BinaryMeta{ + ContentType: contentType, + Size: int64(len(data)), + CachedAt: now, + ExpiresAt: now.Add(ttl), + }) + if !metaJSON.OK { + return failure(opCacheSetBinary, "failed to marshal binary metadata", resultCause(metaJSON).Value.(error)) + } + + r := writeFileWithRollback(cache.medium, paths.binaryPath, string(data), opCacheSetBinary, "failed to write binary payload", + snapshotRestore{snapshot: pair[0], message: "failed to restore binary metadata after payload write failure"}, + snapshotRestore{snapshot: pair[1], message: "failed to restore binary payload after payload write failure"}, + ) + if !r.OK { + return r + } + return writeFileWithRollback(cache.medium, paths.jsonPath, metaJSON.Value.(string), opCacheSetBinary, "failed to write binary metadata", + snapshotRestore{snapshot: pair[1], message: "failed to restore binary payload after metadata write failure"}, + snapshotRestore{snapshot: pair[0], message: "failed to restore binary metadata after metadata write failure"}, + ) +} + +// GetBinary returns raw binary cache payload. Missing or expired entries return OK with nil Value. +func (cache *Cache) GetBinary(key string) core.Result { + if r := cache.ensureReady(opCacheGetBinary); !r.OK { + return r + } + + cache.entryMu.RLock() + defer cache.entryMu.RUnlock() + + pathsResult := cache.entryPaths(key) + if !pathsResult.OK { + return pathsResult + } + paths := pathsResult.Value.(entryPathSet) + + rawMeta, err := cache.medium.Read(paths.jsonPath) + if err != nil { + if core.Is(err, fs.ErrNotExist) { + return core.Ok(nil) + } + return failure(opCacheGetBinary, "failed to read binary metadata", err) + } + + var meta BinaryMeta + if r := core.JSONUnmarshalString(rawMeta, &meta); !r.OK { + return failure(opCacheGetBinary, "failed to unmarshal binary metadata", resultCause(r).Value.(error)) + } + if time.Now().After(meta.ExpiresAt) { + return core.Ok(nil) + } + + body, err := cache.medium.Read(paths.binaryPath) + if err != nil { + if core.Is(err, fs.ErrNotExist) { + return core.Ok(nil) + } + return failure(opCacheGetBinary, "failed to read binary data", err) + } + return core.Ok([]byte(body)) +} + +// DeleteMany removes several entries in one call. Missing keys are ignored. +func (cache *Cache) DeleteMany(keys ...string) core.Result { + if r := cache.ensureReady("cache.DeleteMany"); !r.OK { + return r + } + + cache.entryMu.Lock() + defer cache.entryMu.Unlock() + + resolved := make([]entryPathSet, 0, len(keys)) + for _, key := range keys { + pathsResult := cache.entryPaths(key) + if !pathsResult.OK { + return pathsResult + } + resolved = append(resolved, pathsResult.Value.(entryPathSet)) + } + for _, paths := range resolved { + if err := cache.medium.Delete(paths.jsonPath); err != nil && !core.Is(err, fs.ErrNotExist) { + return failure("cache.DeleteMany", "failed to delete cache json file", err) + } + if err := cache.medium.Delete(paths.binaryPath); err != nil && !core.Is(err, fs.ErrNotExist) { + return failure("cache.DeleteMany", "failed to delete cache binary file", err) + } + } + return core.Ok(nil) +} + +func (cache *Cache) listJSONKeys() core.Result { + r := cache.collectJSONKeys("") + if !r.OK { + return r + } + keys := r.Value.([]string) + slices.Sort(keys) + return core.Ok(keys) +} + +func (cache *Cache) collectJSONKeys(prefix string) core.Result { + listPath := cache.baseDir + if prefix != "" { + listPath = core.JoinPath(cache.baseDir, prefix) + } + + entries, err := cache.medium.List(listPath) + if err != nil { + if core.Is(err, fs.ErrNotExist) { + return core.Ok([]string{}) + } + return failure("cache.collectJSONKeys", "failed to list cache directory", err) + } + + var keys []string + for _, entry := range entries { + name := entry.Name() + childPrefix := name + if prefix != "" { + childPrefix = core.JoinPath(prefix, name) + } + if entry.IsDir() { + childKeys := cache.collectJSONKeys(childPrefix) + if !childKeys.OK { + return childKeys + } + keys = append(keys, childKeys.Value.([]string)...) + continue + } + if core.HasSuffix(name, ".json") { + keys = append(keys, core.TrimSuffix(childPrefix, ".json")) + } + } + return core.Ok(keys) +} + +func (cache *Cache) keysByPattern(pattern string) core.Result { + if r := ensureSafePattern(pattern); !r.OK { + return r + } + + cache.entryMu.RLock() + defer cache.entryMu.RUnlock() + + allKeys := cache.listJSONKeys() + if !allKeys.OK { + return allKeys + } + + var matched []string + for _, key := range allKeys.Value.([]string) { + match := matchKeyPattern(pattern, key) + if match.OK && match.Value.(bool) { + matched = append(matched, key) + } + if !match.OK { + return failure("cache.keysByPattern", "failed to match pattern", resultCause(match).Value.(error)) + } + } + return core.Ok(matched) +} + +func (cache *Cache) clearScope(prefix string) core.Result { + keysResult := cache.keysByPattern(prefix) + if !keysResult.OK { + return keysResult + } + descendantsResult := cache.keysByPattern(prefix + "/*") + if !descendantsResult.OK { + return descendantsResult + } + keys := append(keysResult.Value.([]string), descendantsResult.Value.([]string)...) + for _, key := range keys { + if r := cache.removeEntryFiles(key); !r.OK { + return r + } + } + return core.Ok(nil) +} + +func matchKeyPattern(pattern, key string) core.Result { + if !containsAnyGlob(pattern) { + return core.Ok(pattern == key) + } + if core.HasSuffix(pattern, "/*") { + prefix := core.TrimSuffix(pattern, "/*") + if prefix == "" { + return core.Ok(true) + } + return core.Ok(core.HasPrefix(key, prefix+"/")) + } + + patternParts := core.Split(pattern, "/") + keyParts := core.Split(key, "/") + if len(patternParts) != len(keyParts) { + return core.Ok(false) + } + for i, part := range patternParts { + if !containsAnyGlob(part) { + if part != keyParts[i] { + return core.Ok(false) + } + continue + } + match := segmentMatch(part, keyParts[i]) + if !match.OK || !match.Value.(bool) { + return match + } + } + return core.Ok(true) +} + +func containsAnyGlob(s string) bool { + for _, r := range s { + if r == '*' || r == '?' || r == '[' || r == ']' { + return true + } + } + return false +} + +func segmentMatch(pattern, name string) core.Result { + 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 core.Ok(false) + } + for p < len(pattern) && pattern[p] == '*' { + p++ + } + return core.Ok(p == len(pattern)) +} + +// OnInvalidate registers a trigger callback that returns patterns to delete. +func (cache *Cache) OnInvalidate(trigger string, fn InvalidateFunc) { + if cache == nil || fn == nil { + return + } + if r := cache.ensureReady("cache.OnInvalidate"); !r.OK { + return + } + lock := cache.runtime.Lock("cache") + lock.Mutex.Lock() + defer lock.Mutex.Unlock() + if cache.invalidation == nil { + cache.invalidation = make(map[string][]InvalidateFunc) + } + cache.invalidation[trigger] = append(cache.invalidation[trigger], fn) +} + +// Invalidate executes trigger callbacks and deletes matching entries. +func (cache *Cache) Invalidate(trigger string) core.Result { + if r := cache.ensureReady("cache.Invalidate"); !r.OK { + return r + } + + callbacks := cache.invalidationCallbacks(trigger) + total := 0 + for _, callback := range callbacks { + deleted := cache.invalidatePatterns(callback(trigger)) + if !deleted.OK { + return deleted + } + total += deleted.Value.(int) + } + return core.Ok(total) +} + +func (cache *Cache) invalidationCallbacks(trigger string) []InvalidateFunc { + lock := cache.runtime.Lock("cache") + lock.Mutex.RLock() + callbacks := append([]InvalidateFunc(nil), cache.invalidation[trigger]...) + lock.Mutex.RUnlock() + return callbacks +} + +func (cache *Cache) invalidatePatterns(patterns []string) core.Result { + total := 0 + for _, pattern := range patterns { + deleted := cache.invalidatePattern(pattern) + if !deleted.OK { + return deleted + } + total += deleted.Value.(int) + } + return core.Ok(total) +} + +func (cache *Cache) invalidatePattern(pattern string) core.Result { + if pattern == "" { + return core.Ok(0) + } + matchesResult := cache.keysByPattern(pattern) + if !matchesResult.OK { + return matchesResult + } + + total := 0 + for _, key := range matchesResult.Value.([]string) { + removed := cache.removeEntryFiles(key) + if !removed.OK { + return removed + } + if removed.Value.(bool) { + total++ + } + } + return core.Ok(total) +} + +// Scoped returns a cache namespaced by origin hash. +func (cache *Cache) Scoped(origin string) *ScopedCache { + if cache == nil { + return nil + } + return &ScopedCache{parent: cache, prefix: scopePrefix(origin)} +} + +// ClearScope removes cache entries for a scoped origin. +func (cache *Cache) ClearScope(origin string) core.Result { + if r := cache.ensureReady("cache.ClearScope"); !r.OK { + return r + } + prefix := scopePrefix(origin) + if r := ensureSafeKey(prefix); !r.OK { + return r + } + return cache.clearScope(prefix) +} + +func (cache *Cache) defaultTTL() time.Duration { + if cache.cacheTTL <= 0 { + return DefaultTTL + } + return cache.cacheTTL +} + +// ScopedCache namespaces cache operations under a hashed origin prefix. +type ScopedCache struct { + parent *Cache + prefix string +} + +func scopePrefix(origin string) string { + return "scope_" + core.SHA256Hex([]byte(origin)) +} + +func (scopedCache *ScopedCache) fullKey(key string) string { + return scopedCache.prefix + "/" + key +} + +// Scoped returns a cache namespaced by a different origin. +func (scopedCache *ScopedCache) Scoped(origin string) *ScopedCache { + if scopedCache == nil || scopedCache.parent == nil { + return nil + } + return scopedCache.parent.Scoped(origin) +} + +// Path resolves the on-disk JSON path for a scoped key. +func (scopedCache *ScopedCache) Path(key string) core.Result { + if scopedCache == nil || scopedCache.parent == nil { + return failure("cache.Scoped.Path", msgScopedCacheNil, nil) + } + return scopedCache.parent.Path(scopedCache.fullKey(key)) +} + +// Get unmarshals a scoped cached item into dest. +func (scopedCache *ScopedCache) Get(key string, dest any) core.Result { + if scopedCache == nil || scopedCache.parent == nil { + return failure("cache.Scoped.Get", msgScopedCacheNil, nil) + } + return scopedCache.parent.Get(scopedCache.fullKey(key), dest) +} + +// Set stores a scoped value using the parent cache's default TTL. +func (scopedCache *ScopedCache) Set(key string, value any) core.Result { + if scopedCache == nil || scopedCache.parent == nil { + return failure("cache.Scoped.Set", msgScopedCacheNil, nil) + } + return scopedCache.parent.Set(scopedCache.fullKey(key), value) +} + +// SetWithTTL stores a scoped value with an explicit TTL. +func (scopedCache *ScopedCache) SetWithTTL(key string, value any, ttl time.Duration) core.Result { + if scopedCache == nil || scopedCache.parent == nil { + return failure("cache.Scoped.SetWithTTL", msgScopedCacheNil, nil) + } + return scopedCache.parent.SetWithTTL(scopedCache.fullKey(key), value, ttl) +} + +// SetBinary stores scoped raw bytes. +func (scopedCache *ScopedCache) SetBinary(key string, data []byte, contentType string) core.Result { + if scopedCache == nil || scopedCache.parent == nil { + return failure("cache.Scoped.SetBinary", msgScopedCacheNil, nil) + } + return scopedCache.parent.SetBinary(scopedCache.fullKey(key), data, contentType) +} + +// SetBinaryWithTTL stores scoped raw bytes with an explicit TTL. +func (scopedCache *ScopedCache) SetBinaryWithTTL(key string, data []byte, contentType string, ttl time.Duration) core.Result { + if scopedCache == nil || scopedCache.parent == nil { + return failure("cache.Scoped.SetBinaryWithTTL", msgScopedCacheNil, nil) + } + return scopedCache.parent.SetBinaryWithTTL(scopedCache.fullKey(key), data, contentType, ttl) +} + +// GetBinary returns scoped raw bytes. Missing or expired entries return OK with nil Value. +func (scopedCache *ScopedCache) GetBinary(key string) core.Result { + if scopedCache == nil || scopedCache.parent == nil { + return failure("cache.Scoped.GetBinary", msgScopedCacheNil, nil) + } + return scopedCache.parent.GetBinary(scopedCache.fullKey(key)) +} + +// Delete removes one scoped entry. +func (scopedCache *ScopedCache) Delete(key string) core.Result { + if scopedCache == nil || scopedCache.parent == nil { + return failure("cache.Scoped.Delete", msgScopedCacheNil, nil) + } + return scopedCache.parent.Delete(scopedCache.fullKey(key)) +} + +// DeleteMany removes several scoped entries. +func (scopedCache *ScopedCache) DeleteMany(keys ...string) core.Result { + if scopedCache == nil || scopedCache.parent == nil { + return failure("cache.Scoped.DeleteMany", msgScopedCacheNil, nil) + } + full := make([]string, len(keys)) + for i, key := range keys { + full[i] = scopedCache.fullKey(key) + } + return scopedCache.parent.DeleteMany(full...) +} + +// Clear removes all entries in the scope. +func (scopedCache *ScopedCache) Clear() core.Result { + if scopedCache == nil || scopedCache.parent == nil { + return failure("cache.Scoped.Clear", msgScopedCacheNil, nil) + } + return scopedCache.parent.clearScope(scopedCache.prefix) +} + +// ClearScope removes cache entries for a scoped origin. +func (scopedCache *ScopedCache) ClearScope(origin string) core.Result { + if scopedCache == nil || scopedCache.parent == nil { + return failure("cache.Scoped.ClearScope", msgScopedCacheNil, nil) + } + return scopedCache.parent.ClearScope(origin) +} + +// OnInvalidate registers a scoped invalidation callback. +func (scopedCache *ScopedCache) OnInvalidate(trigger string, fn InvalidateFunc) { + if scopedCache == nil || scopedCache.parent == nil || 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 != "" { + scopedPatterns = append(scopedPatterns, scopePattern(prefix, pattern)) + } + } + return scopedPatterns + }) +} + +// Invalidate executes scoped trigger callbacks. +func (scopedCache *ScopedCache) Invalidate(trigger string) core.Result { + if scopedCache == nil || scopedCache.parent == nil { + return failure("cache.Scoped.Invalidate", msgScopedCacheNil, nil) + } + return scopedCache.parent.Invalidate(trigger) +} + +// Age reports how long ago key was cached, or -1 if it is missing or unreadable. +func (scopedCache *ScopedCache) Age(key string) time.Duration { + if scopedCache == nil || scopedCache.parent == nil { + return -1 + } + return scopedCache.parent.Age(scopedCache.fullKey(key)) +} + +func scopePattern(prefix, pattern string) string { + pattern = core.TrimPrefix(pattern, "/") + if pattern == "" { + return prefix + } + return prefix + "/" + pattern +} + +// CacheStorage manages named caches for HTTP cache API emulation. +type CacheStorage struct { + medium coreio.Medium + baseDir string + caches map[string]*HTTPCache + runtime *core.Core +} + +// NewCacheStorage creates a namespace container for HTTPCache instances. +func NewCacheStorage(medium coreio.Medium, baseDir string) core.Result { + if medium == nil { + medium = coreio.Local + } + if baseDir == "" { + cwd := currentDir() + if cwd == "" || cwd == "." { + return failure("cache.NewCacheStorage", "failed to resolve current working directory", nil) + } + baseDir = normalizePath(core.JoinPath(cwd, ".core", cacheStorageDirName)) + } else { + baseDir = absolutePath(baseDir) + } + if err := medium.EnsureDir(baseDir); err != nil { + return failure("cache.NewCacheStorage", "failed to create cache storage directory", err) + } + return core.Ok(&CacheStorage{ + medium: medium, + baseDir: baseDir, + caches: make(map[string]*HTTPCache), + runtime: core.New(), + }) +} + +// Open retrieves a named HTTPCache, creating it on first use. +func (storage *CacheStorage) Open(name string) core.Result { + if r := storage.ensureReady(opCacheStorageOpen); !r.OK { + return r + } + if r := ensureSafeCacheName(opCacheStorageOpen, name); !r.OK { + return r + } + + lock := storage.runtime.Lock(cacheStorageDirName) + lock.Mutex.Lock() + defer lock.Mutex.Unlock() + if httpCache, ok := storage.caches[name]; ok { + return core.Ok(httpCache) + } + + cacheDir := core.JoinPath(storage.baseDir, name) + if err := storage.medium.EnsureDir(cacheDir); err != nil { + return failure(opCacheStorageOpen, "failed to create cache directory", err) + } + httpCache := &HTTPCache{name: name, medium: storage.medium, baseDir: cacheDir} + storage.caches[name] = httpCache + return core.Ok(httpCache) +} + +// Delete removes a named HTTP cache and all entries. +func (storage *CacheStorage) Delete(name string) core.Result { + if r := storage.ensureReady(opCacheStorageDelete); !r.OK { + return r + } + if r := ensureSafeCacheName(opCacheStorageDelete, name); !r.OK { + return r + } + + lock := storage.runtime.Lock(cacheStorageDirName) + lock.Mutex.Lock() + defer lock.Mutex.Unlock() + if err := storage.medium.DeleteAll(core.JoinPath(storage.baseDir, name)); err != nil && !core.Is(err, fs.ErrNotExist) { + return failure(opCacheStorageDelete, "failed to delete cache directory", err) + } + delete(storage.caches, name) + return core.Ok(nil) +} + +// Keys lists all named caches. +func (storage *CacheStorage) Keys() core.Result { + if r := storage.ensureReady("cache.CacheStorage.Keys"); !r.OK { + return r + } + + lock := storage.runtime.Lock(cacheStorageDirName) + lock.Mutex.RLock() + names := make(map[string]struct{}, len(storage.caches)) + for name := range storage.caches { + names[name] = struct{}{} + } + lock.Mutex.RUnlock() + + entries, err := storage.medium.List(storage.baseDir) + if err != nil && !core.Is(err, fs.ErrNotExist) { + return failure("cache.CacheStorage.Keys", "failed to list caches", err) + } + for _, entry := range entries { + if entry.IsDir() { + names[entry.Name()] = struct{}{} + } + } + + out := make([]string, 0, len(names)) + for name := range names { + out = append(out, name) + } + slices.Sort(out) + return core.Ok(out) +} + +// Close releases storage resources for compatibility with long-lived workflows. +func (storage *CacheStorage) Close() core.Result { + if storage == nil { + return core.Ok(nil) + } + if storage.runtime == nil { + storage.caches = make(map[string]*HTTPCache) + return core.Ok(nil) + } + lock := storage.runtime.Lock(cacheStorageDirName) + lock.Mutex.Lock() + defer lock.Mutex.Unlock() + storage.caches = make(map[string]*HTTPCache) + return core.Ok(nil) +} + +func (storage *CacheStorage) ensureReady(op string) core.Result { + if storage == nil { + return failure(op, "cache storage is nil", nil) + } + if storage.medium == nil { + return failure(op, "cache storage medium is nil; construct via cache.NewCacheStorage", nil) + } + if storage.baseDir == "" { + return failure(op, "cache storage base directory is empty; construct via cache.NewCacheStorage", nil) + } + if storage.runtime == nil { + return failure(op, "cache storage runtime is nil; construct via cache.NewCacheStorage", nil) + } + lock := storage.runtime.Lock(cacheStorageDirName) + lock.Mutex.Lock() + defer lock.Mutex.Unlock() + if storage.caches == nil { + storage.caches = make(map[string]*HTTPCache) + } + return core.Ok(nil) +} + +// HTTPCache stores request/response pairs. +type HTTPCache struct { + name string + medium coreio.Medium + baseDir string +} + +func (httpCache *HTTPCache) ensureReady(op string) core.Result { + if httpCache == nil { + return failure(op, "http cache is nil", nil) + } + if httpCache.medium == nil { + return failure(op, "http cache medium is nil; construct via cache.CacheStorage.Open", nil) + } + if httpCache.baseDir == "" { + return failure(op, "http cache base directory is empty; construct via cache.CacheStorage.Open", nil) + } + return core.Ok(nil) +} + +// CachedRequest identifies a request by URL and method. +type CachedRequest struct { + URL string `json:"url"` + Method string `json:"method"` +} + +// CachedResponse stores HTTP metadata for a cached response body. +type CachedResponse struct { + Status int `json:"status"` + StatusText string `json:"status_text"` + Headers map[string]string `json:"headers"` + BodyPath string `json:"body_path"` + CachedAt time.Time `json:"cached_at"` +} + +type cachedResponseRecord struct { + Request CachedRequest `json:"request"` + Response CachedResponse `json:"response"` +} + +func (httpCache *HTTPCache) storagePath(parts ...string) string { + args := append([]string{httpCache.baseDir}, parts...) + return core.JoinPath(args...) +} + +func (httpCache *HTTPCache) requestKey(req CachedRequest) core.Result { + return requestStorageKey(req) +} + +func legacyRequestKey(req CachedRequest) string { + return rawBase64URLEncode([]byte(req.Method + "\x00" + req.URL)) +} + +func rawBase64URLEncode(data []byte) string { + if len(data) == 0 { + return "" + } + + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + builder := core.NewBuilder() + i := 0 + for ; i+3 <= len(data); i += 3 { + n := uint(data[i])<<16 | uint(data[i+1])<<8 | uint(data[i+2]) + builder.WriteByte(alphabet[(n>>18)&0x3f]) + builder.WriteByte(alphabet[(n>>12)&0x3f]) + builder.WriteByte(alphabet[(n>>6)&0x3f]) + builder.WriteByte(alphabet[n&0x3f]) + } + switch len(data) - i { + case 1: + n := uint(data[i]) << 16 + builder.WriteByte(alphabet[(n>>18)&0x3f]) + builder.WriteByte(alphabet[(n>>12)&0x3f]) + case 2: + n := uint(data[i])<<16 | uint(data[i+1])<<8 + builder.WriteByte(alphabet[(n>>18)&0x3f]) + builder.WriteByte(alphabet[(n>>12)&0x3f]) + builder.WriteByte(alphabet[(n>>6)&0x3f]) + } + return builder.String() +} + +func rawBase64URLDecode(encoded string) core.Result { + if core.Contains(encoded, "=") { + return failure(opCacheRawBase64URLDecode, "raw URL base64 must not contain padding", nil) + } + if len(encoded)%4 == 1 { + return failure(opCacheRawBase64URLDecode, "invalid raw URL base64 length", nil) + } + + out := make([]byte, 0, len(encoded)*3/4) + for i := 0; i < len(encoded); { + remaining := len(encoded) - i + chunkLen := 4 + if remaining < chunkLen { + chunkLen = remaining + } + + var values [4]byte + for j := 0; j < chunkLen; j++ { + value := rawBase64URLDecodeValue(encoded[i+j]) + if value < 0 { + return failure(opCacheRawBase64URLDecode, "invalid raw URL base64 character", nil) + } + values[j] = byte(value) + } + + out = append(out, values[0]<<2|values[1]>>4) + if chunkLen >= 3 { + out = append(out, values[1]<<4|values[2]>>2) + } + if chunkLen == 4 { + out = append(out, values[2]<<6|values[3]) + } + i += chunkLen + } + return core.Ok(out) +} + +func rawBase64URLDecodeValue(c byte) int { + switch { + case c >= 'A' && c <= 'Z': + return int(c - 'A') + case c >= 'a' && c <= 'z': + return int(c-'a') + 26 + case c >= '0' && c <= '9': + return int(c-'0') + 52 + case c == '-': + return 62 + case c == '_': + return 63 + default: + return -1 + } +} + +func decodeRequestKey(encoded string) core.Result { + raw := rawBase64URLDecode(encoded) + if !raw.OK { + return failure("cache.decodeRequestKey", "invalid cached request key", resultCause(raw).Value.(error)) + } + parts := core.SplitN(string(raw.Value.([]byte)), "\x00", 2) + if len(parts) != 2 { + return failure("cache.decodeRequestKey", "invalid cached request key payload", nil) + } + return core.Ok(CachedRequest{Method: parts[0], URL: parts[1]}) +} + +func (httpCache *HTTPCache) responseMetaPath(key string) string { + return httpCache.storagePath(responsesDirName, key+".json") +} + +func (httpCache *HTTPCache) responseBinaryPath(key string) string { + return httpCache.storagePath(responsesDirName, key+".bin") +} + +func (httpCache *HTTPCache) readResponseRecord(key string) core.Result { + raw, err := httpCache.medium.Read(httpCache.responseMetaPath(key)) + if err != nil { + if core.Is(err, fs.ErrNotExist) { + return core.Ok(nil) + } + return failure(opHTTPCacheReadResponseRecord, "failed to read cached response", err) + } + + envelope := cachedResponseEnvelopeState(raw) + if !envelope.OK { + return envelope + } + state := envelope.Value.([2]bool) + if state[0] || state[1] { + return parseCachedResponseRecord(key, raw, state[0], state[1]) + } + return parseLegacyCachedResponseRecord(key, raw) +} + +func cachedResponseEnvelopeState(raw string) core.Result { + var envelope map[string]any + if r := core.JSONUnmarshalString(raw, &envelope); !r.OK { + return failure(opHTTPCacheReadResponseRecord, msgFailedUnmarshalCachedResponse, resultCause(r).Value.(error)) + } + _, hasRequest := envelope["request"] + _, hasResponse := envelope["response"] + return core.Ok([2]bool{hasRequest, hasResponse}) +} + +func parseCachedResponseRecord(key, raw string, hasRequest, hasResponse bool) core.Result { + if !hasRequest || !hasResponse { + return failure(opHTTPCacheReadResponseRecord, "cached response envelope is incomplete", nil) + } + + var record cachedResponseRecord + if r := core.JSONUnmarshalString(raw, &record); !r.OK { + return failure(opHTTPCacheReadResponseRecord, msgFailedUnmarshalCachedResponse, resultCause(r).Value.(error)) + } + if r := validateCachedResponseRecord(key, &record); !r.OK { + return r + } + return core.Ok(&record) +} + +func parseLegacyCachedResponseRecord(key, raw string) core.Result { + var response CachedResponse + if r := core.JSONUnmarshalString(raw, &response); !r.OK { + return failure(opHTTPCacheReadResponseRecord, msgFailedUnmarshalCachedResponse, resultCause(r).Value.(error)) + } + + req := decodeRequestKey(key) + if !req.OK { + return req + } + record := cachedResponseRecord{Request: req.Value.(CachedRequest), Response: response} + if r := validateCachedResponseRecord(key, &record); !r.OK { + return r + } + return core.Ok(&record) +} + +// Match finds a cached response for request. Missing entries return OK with nil Value. +func (httpCache *HTTPCache) Match(req CachedRequest) core.Result { + if r := httpCache.ensureReady("cache.HTTPCache.Match"); !r.OK { + return r + } + if r := validateCachedRequest(req); !r.OK { + return failure("cache.HTTPCache.Match", msgInvalidCachedRequest, resultCause(r).Value.(error)) + } + + key := httpCache.requestKey(req) + if !key.OK { + return key + } + record := httpCache.readResponseRecord(key.Value.(string)) + if !record.OK { + return record + } + if record.Value == nil { + record = httpCache.readResponseRecord(legacyRequestKey(req)) + } + if !record.OK || record.Value == nil { + return record + } + return core.Ok(&record.Value.(*cachedResponseRecord).Response) +} + +// Put stores a request/response pair and its body. +func (httpCache *HTTPCache) Put(req CachedRequest, resp CachedResponse, body []byte) core.Result { + if r := httpCache.ensureReady(opHTTPCachePut); !r.OK { + return r + } + key := httpCache.requestKey(req) + if !key.OK { + return key + } + if r := validateCachedRequest(req); !r.OK { + return failure(opHTTPCachePut, msgInvalidCachedRequest, resultCause(r).Value.(error)) + } + resp.BodyPath = core.JoinPath(responsesDirName, key.Value.(string)+".bin") + if resp.Headers == nil { + resp.Headers = make(map[string]string) + } + if r := validateCachedResponse(resp); !r.OK { + return failure(opHTTPCachePut, "invalid cached response", resultCause(r).Value.(error)) + } + if err := httpCache.medium.EnsureDir(httpCache.storagePath(responsesDirName)); err != nil { + return failure(opHTTPCachePut, "failed to create response directory", err) + } + + metaPath := httpCache.responseMetaPath(key.Value.(string)) + binaryPath := httpCache.responseBinaryPath(key.Value.(string)) + snapshots := readCachedResponseSnapshots(httpCache.medium, metaPath, binaryPath) + if !snapshots.OK { + return snapshots + } + pair := snapshots.Value.([2]fileSnapshot) + + resp.CachedAt = time.Now() + meta := marshalPrettyJSON(cachedResponseRecord{Request: req, Response: resp}) + if !meta.OK { + return failure(opHTTPCachePut, "failed to marshal cached response", resultCause(meta).Value.(error)) + } + + r := writeFileWithRollback(httpCache.medium, binaryPath, string(body), opHTTPCachePut, "failed to write cached response body", + snapshotRestore{snapshot: pair[0], message: "failed to restore response metadata after body write failure"}, + snapshotRestore{snapshot: pair[1], message: "failed to restore response body after body write failure"}, + ) + if !r.OK { + return r + } + return writeFileWithRollback(httpCache.medium, metaPath, meta.Value.(string), opHTTPCachePut, "failed to write cached response metadata", + snapshotRestore{snapshot: pair[1], message: "failed to restore response body after metadata write failure"}, + snapshotRestore{snapshot: pair[0], message: "failed to restore response metadata after metadata write failure"}, + ) +} + +// ReadBody returns the response body bytes. +func (httpCache *HTTPCache) ReadBody(resp *CachedResponse) core.Result { + if r := httpCache.ensureReady(opHTTPCacheReadBody); !r.OK { + return r + } + if resp == nil { + return failure(opHTTPCacheReadBody, "response is nil", nil) + } + if resp.BodyPath == "" { + return failure(opHTTPCacheReadBody, "response has empty body path", nil) + } + if r := ensureSafeResponseBodyPath(resp.BodyPath); !r.OK { + return failure(opHTTPCacheReadBody, "invalid response body path", resultCause(r).Value.(error)) + } + body, err := httpCache.medium.Read(httpCache.storagePath(resp.BodyPath)) + if err != nil { + return failure(opHTTPCacheReadBody, "failed to read response body", err) + } + return core.Ok([]byte(body)) +} + +func validateCachedResponseRecord(key string, record *cachedResponseRecord) core.Result { + if record == nil { + return failure(opHTTPCacheValidateCachedResponseRecord, "cached response record is nil", nil) + } + if r := validateCachedRequest(record.Request); !r.OK { + return failure(opHTTPCacheValidateCachedResponseRecord, msgInvalidCachedRequest, resultCause(r).Value.(error)) + } + + expectedKey := requestStorageKey(record.Request) + if !expectedKey.OK { + return expectedKey + } + legacyKey := legacyRequestKey(record.Request) + if key != expectedKey.Value.(string) && key != legacyKey { + return failure(opHTTPCacheValidateCachedResponseRecord, "cached request metadata does not match cache key", nil) + } + if r := validateCachedResponse(record.Response); !r.OK { + return r + } + expectedBodyPaths := []string{ + core.JoinPath(responsesDirName, expectedKey.Value.(string)+".bin"), + core.JoinPath(responsesDirName, legacyKey+".bin"), + } + if !slices.Contains(expectedBodyPaths, record.Response.BodyPath) { + return failure(opHTTPCacheValidateCachedResponseRecord, "cached response body path does not match cache key", nil) + } + return core.Ok(nil) +} + +func requestStorageKey(req CachedRequest) core.Result { + if r := validateCachedRequest(req); !r.OK { + return failure("cache.HTTPCache.requestStorageKey", msgInvalidCachedRequest, resultCause(r).Value.(error)) + } + return core.Ok(core.SHA256Hex([]byte(req.Method + "\x00" + req.URL))) +} + +func validateCachedRequest(req CachedRequest) core.Result { + if core.Trim(req.URL) == "" || core.Trim(req.Method) == "" { + return failure(opHTTPCacheValidateCachedRequest, "request URL and method are required", nil) + } + if len(req.URL) > maxCachedRequestURLBytes { + return failure(opHTTPCacheValidateCachedRequest, "request URL is too long", nil) + } + if len(req.Method) > maxCachedRequestMethodBytes { + return failure(opHTTPCacheValidateCachedRequest, "request method is too long", nil) + } + if hasHTTPDangerousBytes(req.URL) || hasHTTPDangerousBytes(req.Method) { + return failure(opHTTPCacheValidateCachedRequest, "request contains control characters", nil) + } + if !isHTTPToken(req.Method) { + return failure(opHTTPCacheValidateCachedRequest, "invalid HTTP method", nil) + } + return core.Ok(nil) +} + +func validateCachedResponse(resp CachedResponse) core.Result { + if resp.Status < 100 || resp.Status > 599 { + return failure(opHTTPCacheValidateCachedResponse, "invalid HTTP status", nil) + } + if hasHTTPDangerousBytes(resp.StatusText) { + return failure(opHTTPCacheValidateCachedResponse, "invalid HTTP status text", nil) + } + if len(resp.StatusText) > maxCachedStatusTextBytes { + return failure(opHTTPCacheValidateCachedResponse, "HTTP status text is too long", nil) + } + if r := ensureSafeResponseBodyPath(resp.BodyPath); !r.OK { + return failure(opHTTPCacheValidateCachedResponse, "invalid response body path", resultCause(r).Value.(error)) + } + if len(resp.Headers) > maxCachedHeaderCount { + return failure(opHTTPCacheValidateCachedResponse, "too many response headers", nil) + } + for name, value := range resp.Headers { + if len(name) > maxCachedHeaderNameBytes { + return failure(opHTTPCacheValidateCachedResponse, "response header name is too long", nil) + } + if len(value) > maxCachedHeaderValueBytes { + return failure(opHTTPCacheValidateCachedResponse, "response header value is too long", nil) + } + if r := validateHTTPHeaderName(name); !r.OK { + return failure(opHTTPCacheValidateCachedResponse, "invalid response header name", resultCause(r).Value.(error)) + } + if hasHTTPDangerousBytes(value) { + return failure(opHTTPCacheValidateCachedResponse, "invalid response header value", nil) + } + } + return core.Ok(nil) +} + +func validateHTTPHeaderName(name string) core.Result { + if name == "" { + return failure("cache.HTTPCache.validateHTTPHeaderName", "header name is empty", nil) + } + if !isHTTPToken(name) { + return failure("cache.HTTPCache.validateHTTPHeaderName", "invalid header name", nil) + } + return core.Ok(nil) +} + +func hasHTTPDangerousBytes(s string) bool { + return hasDangerousBytes(s) +} + +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. +func (httpCache *HTTPCache) Delete(req CachedRequest) core.Result { + if r := httpCache.ensureReady(opHTTPCacheDelete); !r.OK { + return r + } + if r := validateCachedRequest(req); !r.OK { + return failure(opHTTPCacheDelete, msgInvalidCachedRequest, resultCause(r).Value.(error)) + } + + key := httpCache.requestKey(req) + if !key.OK { + return key + } + keys := []string{key.Value.(string)} + legacyKey := legacyRequestKey(req) + if legacyKey != key.Value.(string) { + keys = append(keys, legacyKey) + } + for _, current := range keys { + if err := httpCache.medium.Delete(httpCache.responseMetaPath(current)); err != nil && !core.Is(err, fs.ErrNotExist) { + return failure(opHTTPCacheDelete, "failed to delete cached response metadata", err) + } + if err := httpCache.medium.Delete(httpCache.responseBinaryPath(current)); err != nil && !core.Is(err, fs.ErrNotExist) { + return failure(opHTTPCacheDelete, "failed to delete cached response body", err) + } + } + return core.Ok(nil) +} + +// Keys returns all cached request URLs. +func (httpCache *HTTPCache) Keys() core.Result { + if r := httpCache.ensureReady("cache.HTTPCache.Keys"); !r.OK { + return r + } + + entries, err := httpCache.medium.List(httpCache.storagePath(responsesDirName)) + if err != nil { + if core.Is(err, fs.ErrNotExist) { + return core.Ok([]string{}) + } + return failure("cache.HTTPCache.Keys", "failed to list response entries", err) + } + + seen := make(map[string]struct{}) + var urls []string + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !core.HasSuffix(name, ".json") { + continue + } + record := httpCache.readResponseRecord(core.TrimSuffix(name, ".json")) + if !record.OK || record.Value == nil { + continue + } + url := record.Value.(*cachedResponseRecord).Request.URL + if url == "" { + continue + } + if _, ok := seen[url]; ok { + continue + } + seen[url] = struct{}{} + urls = append(urls, url) + } + slices.Sort(urls) + return core.Ok(urls) +} + +func readBinarySnapshots(medium coreio.Medium, jsonPath, binaryPath string) core.Result { + jsonSnapshot := readSnapshot(medium, jsonPath, opCacheSetBinary, "failed to inspect existing binary metadata") + if !jsonSnapshot.OK { + return jsonSnapshot + } + binarySnapshot := readSnapshot(medium, binaryPath, opCacheSetBinary, "failed to inspect existing binary payload") + if !binarySnapshot.OK { + return binarySnapshot + } + return core.Ok([2]fileSnapshot{jsonSnapshot.Value.(fileSnapshot), binarySnapshot.Value.(fileSnapshot)}) +} + +func readCachedResponseSnapshots(medium coreio.Medium, metaPath, binaryPath string) core.Result { + metaSnapshot := readSnapshot(medium, metaPath, opHTTPCachePut, "failed to inspect existing cached response metadata") + if !metaSnapshot.OK { + return metaSnapshot + } + binarySnapshot := readSnapshot(medium, binaryPath, opHTTPCachePut, "failed to inspect existing cached response body") + if !binarySnapshot.OK { + return binarySnapshot + } + return core.Ok([2]fileSnapshot{metaSnapshot.Value.(fileSnapshot), binarySnapshot.Value.(fileSnapshot)}) +} + +func readSnapshot(medium coreio.Medium, path, op, message string) core.Result { + snapshot := readFileSnapshot(medium, path) + if !snapshot.OK { + return failure(op, message, resultCause(snapshot).Value.(error)) + } + return snapshot +} + +func readFileSnapshot(medium coreio.Medium, path string) core.Result { + content, err := medium.Read(path) + if err != nil { + if core.Is(err, fs.ErrNotExist) { + return core.Ok(fileSnapshot{path: path}) + } + return core.Fail(err) + } + return core.Ok(fileSnapshot{path: path, existed: true, content: content}) +} + +func writeFileWithRollback(medium coreio.Medium, path, content, op, message string, restores ...snapshotRestore) core.Result { + if err := medium.Write(path, content); err != nil { + restoreResult := restoreSnapshotsAfterFailure(medium, err, op, restores...) + if !restoreResult.OK { + return restoreResult + } + return failure(op, message, err) + } + return core.Ok(nil) +} + +func restoreSnapshotsAfterFailure(medium coreio.Medium, cause error, op string, restores ...snapshotRestore) core.Result { + for _, restore := range restores { + r := restoreFileSnapshot(medium, restore.snapshot) + if !r.OK { + return failure(op, restore.message, core.ErrorJoin(cause, resultCause(r).Value.(error))) + } + } + return core.Ok(nil) +} + +func restoreFileSnapshot(medium coreio.Medium, snapshot fileSnapshot) core.Result { + if snapshot.path == "" { + return core.Ok(nil) + } + if !snapshot.existed { + if err := medium.Delete(snapshot.path); err != nil && !core.Is(err, fs.ErrNotExist) { + return core.Fail(err) + } + return core.Ok(nil) + } + if err := medium.Write(snapshot.path, snapshot.content); err != nil { + return core.Fail(err) + } + return core.Ok(nil) +} + +// Clear removes all cached items under the cache base directory. +func (c *Cache) Clear() core.Result { + if r := c.ensureReady("cache.Clear"); !r.OK { + return r + } + if err := c.medium.DeleteAll(c.baseDir); err != nil { + return failure("cache.Clear", "failed to clear cache", err) + } + return core.Ok(nil) +} + +// Age reports how long ago key was cached, or -1 if it is missing or unreadable. +func (c *Cache) Age(key string) time.Duration { + if r := c.ensureReady("cache.Age"); !r.OK { + return -1 + } + path := c.Path(key) + if !path.OK { + return -1 + } + dataStr, err := c.medium.Read(path.Value.(string)) + if err != nil { + return -1 + } + var entry Entry + if r := core.JSONUnmarshalString(dataStr, &entry); !r.OK { + return -1 + } + return time.Since(entry.CachedAt) +} + +// GitHubReposKey returns the cache key used for an organisation's repo list. +func GitHubReposKey(org string) string { + return core.JoinPath("github", encodePathSegment(org), "repos") +} + +// GitHubRepoKey returns the cache key used for a repository metadata entry. +func GitHubRepoKey(org, repo string) string { + return core.JoinPath("github", encodePathSegment(org), encodePathSegment(repo), "meta") +} + +func encodePathSegment(segment string) string { + return core.URLPathEscape(segment) +} + +func marshalPrettyJSON(value any) core.Result { + result := core.JSONMarshalIndent(value, "", " ") + if !result.OK { + return result + } + return core.Ok(string(result.Value.([]byte))) +} + +func ensureSafeKey(key string) core.Result { + if key == "" { + return failure(opCacheValidateKey, "invalid empty key", nil) + } + if len(key) > maxCacheKeyBytes { + return failure(opCacheValidateKey, "invalid key: too long", nil) + } + if core.Contains(key, "\\") { + return failure(opCacheValidateKey, "invalid key: contains path separators", nil) + } + if hasPathDangerousBytes(key) { + return failure(opCacheValidateKey, "invalid key: contains control bytes", nil) + } + for _, part := range core.Split(key, "/") { + if part == "" || part == "." || part == ".." { + return failure(opCacheValidateKey, "invalid key: path traversal attempt", nil) + } + } + return core.Ok(nil) +} + +func ensureSafePattern(pattern string) core.Result { + if pattern == "" { + return failure(opCacheValidatePattern, "invalid empty pattern", nil) + } + if len(pattern) > maxCachePatternBytes { + return failure(opCacheValidatePattern, "invalid pattern: too long", nil) + } + if core.Contains(pattern, "\\") || hasPathDangerousBytes(pattern) { + return failure(opCacheValidatePattern, "invalid pattern: contains control bytes", nil) + } + return core.Ok(nil) +} + +func ensureNoSymlinkPath(baseDir, path string) core.Result { + if r := rejectSymlink(baseDir); !r.OK { + return r + } + if path == baseDir { + return core.Ok(nil) + } + + rel := core.TrimPrefix(path, normalizePath(core.Concat(baseDir, pathSeparator()))) + if rel == path { + return core.Ok(nil) + } + current := baseDir + for _, part := range core.Split(rel, pathSeparator()) { + if part == "" { + continue + } + current = core.JoinPath(current, part) + if r := rejectSymlink(current); !r.OK { + return r + } + } + return core.Ok(nil) +} + +func rejectSymlink(path string) core.Result { + result := core.Lstat(path) + if !result.OK { + if core.Is(resultCause(result).Value.(error), fs.ErrNotExist) { + return core.Ok(nil) + } + return result + } + info := result.Value.(fs.FileInfo) + if info.Mode()&fs.ModeSymlink != 0 { + return failure("cache.validatePath", "path contains symlink", nil) + } + return core.Ok(nil) +} + +func hasPathDangerousBytes(s string) bool { + return hasDangerousBytes(s) +} + +func hasDangerousBytes(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] < 0x20 || s[i] == 0x7f { + return true + } + } + return false +} + +func ensureSafeResponseBodyPath(path string) core.Result { + if path == "" { + return failure(opCacheValidateResponseBodyPath, "invalid empty body path", nil) + } + if len(path) > maxCacheKeyBytes { + return failure(opCacheValidateResponseBodyPath, "invalid body path: too long", nil) + } + if core.PathIsAbs(path) { + return failure(opCacheValidateResponseBodyPath, "invalid body path: absolute paths are not allowed", nil) + } + if core.Contains(path, "\\") || hasPathDangerousBytes(path) { + return failure(opCacheValidateResponseBodyPath, "invalid body path: contains control bytes", nil) + } + + normalized := normalizePath(path) + if !core.HasPrefix(normalized, responsesPathPrefix) || !core.HasSuffix(normalized, ".bin") { + return failure(opCacheValidateResponseBodyPath, "invalid body path: expected responses/.bin", nil) + } + + rel := core.TrimPrefix(normalized, responsesPathPrefix) + rel = core.TrimSuffix(rel, ".bin") + if rel == "" { + return failure(opCacheValidateResponseBodyPath, "invalid body path", nil) + } + for _, segment := range core.Split(rel, "/") { + if r := ensureSafeKey(segment); !r.OK { + return failure(opCacheValidateResponseBodyPath, "invalid body path", resultCause(r).Value.(error)) + } + } + return core.Ok(nil) +} + +func ensureSafeCacheName(op, name string) core.Result { + if name == "" { + return failure(op, "cache name is empty", nil) + } + if len(name) > maxCacheNameBytes { + return failure(op, "invalid cache name: too long", nil) + } + if core.Contains(name, "/") || core.Contains(name, `\`) { + return failure(op, msgInvalidCacheName, nil) + } + if hasPathDangerousBytes(name) { + return failure(op, msgInvalidCacheName, nil) + } + if name == "." || name == ".." { + return failure(op, msgInvalidCacheName, nil) + } + return core.Ok(nil) +} + +func pathSeparator() string { + if ds := core.Env("DS"); ds != "" { + return ds + } + return "/" +} + +func normalizePath(path string) string { + ds := pathSeparator() + normalized := core.Replace(path, "\\", ds) + if ds != "/" { + normalized = core.Replace(normalized, "/", ds) + } + return core.CleanPath(normalized, ds) +} + +func absolutePath(path string) string { + normalized := normalizePath(path) + if core.PathIsAbs(normalized) { + return normalized + } + cwd := currentDir() + if cwd == "" || cwd == "." { + return normalized + } + return normalizePath(core.JoinPath(cwd, normalized)) +} + +func currentDir() string { + if cwd := core.Getwd(); cwd.OK && cwd.Value.(string) != "" { + return normalizePath(cwd.Value.(string)) + } + cwd := normalizePath(core.Env("PWD")) + if cwd != "" && cwd != "." { + return cwd + } + return normalizePath(core.Env("DIR_CWD")) +} + +func (c *Cache) ensureConfigured(op string) core.Result { + if c == nil { + return failure(op, "cache is nil", nil) + } + if c.baseDir == "" { + return failure(op, "cache base directory is empty; construct with cache.New", nil) + } + if c.runtime == nil { + return failure(op, "cache runtime is nil; construct with cache.New", nil) + } + return core.Ok(nil) +} + +func (c *Cache) ensureReady(op string) core.Result { + if r := c.ensureConfigured(op); !r.OK { + return r + } + if c.medium == nil { + return failure(op, "cache medium is nil; construct with cache.New", nil) + } + return core.Ok(nil) +} + +func failure(op, message string, cause error) core.Result { + return core.Fail(core.E(op, message, cause)) +} + +func resultCause(result core.Result) core.Result { + if err, ok := result.Value.(error); ok { + return core.Ok(err) + } + return core.Ok(core.E("cache.result", "unexpected result failure", nil)) +} diff --git a/go/cache_example_test.go b/go/cache_example_test.go new file mode 100644 index 0000000..c099f3f --- /dev/null +++ b/go/cache_example_test.go @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package cache_test + +import ( + "time" + + "dappco.re/go/cache" + coreio "dappco.re/go/io" +) + +func exampleCache() *cache.Cache { + return cache.New(coreio.NewMockMedium(), "/tmp/go-cache-example", time.Minute).Value.(*cache.Cache) +} + +func exampleStorage() *cache.CacheStorage { + return cache.NewCacheStorage(coreio.NewMockMedium(), "/tmp/go-cache-storage-example").Value.(*cache.CacheStorage) +} + +func exampleHTTPCache() *cache.HTTPCache { + storage := exampleStorage() + return storage.Open("app-v1").Value.(*cache.HTTPCache) +} + +func ExampleNew() { + r := cache.New(coreio.NewMockMedium(), "/tmp/go-cache-example-new", cache.DefaultTTL) + if r.OK { + r.Value.(*cache.Cache).Set("agent/profile", map[string]string{"name": "codex"}) + } +} + +func ExampleCache_Path() { + c := exampleCache() + c.Path("agent/profile") +} + +func ExampleCache_Get() { + c := exampleCache() + c.Set("agent/profile", map[string]string{"name": "codex"}) + var profile map[string]string + c.Get("agent/profile", &profile) +} + +func ExampleCache_Set() { + c := exampleCache() + c.Set("agent/profile", map[string]string{"name": "codex"}) +} + +func ExampleCache_SetWithTTL() { + c := exampleCache() + c.SetWithTTL("agent/profile", "codex", time.Minute) +} + +func ExampleCache_Delete() { + c := exampleCache() + c.Set("agent/profile", "codex") + c.Delete("agent/profile") +} + +func ExampleCache_SetBinary() { + c := exampleCache() + c.SetBinary("artifact/blob", []byte("data"), "application/octet-stream") +} + +func ExampleCache_SetBinaryWithTTL() { + c := exampleCache() + c.SetBinaryWithTTL("artifact/blob", []byte("data"), "application/octet-stream", time.Minute) +} + +func ExampleCache_GetBinary() { + c := exampleCache() + c.SetBinary("artifact/blob", []byte("data"), "application/octet-stream") + c.GetBinary("artifact/blob") +} + +func ExampleCache_DeleteMany() { + c := exampleCache() + c.Set("agent/one", "1") + c.Set("agent/two", "2") + c.DeleteMany("agent/one", "agent/two") +} + +func ExampleCache_OnInvalidate() { + c := exampleCache() + c.OnInvalidate("agent.changed", func(string) []string { return []string{"agent/*"} }) +} + +func ExampleCache_Invalidate() { + c := exampleCache() + c.OnInvalidate("agent.changed", func(string) []string { return []string{"agent/*"} }) + c.Invalidate("agent.changed") +} + +func ExampleCache_Scoped() { + c := exampleCache() + scoped := c.Scoped("https://app.example") + scoped.Set("agent/profile", "codex") +} + +func ExampleCache_ClearScope() { + c := exampleCache() + c.Scoped("https://app.example").Set("agent/profile", "codex") + c.ClearScope("https://app.example") +} + +func ExampleScopedCache_Scoped() { + scoped := exampleCache().Scoped("https://app.example") + scoped.Scoped("https://admin.example") +} + +func ExampleScopedCache_Path() { + scoped := exampleCache().Scoped("https://app.example") + scoped.Path("agent/profile") +} + +func ExampleScopedCache_Get() { + scoped := exampleCache().Scoped("https://app.example") + scoped.Set("agent/profile", "codex") + var profile string + scoped.Get("agent/profile", &profile) +} + +func ExampleScopedCache_Set() { + scoped := exampleCache().Scoped("https://app.example") + scoped.Set("agent/profile", "codex") +} + +func ExampleScopedCache_SetWithTTL() { + scoped := exampleCache().Scoped("https://app.example") + scoped.SetWithTTL("agent/profile", "codex", time.Minute) +} + +func ExampleScopedCache_SetBinary() { + scoped := exampleCache().Scoped("https://app.example") + scoped.SetBinary("artifact/blob", []byte("data"), "application/octet-stream") +} + +func ExampleScopedCache_SetBinaryWithTTL() { + scoped := exampleCache().Scoped("https://app.example") + scoped.SetBinaryWithTTL("artifact/blob", []byte("data"), "application/octet-stream", time.Minute) +} + +func ExampleScopedCache_GetBinary() { + scoped := exampleCache().Scoped("https://app.example") + scoped.SetBinary("artifact/blob", []byte("data"), "application/octet-stream") + scoped.GetBinary("artifact/blob") +} + +func ExampleScopedCache_Delete() { + scoped := exampleCache().Scoped("https://app.example") + scoped.Set("agent/profile", "codex") + scoped.Delete("agent/profile") +} + +func ExampleScopedCache_DeleteMany() { + scoped := exampleCache().Scoped("https://app.example") + scoped.Set("agent/one", "1") + scoped.Set("agent/two", "2") + scoped.DeleteMany("agent/one", "agent/two") +} + +func ExampleScopedCache_Clear() { + scoped := exampleCache().Scoped("https://app.example") + scoped.Set("agent/profile", "codex") + scoped.Clear() +} + +func ExampleScopedCache_ClearScope() { + scoped := exampleCache().Scoped("https://app.example") + scoped.Scoped("https://admin.example").Set("agent/profile", "codex") + scoped.ClearScope("https://admin.example") +} + +func ExampleScopedCache_OnInvalidate() { + scoped := exampleCache().Scoped("https://app.example") + scoped.OnInvalidate("agent.changed", func(string) []string { return []string{"agent/*"} }) +} + +func ExampleScopedCache_Invalidate() { + scoped := exampleCache().Scoped("https://app.example") + scoped.OnInvalidate("agent.changed", func(string) []string { return []string{"agent/*"} }) + scoped.Invalidate("agent.changed") +} + +func ExampleScopedCache_Age() { + scoped := exampleCache().Scoped("https://app.example") + scoped.Set("agent/profile", "codex") + scoped.Age("agent/profile") +} + +func ExampleNewCacheStorage() { + cache.NewCacheStorage(coreio.NewMockMedium(), "/tmp/go-cache-storage-example-new") +} + +func ExampleCacheStorage_Open() { + storage := exampleStorage() + storage.Open("app-v1") +} + +func ExampleCacheStorage_Delete() { + storage := exampleStorage() + storage.Open("app-v1") + storage.Delete("app-v1") +} + +func ExampleCacheStorage_Keys() { + storage := exampleStorage() + storage.Open("app-v1") + storage.Keys() +} + +func ExampleCacheStorage_Close() { + storage := exampleStorage() + storage.Close() +} + +func ExampleHTTPCache_Match() { + httpCache := exampleHTTPCache() + req := cache.CachedRequest{Method: "GET", URL: "https://example.com/data"} + httpCache.Put(req, cache.CachedResponse{Status: 200, StatusText: "OK"}, []byte("body")) + httpCache.Match(req) +} + +func ExampleHTTPCache_Put() { + httpCache := exampleHTTPCache() + req := cache.CachedRequest{Method: "GET", URL: "https://example.com/data"} + httpCache.Put(req, cache.CachedResponse{Status: 200, StatusText: "OK"}, []byte("body")) +} + +func ExampleHTTPCache_ReadBody() { + httpCache := exampleHTTPCache() + req := cache.CachedRequest{Method: "GET", URL: "https://example.com/data"} + httpCache.Put(req, cache.CachedResponse{Status: 200, StatusText: "OK"}, []byte("body")) + if r := httpCache.Match(req); r.OK && r.Value != nil { + httpCache.ReadBody(r.Value.(*cache.CachedResponse)) + } +} + +func ExampleHTTPCache_Delete() { + httpCache := exampleHTTPCache() + req := cache.CachedRequest{Method: "GET", URL: "https://example.com/data"} + httpCache.Delete(req) +} + +func ExampleHTTPCache_Keys() { + httpCache := exampleHTTPCache() + req := cache.CachedRequest{Method: "GET", URL: "https://example.com/data"} + httpCache.Put(req, cache.CachedResponse{Status: 200, StatusText: "OK"}, []byte("body")) + httpCache.Keys() +} + +func ExampleCache_Clear() { + c := exampleCache() + c.Set("agent/profile", "codex") + c.Clear() +} + +func ExampleCache_Age() { + c := exampleCache() + c.Set("agent/profile", "codex") + c.Age("agent/profile") +} + +func ExampleGitHubReposKey() { + cache.GitHubReposKey("acme") +} + +func ExampleGitHubRepoKey() { + cache.GitHubRepoKey("acme", "widgets") +} diff --git a/go/cache_test.go b/go/cache_test.go new file mode 100644 index 0000000..bef8720 --- /dev/null +++ b/go/cache_test.go @@ -0,0 +1,1081 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package cache_test + +import ( + "io/fs" + "time" + + . "dappco.re/go" + "dappco.re/go/cache" + coreio "dappco.re/go/io" +) + +const ( + testBase = "/tmp/go-cache-v090" + testKey = "agent/profile" + testTextPlain = "text/plain" + testAppOrigin = "https://app.example" + testAdminOrigin = "https://admin.example" + testURL = "https://example.com/data" + testHeaderName = "Content-Type" + testHeaderValue = "text/plain" + testLongCacheKeySize = 4097 +) + +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 testCache(t *T, baseDir string) (*cache.Cache, *coreio.MockMedium) { + t.Helper() + medium := coreio.NewMockMedium() + r := cache.New(medium, baseDir, time.Minute) + RequireTrue(t, r.OK) + return r.Value.(*cache.Cache), medium +} + +func testStorage(t *T, baseDir string) (*cache.CacheStorage, *coreio.MockMedium) { + t.Helper() + medium := coreio.NewMockMedium() + r := cache.NewCacheStorage(medium, baseDir) + RequireTrue(t, r.OK) + return r.Value.(*cache.CacheStorage), medium +} + +func testHTTPCache(t *T, baseDir, name string) (*cache.HTTPCache, *coreio.MockMedium) { + t.Helper() + storage, medium := testStorage(t, baseDir) + r := storage.Open(name) + RequireTrue(t, r.OK) + return r.Value.(*cache.HTTPCache), medium +} + +func longString(s string, count int) string { + builder := NewBuilder() + for range count { + builder.WriteString(s) + } + return builder.String() +} + +func request(method, url string) cache.CachedRequest { + return cache.CachedRequest{Method: method, URL: url} +} + +func response(status int) cache.CachedResponse { + return cache.CachedResponse{ + Status: status, + StatusText: "OK", + Headers: map[string]string{testHeaderName: testHeaderValue}, + } +} + +func resultString(t *T, r Result) string { + t.Helper() + RequireTrue(t, r.OK) + return r.Value.(string) +} + +func resultBool(t *T, r Result) bool { + t.Helper() + RequireTrue(t, r.OK) + return r.Value.(bool) +} + +func resultInt(t *T, r Result) int { + t.Helper() + RequireTrue(t, r.OK) + return r.Value.(int) +} + +func resultBytes(t *T, r Result) []byte { + t.Helper() + RequireTrue(t, r.OK) + return r.Value.([]byte) +} + +func TestCache_New_Good(t *T) { + r := cache.New(coreio.NewMockMedium(), testBase+"/new-good", time.Minute) + RequireTrue(t, r.OK) + c := r.Value.(*cache.Cache) + AssertTrue(t, c.Set(testKey, map[string]string{"name": "codex"}).OK) +} + +func TestCache_New_Bad(t *T) { + r := cache.New(coreio.NewMockMedium(), testBase+"/new-bad", -time.Second) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "ttl") +} + +func TestCache_New_Ugly(t *T) { + r := cache.New(nil, t.TempDir(), 0) + RequireTrue(t, r.OK) + c := r.Value.(*cache.Cache) + AssertTrue(t, c.Delete("missing/key").OK) +} + +func TestCache_Path_Good(t *T) { + c, _ := testCache(t, testBase+"/path-good") + path := resultString(t, c.Path(testKey)) + AssertContains(t, path, testKey+".json") + AssertContains(t, path, "path-good") +} + +func TestCache_Path_Bad(t *T) { + c, _ := testCache(t, testBase+"/path-bad") + r := c.Path("../escape") + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "traversal") +} + +func TestCache_Path_Ugly(t *T) { + c, _ := testCache(t, testBase+"/path-ugly") + r := c.Path(longString("a", testLongCacheKeySize)) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "too long") +} + +func TestCache_Get_Good(t *T) { + c, _ := testCache(t, testBase+"/get-good") + RequireTrue(t, c.Set(testKey, map[string]string{"name": "codex"}).OK) + var got map[string]string + AssertTrue(t, resultBool(t, c.Get(testKey, &got))) + AssertEqual(t, "codex", got["name"]) +} + +func TestCache_Get_Bad(t *T) { + c, _ := testCache(t, testBase+"/get-bad") + var got map[string]string + found := resultBool(t, c.Get("missing/key", &got)) + AssertFalse(t, found) + AssertNil(t, got) +} + +func TestCache_Get_Ugly(t *T) { + c, medium := testCache(t, testBase+"/get-ugly") + path := resultString(t, c.Path(testKey)) + RequireNoError(t, medium.Write(path, "{not-json")) + var got map[string]string + r := c.Get(testKey, &got) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "unmarshal") +} + +func TestCache_Set_Good(t *T) { + c, medium := testCache(t, testBase+"/set-good") + r := c.Set(testKey, map[string]string{"mode": "good"}) + RequireTrue(t, r.OK) + AssertTrue(t, medium.IsFile(resultString(t, c.Path(testKey)))) +} + +func TestCache_Set_Bad(t *T) { + c, _ := testCache(t, testBase+"/set-bad") + r := c.Set(testKey, make(chan int)) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "marshal") +} + +func TestCache_Set_Ugly(t *T) { + c, _ := testCache(t, testBase+"/set-ugly") + r := c.Set("edge/empty", map[string]string{}) + RequireTrue(t, r.OK) + var got map[string]string + AssertTrue(t, resultBool(t, c.Get("edge/empty", &got))) +} + +func TestCache_SetWithTTL_Good(t *T) { + c, _ := testCache(t, testBase+"/ttl-good") + r := c.SetWithTTL(testKey, "fresh", time.Minute) + RequireTrue(t, r.OK) + var got string + AssertTrue(t, resultBool(t, c.Get(testKey, &got))) +} + +func TestCache_SetWithTTL_Bad(t *T) { + c, _ := testCache(t, testBase+"/ttl-bad") + r := c.SetWithTTL(testKey, "expired", -time.Second) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "ttl") +} + +func TestCache_SetWithTTL_Ugly(t *T) { + c, _ := testCache(t, testBase+"/ttl-ugly") + RequireTrue(t, c.SetWithTTL(testKey, "now", 0).OK) + var got string + AssertFalse(t, resultBool(t, c.Get(testKey, &got))) +} + +func TestCache_Delete_Good(t *T) { + c, medium := testCache(t, testBase+"/delete-good") + RequireTrue(t, c.Set(testKey, "delete").OK) + RequireTrue(t, c.Delete(testKey).OK) + AssertFalse(t, medium.IsFile(resultString(t, c.Path(testKey)))) +} + +func TestCache_Delete_Bad(t *T) { + var c *cache.Cache + r := c.Delete(testKey) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "nil") +} + +func TestCache_Delete_Ugly(t *T) { + c, _ := testCache(t, testBase+"/delete-ugly") + r := c.Delete("missing/key") + AssertTrue(t, r.OK) + AssertNil(t, r.Value) +} + +func TestCache_SetBinary_Good(t *T) { + c, _ := testCache(t, testBase+"/binary-good") + RequireTrue(t, c.SetBinary("artifact/blob", []byte("wasm"), "application/wasm").OK) + AssertEqual(t, "wasm", string(resultBytes(t, c.GetBinary("artifact/blob")))) +} + +func TestCache_SetBinary_Bad(t *T) { + var c *cache.Cache + r := c.SetBinary("artifact/blob", []byte("x"), testTextPlain) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "nil") +} + +func TestCache_SetBinary_Ugly(t *T) { + medium := newScriptedMedium() + c := cache.New(medium, testBase+"/binary-ugly", time.Minute).Value.(*cache.Cache) + path := resultString(t, c.Path("artifact/blob")) + medium.writeErr[TrimSuffix(path, ".json")+".bin"] = AnError + r := c.SetBinary("artifact/blob", []byte("x"), testTextPlain) + AssertFalse(t, r.OK) + AssertFalse(t, medium.IsFile(path)) +} + +func TestCache_SetBinaryWithTTL_Good(t *T) { + c, _ := testCache(t, testBase+"/binary-ttl-good") + r := c.SetBinaryWithTTL("artifact/blob", []byte("abc"), testTextPlain, time.Minute) + RequireTrue(t, r.OK) + AssertEqual(t, "abc", string(resultBytes(t, c.GetBinary("artifact/blob")))) +} + +func TestCache_SetBinaryWithTTL_Bad(t *T) { + c, _ := testCache(t, testBase+"/binary-ttl-bad") + r := c.SetBinaryWithTTL("artifact/blob", []byte("abc"), testTextPlain, -time.Second) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "ttl") +} + +func TestCache_SetBinaryWithTTL_Ugly(t *T) { + c, _ := testCache(t, testBase+"/binary-ttl-ugly") + RequireTrue(t, c.SetBinaryWithTTL("artifact/blob", []byte("abc"), testTextPlain, 0).OK) + AssertNil(t, c.GetBinary("artifact/blob").Value) +} + +func TestCache_GetBinary_Good(t *T) { + c, _ := testCache(t, testBase+"/get-binary-good") + RequireTrue(t, c.SetBinary("artifact/blob", []byte{0, 1, 2}, testTextPlain).OK) + AssertEqual(t, []byte{0, 1, 2}, resultBytes(t, c.GetBinary("artifact/blob"))) +} + +func TestCache_GetBinary_Bad(t *T) { + c, _ := testCache(t, testBase+"/get-binary-bad") + r := c.GetBinary("missing/blob") + RequireTrue(t, r.OK) + AssertNil(t, r.Value) +} + +func TestCache_GetBinary_Ugly(t *T) { + c, medium := testCache(t, testBase+"/get-binary-ugly") + path := resultString(t, c.Path("artifact/blob")) + RequireNoError(t, medium.Write(path, "{bad-json")) + r := c.GetBinary("artifact/blob") + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "metadata") +} + +func TestCache_DeleteMany_Good(t *T) { + c, medium := testCache(t, testBase+"/delete-many-good") + RequireTrue(t, c.Set("agent/one", "1").OK) + RequireTrue(t, c.Set("agent/two", "2").OK) + RequireTrue(t, c.DeleteMany("agent/one", "agent/two").OK) + AssertFalse(t, medium.IsFile(resultString(t, c.Path("agent/one")))) +} + +func TestCache_DeleteMany_Bad(t *T) { + c, medium := testCache(t, testBase+"/delete-many-bad") + RequireTrue(t, c.Set("agent/keep", "1").OK) + r := c.DeleteMany("../escape", "agent/keep") + AssertFalse(t, r.OK) + AssertTrue(t, medium.IsFile(resultString(t, c.Path("agent/keep")))) +} + +func TestCache_DeleteMany_Ugly(t *T) { + c, _ := testCache(t, testBase+"/delete-many-ugly") + r := c.DeleteMany("missing/one", "missing/two") + AssertTrue(t, r.OK) + AssertNil(t, r.Value) +} + +func TestCache_OnInvalidate_Good(t *T) { + c, _ := testCache(t, testBase+"/on-invalidate-good") + c.OnInvalidate("profile.changed", func(string) []string { return []string{"agent/*"} }) + RequireTrue(t, c.Set(testKey, "value").OK) + AssertEqual(t, 1, resultInt(t, c.Invalidate("profile.changed"))) +} + +func TestCache_OnInvalidate_Bad(t *T) { + c, _ := testCache(t, testBase+"/on-invalidate-bad") + c.OnInvalidate("profile.changed", nil) + RequireTrue(t, c.Set(testKey, "value").OK) + AssertEqual(t, 0, resultInt(t, c.Invalidate("profile.changed"))) +} + +func TestCache_OnInvalidate_Ugly(t *T) { + var c *cache.Cache + c.OnInvalidate("profile.changed", func(string) []string { return []string{"agent/*"} }) + AssertNil(t, c) +} + +func TestCache_Invalidate_Good(t *T) { + c, _ := testCache(t, testBase+"/invalidate-good") + c.OnInvalidate("profile.changed", func(string) []string { return []string{"agent/*"} }) + RequireTrue(t, c.Set(testKey, "value").OK) + AssertEqual(t, 1, resultInt(t, c.Invalidate("profile.changed"))) +} + +func TestCache_Invalidate_Bad(t *T) { + c, _ := testCache(t, testBase+"/invalidate-bad") + c.OnInvalidate("bad", func(string) []string { return []string{longString("a", testLongCacheKeySize)} }) + r := c.Invalidate("bad") + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "pattern") +} + +func TestCache_Invalidate_Ugly(t *T) { + c, _ := testCache(t, testBase+"/invalidate-ugly") + deleted := resultInt(t, c.Invalidate("no.callbacks")) + AssertEqual(t, 0, deleted) +} + +func TestCache_Scoped_Good(t *T) { + c, _ := testCache(t, testBase+"/scoped-good") + scoped := c.Scoped(testAppOrigin) + RequireTrue(t, scoped != nil) + AssertTrue(t, scoped.Set("prefs/theme", "dark").OK) +} + +func TestCache_Scoped_Bad(t *T) { + var c *cache.Cache + scoped := c.Scoped(testAppOrigin) + AssertNil(t, scoped) + AssertEqual(t, (*cache.ScopedCache)(nil), scoped) +} + +func TestCache_Scoped_Ugly(t *T) { + c, _ := testCache(t, testBase+"/scoped-ugly") + scoped := c.Scoped("") + RequireTrue(t, scoped != nil) + AssertTrue(t, scoped.Set("prefs/theme", "dark").OK) +} + +func TestCache_ClearScope_Good(t *T) { + c, _ := testCache(t, testBase+"/clear-scope-good") + RequireTrue(t, c.Scoped(testAppOrigin).Set("prefs/theme", "dark").OK) + RequireTrue(t, c.ClearScope(testAppOrigin).OK) + AssertEqual(t, time.Duration(-1), c.Scoped(testAppOrigin).Age("prefs/theme")) +} + +func TestCache_ClearScope_Bad(t *T) { + var c *cache.Cache + r := c.ClearScope(testAppOrigin) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "nil") +} + +func TestCache_ClearScope_Ugly(t *T) { + c, _ := testCache(t, testBase+"/clear-scope-ugly") + r := c.ClearScope("no-entries") + AssertTrue(t, r.OK) + AssertNil(t, r.Value) +} + +func TestCache_Clear_Good(t *T) { + c, _ := testCache(t, testBase+"/clear-good") + RequireTrue(t, c.Set(testKey, "value").OK) + r := c.Clear() + AssertTrue(t, r.OK) + AssertEqual(t, time.Duration(-1), c.Age(testKey)) +} + +func TestCache_Clear_Bad(t *T) { + var c *cache.Cache + r := c.Clear() + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "nil") +} + +func TestCache_Clear_Ugly(t *T) { + c, _ := testCache(t, testBase+"/clear-ugly") + RequireTrue(t, c.Clear().OK) + r := c.Clear() + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "clear") +} + +func TestCache_Age_Good(t *T) { + c, _ := testCache(t, testBase+"/age-good") + RequireTrue(t, c.Set(testKey, "value").OK) + age := c.Age(testKey) + AssertTrue(t, age >= 0) +} + +func TestCache_Age_Bad(t *T) { + c, _ := testCache(t, testBase+"/age-bad") + age := c.Age("missing/key") + AssertEqual(t, time.Duration(-1), age) +} + +func TestCache_Age_Ugly(t *T) { + var c *cache.Cache + age := c.Age(testKey) + AssertEqual(t, time.Duration(-1), age) +} + +func TestCache_GitHubReposKey_Good(t *T) { + key := cache.GitHubReposKey("acme") + AssertEqual(t, "github/acme/repos", key) + AssertContains(t, key, "repos") +} + +func TestCache_GitHubReposKey_Bad(t *T) { + key := cache.GitHubReposKey("acme/widgets") + AssertContains(t, key, "acme%2Fwidgets") + AssertNotContains(t, key, "acme/widgets") +} + +func TestCache_GitHubReposKey_Ugly(t *T) { + key := cache.GitHubReposKey("") + AssertEqual(t, "github//repos", key) + AssertContains(t, key, "github") +} + +func TestCache_GitHubRepoKey_Good(t *T) { + key := cache.GitHubRepoKey("acme", "widgets") + AssertEqual(t, "github/acme/widgets/meta", key) + AssertContains(t, key, "meta") +} + +func TestCache_GitHubRepoKey_Bad(t *T) { + key := cache.GitHubRepoKey("acme/widgets", "api server") + AssertContains(t, key, "acme%2Fwidgets") + AssertContains(t, key, "api%20server") +} + +func TestCache_GitHubRepoKey_Ugly(t *T) { + key := cache.GitHubRepoKey("", "") + AssertEqual(t, "github///meta", key) + AssertContains(t, key, "github") +} + +func TestCache_ScopedCache_Scoped_Good(t *T) { + c, _ := testCache(t, testBase+"/scoped-nested-good") + nested := c.Scoped(testAppOrigin).Scoped(testAdminOrigin) + RequireTrue(t, nested != nil) + AssertTrue(t, nested.Set("prefs/theme", "dark").OK) +} + +func TestCache_ScopedCache_Scoped_Bad(t *T) { + var scoped *cache.ScopedCache + nested := scoped.Scoped(testAdminOrigin) + AssertNil(t, nested) + AssertEqual(t, (*cache.ScopedCache)(nil), nested) +} + +func TestCache_ScopedCache_Scoped_Ugly(t *T) { + c, _ := testCache(t, testBase+"/scoped-nested-ugly") + nested := c.Scoped(testAppOrigin).Scoped("") + RequireTrue(t, nested != nil) + AssertTrue(t, nested.Set("prefs/theme", "dark").OK) +} + +func TestCache_ScopedCache_Path_Good(t *T) { + c, _ := testCache(t, testBase+"/scoped-path-good") + path := resultString(t, c.Scoped(testAppOrigin).Path("prefs/theme")) + AssertContains(t, path, "scope_") + AssertContains(t, path, "prefs/theme.json") +} + +func TestCache_ScopedCache_Path_Bad(t *T) { + var scoped *cache.ScopedCache + r := scoped.Path("prefs/theme") + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "nil") +} + +func TestCache_ScopedCache_Path_Ugly(t *T) { + c, _ := testCache(t, testBase+"/scoped-path-ugly") + r := c.Scoped(testAppOrigin).Path("../escape") + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "traversal") +} + +func TestCache_ScopedCache_Get_Good(t *T) { + c, _ := testCache(t, testBase+"/scoped-get-good") + scoped := c.Scoped(testAppOrigin) + RequireTrue(t, scoped.Set("prefs/theme", "dark").OK) + var got string + AssertTrue(t, resultBool(t, scoped.Get("prefs/theme", &got))) +} + +func TestCache_ScopedCache_Get_Bad(t *T) { + var scoped *cache.ScopedCache + var got string + r := scoped.Get("prefs/theme", &got) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "nil") +} + +func TestCache_ScopedCache_Get_Ugly(t *T) { + c, _ := testCache(t, testBase+"/scoped-get-ugly") + var got string + AssertFalse(t, resultBool(t, c.Scoped(testAppOrigin).Get("missing/key", &got))) +} + +func TestCache_ScopedCache_Set_Good(t *T) { + c, _ := testCache(t, testBase+"/scoped-set-good") + scoped := c.Scoped(testAppOrigin) + AssertTrue(t, scoped.Set("prefs/theme", "dark").OK) + AssertTrue(t, scoped.Age("prefs/theme") >= 0) +} + +func TestCache_ScopedCache_Set_Bad(t *T) { + var scoped *cache.ScopedCache + r := scoped.Set("prefs/theme", "dark") + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "nil") +} + +func TestCache_ScopedCache_Set_Ugly(t *T) { + c, _ := testCache(t, testBase+"/scoped-set-ugly") + r := c.Scoped(testAppOrigin).Set("../escape", "dark") + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "traversal") +} + +func TestCache_ScopedCache_SetWithTTL_Good(t *T) { + c, _ := testCache(t, testBase+"/scoped-ttl-good") + scoped := c.Scoped(testAppOrigin) + AssertTrue(t, scoped.SetWithTTL("prefs/theme", "dark", time.Minute).OK) + AssertTrue(t, scoped.Age("prefs/theme") >= 0) +} + +func TestCache_ScopedCache_SetWithTTL_Bad(t *T) { + c, _ := testCache(t, testBase+"/scoped-ttl-bad") + r := c.Scoped(testAppOrigin).SetWithTTL("prefs/theme", "dark", -time.Second) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "ttl") +} + +func TestCache_ScopedCache_SetWithTTL_Ugly(t *T) { + c, _ := testCache(t, testBase+"/scoped-ttl-ugly") + scoped := c.Scoped(testAppOrigin) + RequireTrue(t, scoped.SetWithTTL("prefs/theme", "dark", 0).OK) + var got string + AssertFalse(t, resultBool(t, scoped.Get("prefs/theme", &got))) +} + +func TestCache_ScopedCache_SetBinary_Good(t *T) { + c, _ := testCache(t, testBase+"/scoped-bin-good") + scoped := c.Scoped(testAppOrigin) + RequireTrue(t, scoped.SetBinary("artifact/blob", []byte("abc"), testTextPlain).OK) + AssertEqual(t, "abc", string(resultBytes(t, scoped.GetBinary("artifact/blob")))) +} + +func TestCache_ScopedCache_SetBinary_Bad(t *T) { + var scoped *cache.ScopedCache + r := scoped.SetBinary("artifact/blob", []byte("abc"), testTextPlain) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "nil") +} + +func TestCache_ScopedCache_SetBinary_Ugly(t *T) { + c, _ := testCache(t, testBase+"/scoped-bin-ugly") + r := c.Scoped(testAppOrigin).SetBinary("../escape", []byte("abc"), testTextPlain) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "traversal") +} + +func TestCache_ScopedCache_SetBinaryWithTTL_Good(t *T) { + c, _ := testCache(t, testBase+"/scoped-bin-ttl-good") + scoped := c.Scoped(testAppOrigin) + AssertTrue(t, scoped.SetBinaryWithTTL("artifact/blob", []byte("abc"), testTextPlain, time.Minute).OK) + AssertEqual(t, "abc", string(resultBytes(t, scoped.GetBinary("artifact/blob")))) +} + +func TestCache_ScopedCache_SetBinaryWithTTL_Bad(t *T) { + c, _ := testCache(t, testBase+"/scoped-bin-ttl-bad") + r := c.Scoped(testAppOrigin).SetBinaryWithTTL("artifact/blob", []byte("abc"), testTextPlain, -time.Second) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "ttl") +} + +func TestCache_ScopedCache_SetBinaryWithTTL_Ugly(t *T) { + c, _ := testCache(t, testBase+"/scoped-bin-ttl-ugly") + scoped := c.Scoped(testAppOrigin) + RequireTrue(t, scoped.SetBinaryWithTTL("artifact/blob", []byte("abc"), testTextPlain, 0).OK) + AssertNil(t, scoped.GetBinary("artifact/blob").Value) +} + +func TestCache_ScopedCache_GetBinary_Good(t *T) { + c, _ := testCache(t, testBase+"/scoped-get-bin-good") + scoped := c.Scoped(testAppOrigin) + RequireTrue(t, scoped.SetBinary("artifact/blob", []byte("abc"), testTextPlain).OK) + AssertEqual(t, []byte("abc"), resultBytes(t, scoped.GetBinary("artifact/blob"))) +} + +func TestCache_ScopedCache_GetBinary_Bad(t *T) { + var scoped *cache.ScopedCache + r := scoped.GetBinary("artifact/blob") + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "nil") +} + +func TestCache_ScopedCache_GetBinary_Ugly(t *T) { + c, _ := testCache(t, testBase+"/scoped-get-bin-ugly") + r := c.Scoped(testAppOrigin).GetBinary("missing/blob") + RequireTrue(t, r.OK) + AssertNil(t, r.Value) +} + +func TestCache_ScopedCache_Delete_Good(t *T) { + c, _ := testCache(t, testBase+"/scoped-delete-good") + scoped := c.Scoped(testAppOrigin) + RequireTrue(t, scoped.Set("prefs/theme", "dark").OK) + AssertTrue(t, scoped.Delete("prefs/theme").OK) + AssertEqual(t, time.Duration(-1), scoped.Age("prefs/theme")) +} + +func TestCache_ScopedCache_Delete_Bad(t *T) { + var scoped *cache.ScopedCache + r := scoped.Delete("prefs/theme") + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "nil") +} + +func TestCache_ScopedCache_Delete_Ugly(t *T) { + c, _ := testCache(t, testBase+"/scoped-delete-ugly") + r := c.Scoped(testAppOrigin).Delete("missing/key") + AssertTrue(t, r.OK) + AssertNil(t, r.Value) +} + +func TestCache_ScopedCache_DeleteMany_Good(t *T) { + c, _ := testCache(t, testBase+"/scoped-delete-many-good") + scoped := c.Scoped(testAppOrigin) + RequireTrue(t, scoped.Set("prefs/one", "1").OK) + RequireTrue(t, scoped.Set("prefs/two", "2").OK) + AssertTrue(t, scoped.DeleteMany("prefs/one", "prefs/two").OK) +} + +func TestCache_ScopedCache_DeleteMany_Bad(t *T) { + var scoped *cache.ScopedCache + r := scoped.DeleteMany("prefs/one") + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "nil") +} + +func TestCache_ScopedCache_DeleteMany_Ugly(t *T) { + c, _ := testCache(t, testBase+"/scoped-delete-many-ugly") + r := c.Scoped(testAppOrigin).DeleteMany("../escape") + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "traversal") +} + +func TestCache_ScopedCache_Clear_Good(t *T) { + c, _ := testCache(t, testBase+"/scoped-clear-good") + scoped := c.Scoped(testAppOrigin) + RequireTrue(t, scoped.Set("prefs/theme", "dark").OK) + AssertTrue(t, scoped.Clear().OK) + AssertEqual(t, time.Duration(-1), scoped.Age("prefs/theme")) +} + +func TestCache_ScopedCache_Clear_Bad(t *T) { + var scoped *cache.ScopedCache + r := scoped.Clear() + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "nil") +} + +func TestCache_ScopedCache_Clear_Ugly(t *T) { + c, _ := testCache(t, testBase+"/scoped-clear-ugly") + r := c.Scoped(testAppOrigin).Clear() + AssertTrue(t, r.OK) + AssertNil(t, r.Value) +} + +func TestCache_ScopedCache_ClearScope_Good(t *T) { + c, _ := testCache(t, testBase+"/scoped-clear-scope-good") + scoped := c.Scoped(testAppOrigin) + RequireTrue(t, scoped.Scoped(testAdminOrigin).Set("prefs/theme", "dark").OK) + AssertTrue(t, scoped.ClearScope(testAdminOrigin).OK) +} + +func TestCache_ScopedCache_ClearScope_Bad(t *T) { + var scoped *cache.ScopedCache + r := scoped.ClearScope(testAdminOrigin) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "nil") +} + +func TestCache_ScopedCache_ClearScope_Ugly(t *T) { + c, _ := testCache(t, testBase+"/scoped-clear-scope-ugly") + r := c.Scoped(testAppOrigin).ClearScope("empty") + AssertTrue(t, r.OK) + AssertNil(t, r.Value) +} + +func TestCache_ScopedCache_OnInvalidate_Good(t *T) { + c, _ := testCache(t, testBase+"/scoped-on-invalidate-good") + scoped := c.Scoped(testAppOrigin) + scoped.OnInvalidate("prefs.changed", func(string) []string { return []string{"prefs/*"} }) + RequireTrue(t, scoped.Set("prefs/theme", "dark").OK) + AssertEqual(t, 1, resultInt(t, scoped.Invalidate("prefs.changed"))) +} + +func TestCache_ScopedCache_OnInvalidate_Bad(t *T) { + c, _ := testCache(t, testBase+"/scoped-on-invalidate-bad") + scoped := c.Scoped(testAppOrigin) + scoped.OnInvalidate("prefs.changed", nil) + AssertEqual(t, 0, resultInt(t, scoped.Invalidate("prefs.changed"))) +} + +func TestCache_ScopedCache_OnInvalidate_Ugly(t *T) { + var scoped *cache.ScopedCache + scoped.OnInvalidate("prefs.changed", func(string) []string { return []string{"prefs/*"} }) + AssertNil(t, scoped) +} + +func TestCache_ScopedCache_Invalidate_Good(t *T) { + c, _ := testCache(t, testBase+"/scoped-invalidate-good") + scoped := c.Scoped(testAppOrigin) + scoped.OnInvalidate("prefs.changed", func(string) []string { return []string{"prefs/*"} }) + RequireTrue(t, scoped.Set("prefs/theme", "dark").OK) + AssertEqual(t, 1, resultInt(t, scoped.Invalidate("prefs.changed"))) +} + +func TestCache_ScopedCache_Invalidate_Bad(t *T) { + var scoped *cache.ScopedCache + r := scoped.Invalidate("prefs.changed") + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "nil") +} + +func TestCache_ScopedCache_Invalidate_Ugly(t *T) { + c, _ := testCache(t, testBase+"/scoped-invalidate-ugly") + deleted := resultInt(t, c.Scoped(testAppOrigin).Invalidate("none")) + AssertEqual(t, 0, deleted) +} + +func TestCache_ScopedCache_Age_Good(t *T) { + c, _ := testCache(t, testBase+"/scoped-age-good") + scoped := c.Scoped(testAppOrigin) + RequireTrue(t, scoped.Set("prefs/theme", "dark").OK) + AssertTrue(t, scoped.Age("prefs/theme") >= 0) +} + +func TestCache_ScopedCache_Age_Bad(t *T) { + var scoped *cache.ScopedCache + age := scoped.Age("prefs/theme") + AssertEqual(t, time.Duration(-1), age) +} + +func TestCache_ScopedCache_Age_Ugly(t *T) { + c, _ := testCache(t, testBase+"/scoped-age-ugly") + age := c.Scoped(testAppOrigin).Age("missing/key") + AssertEqual(t, time.Duration(-1), age) +} + +func TestCache_NewCacheStorage_Good(t *T) { + r := cache.NewCacheStorage(coreio.NewMockMedium(), testBase+"/storage-good") + RequireTrue(t, r.OK) + storage := r.Value.(*cache.CacheStorage) + AssertTrue(t, storage.Close().OK) +} + +func TestCache_NewCacheStorage_Bad(t *T) { + medium := newScriptedMedium() + medium.ensureDirErr[testBase+"/storage-bad"] = AnError + r := cache.NewCacheStorage(medium, testBase+"/storage-bad") + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "create") +} + +func TestCache_NewCacheStorage_Ugly(t *T) { + r := cache.NewCacheStorage(nil, t.TempDir()) + RequireTrue(t, r.OK) + storage := r.Value.(*cache.CacheStorage) + AssertTrue(t, storage.Close().OK) +} + +func TestCache_CacheStorage_Open_Good(t *T) { + storage, _ := testStorage(t, testBase+"/storage-open-good") + r := storage.Open("app-v1") + RequireTrue(t, r.OK) + AssertNotNil(t, r.Value.(*cache.HTTPCache)) +} + +func TestCache_CacheStorage_Open_Bad(t *T) { + storage, _ := testStorage(t, testBase+"/storage-open-bad") + r := storage.Open("../escape") + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "cache name") +} + +func TestCache_CacheStorage_Open_Ugly(t *T) { + storage, _ := testStorage(t, testBase+"/storage-open-ugly") + first := storage.Open("app-v1").Value.(*cache.HTTPCache) + second := storage.Open("app-v1").Value.(*cache.HTTPCache) + AssertEqual(t, first, second) +} + +func TestCache_CacheStorage_Delete_Good(t *T) { + storage, medium := testStorage(t, testBase+"/storage-delete-good") + RequireTrue(t, storage.Open("app-v1").OK) + AssertTrue(t, storage.Delete("app-v1").OK) + AssertFalse(t, medium.IsFile(testBase+"/storage-delete-good/app-v1")) +} + +func TestCache_CacheStorage_Delete_Bad(t *T) { + storage, _ := testStorage(t, testBase+"/storage-delete-bad") + r := storage.Delete("../escape") + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "cache name") +} + +func TestCache_CacheStorage_Delete_Ugly(t *T) { + storage, _ := testStorage(t, testBase+"/storage-delete-ugly") + r := storage.Delete("missing-cache") + AssertTrue(t, r.OK) + AssertNil(t, r.Value) +} + +func TestCache_CacheStorage_Keys_Good(t *T) { + storage, _ := testStorage(t, testBase+"/storage-keys-good") + RequireTrue(t, storage.Open("app-v1").OK) + keys := storage.Keys().Value.([]string) + AssertEqual(t, []string{"app-v1"}, keys) +} + +func TestCache_CacheStorage_Keys_Bad(t *T) { + medium := newScriptedMedium() + RequireTrue(t, cache.NewCacheStorage(medium, testBase+"/storage-keys-bad").OK) + storage := cache.NewCacheStorage(medium, testBase+"/storage-keys-bad").Value.(*cache.CacheStorage) + medium.listErr[testBase+"/storage-keys-bad"] = AnError + r := storage.Keys() + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "list") +} + +func TestCache_CacheStorage_Keys_Ugly(t *T) { + storage, _ := testStorage(t, testBase+"/storage-keys-ugly") + keys := storage.Keys().Value.([]string) + AssertEqual(t, 0, len(keys)) +} + +func TestCache_CacheStorage_Close_Good(t *T) { + storage, _ := testStorage(t, testBase+"/storage-close-good") + RequireTrue(t, storage.Open("app-v1").OK) + AssertTrue(t, storage.Close().OK) +} + +func TestCache_CacheStorage_Close_Bad(t *T) { + var storage *cache.CacheStorage + r := storage.Close() + AssertTrue(t, r.OK) + AssertNil(t, r.Value) +} + +func TestCache_CacheStorage_Close_Ugly(t *T) { + storage, _ := testStorage(t, testBase+"/storage-close-ugly") + RequireTrue(t, storage.Close().OK) + r := storage.Open("app-v1") + AssertTrue(t, r.OK) + AssertNotNil(t, r.Value) +} + +func TestCache_HTTPCache_Match_Good(t *T) { + httpCache, _ := testHTTPCache(t, testBase+"/http-match-good", "app-v1") + req := request("GET", testURL) + RequireTrue(t, httpCache.Put(req, response(200), []byte("ok")).OK) + match := httpCache.Match(req).Value.(*cache.CachedResponse) + AssertEqual(t, 200, match.Status) +} + +func TestCache_HTTPCache_Match_Bad(t *T) { + httpCache, _ := testHTTPCache(t, testBase+"/http-match-bad", "app-v1") + r := httpCache.Match(request("", testURL)) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "request") +} + +func TestCache_HTTPCache_Match_Ugly(t *T) { + httpCache, _ := testHTTPCache(t, testBase+"/http-match-ugly", "app-v1") + r := httpCache.Match(request("GET", testURL)) + RequireTrue(t, r.OK) + AssertNil(t, r.Value) +} + +func TestCache_HTTPCache_Put_Good(t *T) { + httpCache, _ := testHTTPCache(t, testBase+"/http-put-good", "app-v1") + req := request("GET", testURL) + r := httpCache.Put(req, response(201), []byte("created")) + AssertTrue(t, r.OK) + AssertNotNil(t, httpCache.Match(req).Value) +} + +func TestCache_HTTPCache_Put_Bad(t *T) { + httpCache, _ := testHTTPCache(t, testBase+"/http-put-bad", "app-v1") + r := httpCache.Put(request("", testURL), response(200), []byte("bad")) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "request") +} + +func TestCache_HTTPCache_Put_Ugly(t *T) { + httpCache, _ := testHTTPCache(t, testBase+"/http-put-ugly", "app-v1") + r := httpCache.Put(request("GET", testURL), response(99), []byte("bad")) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "response") +} + +func TestCache_HTTPCache_ReadBody_Good(t *T) { + httpCache, _ := testHTTPCache(t, testBase+"/http-body-good", "app-v1") + req := request("GET", testURL) + RequireTrue(t, httpCache.Put(req, response(200), []byte("body")).OK) + resp := httpCache.Match(req).Value.(*cache.CachedResponse) + AssertEqual(t, "body", string(resultBytes(t, httpCache.ReadBody(resp)))) +} + +func TestCache_HTTPCache_ReadBody_Bad(t *T) { + httpCache, _ := testHTTPCache(t, testBase+"/http-body-bad", "app-v1") + r := httpCache.ReadBody(nil) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "nil") +} + +func TestCache_HTTPCache_ReadBody_Ugly(t *T) { + httpCache, _ := testHTTPCache(t, testBase+"/http-body-ugly", "app-v1") + r := httpCache.ReadBody(&cache.CachedResponse{BodyPath: "../escape.bin"}) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "body path") +} + +func TestCache_HTTPCache_Delete_Good(t *T) { + httpCache, _ := testHTTPCache(t, testBase+"/http-delete-good", "app-v1") + req := request("GET", testURL) + RequireTrue(t, httpCache.Put(req, response(200), []byte("body")).OK) + RequireTrue(t, httpCache.Delete(req).OK) + AssertNil(t, httpCache.Match(req).Value) +} + +func TestCache_HTTPCache_Delete_Bad(t *T) { + httpCache, _ := testHTTPCache(t, testBase+"/http-delete-bad", "app-v1") + r := httpCache.Delete(request("", testURL)) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "request") +} + +func TestCache_HTTPCache_Delete_Ugly(t *T) { + httpCache, _ := testHTTPCache(t, testBase+"/http-delete-ugly", "app-v1") + r := httpCache.Delete(request("GET", testURL)) + AssertTrue(t, r.OK) + AssertNil(t, r.Value) +} + +func TestCache_HTTPCache_Keys_Good(t *T) { + httpCache, _ := testHTTPCache(t, testBase+"/http-keys-good", "app-v1") + RequireTrue(t, httpCache.Put(request("GET", testURL), response(200), []byte("body")).OK) + keys := httpCache.Keys().Value.([]string) + AssertEqual(t, []string{testURL}, keys) +} + +func TestCache_HTTPCache_Keys_Bad(t *T) { + scripted := newScriptedMedium() + storage := cache.NewCacheStorage(scripted, testBase+"/http-keys-bad").Value.(*cache.CacheStorage) + httpCache := storage.Open("app-v1").Value.(*cache.HTTPCache) + scripted.listErr[testBase+"/http-keys-bad/app-v1/responses"] = AnError + r := httpCache.Keys() + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), "list") +} + +func TestCache_HTTPCache_Keys_Ugly(t *T) { + httpCache, _ := testHTTPCache(t, testBase+"/http-keys-ugly", "app-v1") + keys := httpCache.Keys().Value.([]string) + AssertEqual(t, 0, len(keys)) +} diff --git a/go.mod b/go/go.mod similarity index 100% rename from go.mod rename to go/go.mod diff --git a/go.sum b/go/go.sum similarity index 100% rename from go.sum rename to go/go.sum diff --git a/tests/cli/cache/Taskfile.yaml b/go/tests/cli/cache/Taskfile.yaml similarity index 100% rename from tests/cli/cache/Taskfile.yaml rename to go/tests/cli/cache/Taskfile.yaml diff --git a/tests/cli/cache/main.go b/go/tests/cli/cache/main.go similarity index 55% rename from tests/cli/cache/main.go rename to go/tests/cli/cache/main.go index 488ce53..641c041 100644 --- a/tests/cli/cache/main.go +++ b/go/tests/cli/cache/main.go @@ -6,8 +6,7 @@ package main import ( - "os" - + core "dappco.re/go" "dappco.re/go/cache" coreio "dappco.re/go/io" ) @@ -15,25 +14,26 @@ import ( func main() { medium := coreio.NewMockMedium() - c, err := cache.New(medium, "/cache", cache.DefaultTTL) - if err != nil { - os.Exit(1) + cacheResult := cache.New(medium, "/cache", cache.DefaultTTL) + if !cacheResult.OK { + core.Exit(1) } + c := cacheResult.Value.(*cache.Cache) payload := map[string]string{"hello": "world"} - if err := c.Set("driver/roundtrip", payload); err != nil { - os.Exit(2) + if r := c.Set("driver/roundtrip", payload); !r.OK { + core.Exit(2) } var out map[string]string - found, err := c.Get("driver/roundtrip", &out) - if err != nil { - os.Exit(3) + found := c.Get("driver/roundtrip", &out) + if !found.OK { + core.Exit(3) } - if !found { - os.Exit(4) + if !found.Value.(bool) { + core.Exit(4) } if out["hello"] != "world" { - os.Exit(5) + core.Exit(5) } }