From 6881997b912afc83729d1ca18bad951ad710cfa8 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 22 Mar 2026 17:25:57 +0000 Subject: [PATCH 01/12] test(cache): verify docker sandbox Co-Authored-By: Virgil From cabb7b1d0e47e30aa0ad0f053be01ab42b331ddb Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 23 Mar 2026 07:35:31 +0000 Subject: [PATCH 02/12] fix(cache): resolve issue 4 findings --- cache.go | 102 ++++++++++++++++++++++++++++++++++++++----- cache_test.go | 2 + docs/architecture.md | 6 +-- go.mod | 1 + go.sum | 2 + 5 files changed, 100 insertions(+), 13 deletions(-) diff --git a/cache.go b/cache.go index f532b47..ec98738 100644 --- a/cache.go +++ b/cache.go @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: EUPL-1.2 + // Package cache provides a storage-agnostic, JSON-based cache backed by any io.Medium. package cache @@ -5,18 +7,25 @@ import ( "encoding/json" "errors" "os" - "path/filepath" - "strings" "time" + "dappco.re/go/core" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" ) // DefaultTTL is the default cache expiry time. +// +// Usage example: +// +// c, err := cache.New(coreio.NewMockMedium(), "/tmp/cache", cache.DefaultTTL) const DefaultTTL = 1 * time.Hour // Cache represents a file-based cache. +// +// Usage example: +// +// c, err := cache.New(coreio.NewMockMedium(), "/tmp/cache", time.Minute) type Cache struct { medium coreio.Medium baseDir string @@ -24,6 +33,10 @@ type Cache struct { } // Entry represents a cached item with metadata. +// +// Usage example: +// +// entry := cache.Entry{CachedAt: time.Now(), ExpiresAt: time.Now().Add(time.Minute)} type Entry struct { Data json.RawMessage `json:"data"` CachedAt time.Time `json:"cached_at"` @@ -33,6 +46,10 @@ type Entry struct { // New creates a new cache instance. // If medium is nil, uses coreio.Local (filesystem). // If baseDir is empty, uses .core/cache in current directory. +// +// Usage example: +// +// c, err := cache.New(coreio.NewMockMedium(), "/tmp/cache", 30*time.Minute) func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error) { if medium == nil { medium = coreio.Local @@ -44,7 +61,7 @@ func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error if err != nil { return nil, coreerr.E("cache.New", "failed to get working directory", err) } - baseDir = filepath.Join(cwd, ".core", "cache") + baseDir = core.Path(cwd, ".core", "cache") } if ttl == 0 { @@ -65,20 +82,24 @@ func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error // Path returns the full path for a cache key. // Returns an error if the key attempts path traversal. +// +// Usage example: +// +// path, err := c.Path("github/acme/repos") func (c *Cache) Path(key string) (string, error) { - path := filepath.Join(c.baseDir, key+".json") + path := joinPath(c.baseDir, key+".json") // Ensure the resulting path is still within baseDir to prevent traversal attacks - absBase, err := filepath.Abs(c.baseDir) + absBase, err := pathAbs(c.baseDir) if err != nil { return "", coreerr.E("cache.Path", "failed to get absolute path for baseDir", err) } - absPath, err := filepath.Abs(path) + absPath, err := pathAbs(path) if err != nil { return "", coreerr.E("cache.Path", "failed to get absolute path for key", err) } - if !strings.HasPrefix(absPath, absBase+string(filepath.Separator)) && absPath != absBase { + if !core.HasPrefix(absPath, absBase+pathSeparator()) && absPath != absBase { return "", coreerr.E("cache.Path", "invalid cache key: path traversal attempt", nil) } @@ -86,6 +107,10 @@ func (c *Cache) Path(key string) (string, error) { } // Get retrieves a cached item if it exists and hasn't expired. +// +// Usage example: +// +// found, err := c.Get("session/user-42", &dest) func (c *Cache) Get(key string, dest any) (bool, error) { path, err := c.Path(key) if err != nil { @@ -120,6 +145,10 @@ func (c *Cache) Get(key string, dest any) (bool, error) { } // Set stores an item in the cache. +// +// Usage example: +// +// err := c.Set("session/user-42", map[string]string{"name": "Ada"}) func (c *Cache) Set(key string, data any) error { path, err := c.Path(key) if err != nil { @@ -127,7 +156,7 @@ func (c *Cache) Set(key string, data any) error { } // Ensure parent directory exists - if err := c.medium.EnsureDir(filepath.Dir(path)); err != nil { + if err := c.medium.EnsureDir(core.PathDir(path)); err != nil { return coreerr.E("cache.Set", "failed to create directory", err) } @@ -155,6 +184,10 @@ func (c *Cache) Set(key string, data any) error { } // Delete removes an item from the cache. +// +// Usage example: +// +// err := c.Delete("session/user-42") func (c *Cache) Delete(key string) error { path, err := c.Path(key) if err != nil { @@ -172,6 +205,10 @@ func (c *Cache) Delete(key string) error { } // Clear removes all cached items. +// +// Usage example: +// +// err := c.Clear() func (c *Cache) Clear() error { if err := c.medium.DeleteAll(c.baseDir); err != nil { return coreerr.E("cache.Clear", "failed to clear cache", err) @@ -180,6 +217,10 @@ func (c *Cache) Clear() error { } // Age returns how old a cached item is, or -1 if not cached. +// +// Usage example: +// +// age := c.Age("session/user-42") func (c *Cache) Age(key string) time.Duration { path, err := c.Path(key) if err != nil { @@ -202,11 +243,52 @@ func (c *Cache) Age(key string) time.Duration { // GitHub-specific cache keys // GitHubReposKey returns the cache key for an org's repo list. +// +// Usage example: +// +// key := cache.GitHubReposKey("acme") func GitHubReposKey(org string) string { - return filepath.Join("github", org, "repos") + return core.JoinPath("github", org, "repos") } // GitHubRepoKey returns the cache key for a specific repo's metadata. +// +// Usage example: +// +// key := cache.GitHubRepoKey("acme", "widgets") func GitHubRepoKey(org, repo string) string { - return filepath.Join("github", org, repo, "meta") + return core.JoinPath("github", org, repo, "meta") +} + +func joinPath(segments ...string) string { + return normalizePath(core.JoinPath(segments...)) +} + +func pathAbs(path string) (string, error) { + path = normalizePath(path) + if core.PathIsAbs(path) { + return core.CleanPath(path, pathSeparator()), nil + } + + cwd, err := os.Getwd() + if err != nil { + return "", err + } + + return core.Path(cwd, path), nil +} + +func normalizePath(path string) string { + if pathSeparator() == "/" { + return path + } + return core.Replace(path, "/", pathSeparator()) +} + +func pathSeparator() string { + sep := core.Env("DS") + if sep == "" { + return "/" + } + return sep } diff --git a/cache_test.go b/cache_test.go index c33996e..15d122b 100644 --- a/cache_test.go +++ b/cache_test.go @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: EUPL-1.2 + package cache_test import ( diff --git a/docs/architecture.md b/docs/architecture.md index b2199b0..0f9f619 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -162,11 +162,11 @@ the GitHub key helpers work: ```go func GitHubReposKey(org string) string { - return filepath.Join("github", org, "repos") + return core.JoinPath("github", org, "repos") } func GitHubRepoKey(org, repo string) string { - return filepath.Join("github", org, repo, "meta") + return core.JoinPath("github", org, repo, "meta") } ``` @@ -178,7 +178,7 @@ the full path, it resolves both the base directory and the result to absolute paths, then checks that the result is still a prefix of the base: ```go -if !strings.HasPrefix(absPath, absBase) { +if !core.HasPrefix(absPath, absBase+pathSeparator()) && absPath != absBase { return "", coreerr.E("cache.Path", "invalid cache key: path traversal attempt", nil) } ``` diff --git a/go.mod b/go.mod index c7424fc..495911f 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module dappco.re/go/core/cache go 1.26.0 require ( + dappco.re/go/core v0.6.0 dappco.re/go/core/io v0.2.0 dappco.re/go/core/log v0.1.0 ) diff --git a/go.sum b/go.sum index 76d01ec..3a59267 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +dappco.re/go/core v0.6.0 h1:0wmuO/UmCWXxJkxQ6XvVLnqkAuWitbd49PhxjCsplyk= +dappco.re/go/core v0.6.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E= dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= From f9e1725c7d9f4c39d3b8dcf6ff8afbbcf2018eaf Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 23 Mar 2026 12:59:56 +0000 Subject: [PATCH 03/12] docs(cache): map security attack vectors Co-Authored-By: Virgil --- docs/security-attack-vector-mapping.md | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 docs/security-attack-vector-mapping.md diff --git a/docs/security-attack-vector-mapping.md b/docs/security-attack-vector-mapping.md new file mode 100644 index 0000000..852902e --- /dev/null +++ b/docs/security-attack-vector-mapping.md @@ -0,0 +1,29 @@ +# Security Attack Vector Mapping + +Scope: `dappco.re/go/core/cache` public API and backend read paths in `cache.go`. This package exposes a library surface only; it has no HTTP handlers or CLI argument parsing in-repo. + +| Function | File:line | Input source | Flows into | Current validation | Potential attack vector | +| --- | --- | --- | --- | --- | --- | +| `New` | `cache.go:36` | `medium` constructor argument from the consumer | Stored on `Cache.medium`; used immediately by `medium.EnsureDir(baseDir)` and later by all `Read`/`Write`/`Delete` calls | Only `nil` is replaced with `coreio.Local`; no capability or sandbox check in this package | Backend policy bypass. A caller can supply an unsafe medium, and `nil` falls back to unsandboxed local filesystem access (`io.Local` is rooted at `/`), increasing the impact of later key or `baseDir` misuse. | +| `New` | `cache.go:36` | `baseDir` constructor argument from the consumer | `medium.EnsureDir(baseDir)`; persisted on `Cache.baseDir`; later consumed by `Path` and `Clear` | Empty string defaults to `filepath.Join(cwd, ".core", "cache")`; otherwise no normalization, allowlist, or sandbox enforcement in this package | Arbitrary path selection. If `baseDir` is user-controlled or misconfigured, cache reads/writes/deletes can be redirected to attacker-chosen locations. With default `io.Local`, `Clear` can recurse-delete arbitrary directories other than `/` and `$HOME`, and `Set` can write cache JSON into unexpected filesystem locations. | +| `New` | `cache.go:41` | Process working directory from `os.Getwd()` when `baseDir == ""` | `filepath.Join(cwd, ".core", "cache")` | No validation beyond `Getwd` succeeding | Environment-controlled cache placement. Running the consumer from an attacker-influenced working directory redirects cache storage into that tree, which can expose data to other users/processes or alter which cache is later cleared. | +| `New` | `cache.go:36` | `ttl` constructor argument from the consumer | Stored on `Cache.ttl`; later used by `time.Now().Add(c.ttl)` in `Set` | Only `0` is replaced with `DefaultTTL`; negative or very large durations are accepted | Availability and data-staleness abuse. Negative TTL values force immediate misses; very large TTLs preserve stale or poisoned cache content longer than intended. | +| `Path` | `cache.go:68` | `key` method argument from the caller | `filepath.Join(c.baseDir, key+".json")`; returned path is later consumed by medium operations | Resolves `absBase` and `absPath` and rejects results outside `baseDir` prefix | Direct `../` traversal is blocked, but long or deeply nested keys can still create path-length issues, inode/file-count exhaustion, or namespace confusion within `baseDir`. Dot-segments and separators are normalized, which can collapse distinct logical keys into the same on-disk path. | +| `Get` | `cache.go:89` | `key` method argument from the caller | `Path(key)` then `c.medium.Read(path)` | Inherits `Path` traversal guard | Cache oracle and cross-tenant read risk inside the allowed namespace. An attacker who can choose keys can probe for existence/timing of other entries in a shared cache or read another principal's cached object if the consumer does not namespace keys. | +| `Get` | `cache.go:95` | Backend content returned by `c.medium.Read(path)` | `json.Unmarshal([]byte(dataStr), &entry)`, expiry check, then `json.Unmarshal(entry.Data, dest)` | Missing files become cache misses; invalid envelope JSON becomes a cache miss; there is no size limit, schema check, or integrity/authenticity check | Malicious or compromised storage can feed oversized JSON for memory/CPU exhaustion, forge `ExpiresAt` far into the future to keep poisoned data live, or substitute crafted `data` payloads that alter downstream program behavior after unmarshal. | +| `Get` | `cache.go:89` | `dest` method argument from the caller | `json.Unmarshal(entry.Data, dest)` | Relies entirely on Go's JSON decoder and the caller-provided destination type | Type-driven resource abuse or logic confusion. If storage is attacker-controlled, decoding into permissive targets such as `map[string]any`, slices, or interfaces can trigger large allocations or smuggle unexpected structure into the consumer. | +| `Set` | `cache.go:123` | `key` method argument from the caller | `Path(key)`, `EnsureDir(filepath.Dir(path))`, then `Write(path, string(entryBytes))` | Inherits `Path` traversal guard | Namespace collision or storage exhaustion inside `baseDir`. An attacker-controlled key can create many directories/files, overwrite another tenant's cache entry, or consume disk/inodes within the permitted cache root. | +| `Set` | `cache.go:123` | `data` method argument from the caller | `json.Marshal(data)` into `Entry.Data`, then `json.MarshalIndent(entry)` and `c.medium.Write(path, string(entryBytes))` | Only successful JSON marshaling is required; no content, sensitivity, or size validation | Large or adversarial objects can consume CPU/memory during marshal and write. Sensitive data is stored as plaintext JSON, and with the default local backend the write path uses default file mode `0644`, creating local disclosure risk for cache contents. | +| `Delete` | `cache.go:158` | `key` method argument from the caller | `Path(key)` then `c.medium.Delete(path)` | Inherits `Path` traversal guard; `os.ErrNotExist` is ignored | Attacker-chosen eviction of entries inside `baseDir`. In a shared cache namespace this enables targeted cache invalidation or poisoning by deleting another principal's cached item. | +| `Clear` | `cache.go:175` | `c.baseDir` set earlier by constructor input/environment | `c.medium.DeleteAll(c.baseDir)` | No validation at call time in this package | Destructive recursive delete. If `baseDir` is user-controlled or misconfigured, `Clear` removes whatever tree the medium resolves that path to. With default unsandboxed `io.Local`, only `/` and `$HOME` are explicitly protected in the backend, leaving other directories in scope. | +| `Age` | `cache.go:183` | `key` method argument from the caller | `Path(key)` then `c.medium.Read(path)` | Inherits `Path` traversal guard; any error returns `-1` | Metadata oracle within `baseDir`. An attacker can probe whether specific keys exist and silently suppress backend/path failures because all errors collapse to `-1`. | +| `Age` | `cache.go:189` | Backend content returned by `c.medium.Read(path)` | `json.Unmarshal([]byte(dataStr), &entry)` then `time.Since(entry.CachedAt)` | Invalid JSON returns `-1`; no size limit or timestamp sanity check | Malicious storage can return oversized JSON for resource exhaustion or forge timestamps, producing misleading negative or extreme ages that can distort caller refresh decisions. | +| `GitHubReposKey` | `cache.go:205` | `org` argument from the caller | `filepath.Join("github", org, "repos")`, typically later consumed as a cache key by `Path`/`Set`/`Get` | No validation | Key normalization and collision risk. Inputs containing separators or dot-segments are normalized by `filepath.Join`, so unexpected values can collapse into another logical cache key. Direct traversal only gets blocked later if the resulting key reaches `Path`. | +| `GitHubRepoKey` | `cache.go:210` | `org` argument from the caller | `filepath.Join("github", org, repo, "meta")` | No validation | Same collision/normalization issue as `GitHubReposKey`; a crafted org component can collapse onto another key path before the cache methods apply traversal checks. | +| `GitHubRepoKey` | `cache.go:210` | `repo` argument from the caller | `filepath.Join("github", org, repo, "meta")` | No validation | Same collision/normalization issue as the org input; crafted repo names containing separators or dot-segments can steer multiple logical repos onto the same cache key. | + +## Notes + +- The package's strongest built-in control is the path-traversal guard in `Cache.Path()`. It protects `Get`, `Set`, `Delete`, and `Age` against simple `../` escapes relative to `baseDir`. +- The highest-impact residual risk is not `key` traversal but unchecked control over `baseDir` and backend choice in `New()`, especially because the default `coreio.Local` medium is unsandboxed. +- Read-side trust is weak by design: cache files are accepted without integrity protection, size limits, or schema validation, so any actor that can modify the backing medium can turn the cache into a poisoning or denial-of-service surface. From 2455be1aef5045d0ba56103028fb25e7f441cdb4 Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 23 Mar 2026 13:57:40 +0000 Subject: [PATCH 04/12] docs(cache): add API contract report Co-Authored-By: Virgil --- docs/api-contract.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 docs/api-contract.md diff --git a/docs/api-contract.md b/docs/api-contract.md new file mode 100644 index 0000000..3410d48 --- /dev/null +++ b/docs/api-contract.md @@ -0,0 +1,28 @@ +--- +title: API Contract +description: Exported API contract for dappco.re/go/core/cache. +--- + +# API Contract + +This table lists every exported constant, type, function, and method in +`dappco.re/go/core/cache`. + +`Test coverage` is `yes` when the export is directly exercised by +`cache_test.go`. `Usage-example comment` is `yes` only when the symbol has its +own usage example in a doc comment or Go example test. + +| Name | Signature | Package Path | Description | Test Coverage | Usage-Example Comment | +|------|-----------|--------------|-------------|---------------|-----------------------| +| `DefaultTTL` | `const DefaultTTL = 1 * time.Hour` | `dappco.re/go/core/cache` | Default cache expiry time. | no | no | +| `Cache` | `type Cache struct { /* unexported fields */ }` | `dappco.re/go/core/cache` | File-based cache handle. | yes | no | +| `Entry` | `type Entry struct { Data json.RawMessage; CachedAt time.Time; ExpiresAt time.Time }` | `dappco.re/go/core/cache` | Cached item envelope with payload and timestamps. | no | no | +| `New` | `func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error)` | `dappco.re/go/core/cache` | Creates a cache instance, applying default medium, base directory, and TTL when zero-valued inputs are provided. | yes | no | +| `(*Cache).Path` | `func (c *Cache) Path(key string) (string, error)` | `dappco.re/go/core/cache` | Returns the full path for a cache key and rejects path traversal. | yes | no | +| `(*Cache).Get` | `func (c *Cache) Get(key string, dest any) (bool, error)` | `dappco.re/go/core/cache` | Retrieves a cached item if it exists and has not expired. | yes | no | +| `(*Cache).Set` | `func (c *Cache) Set(key string, data any) error` | `dappco.re/go/core/cache` | Stores an item in the cache. | yes | no | +| `(*Cache).Delete` | `func (c *Cache) Delete(key string) error` | `dappco.re/go/core/cache` | Removes an item from the cache. | yes | no | +| `(*Cache).Clear` | `func (c *Cache) Clear() error` | `dappco.re/go/core/cache` | Removes all cached items. | yes | no | +| `(*Cache).Age` | `func (c *Cache) Age(key string) time.Duration` | `dappco.re/go/core/cache` | Returns how old a cached item is, or `-1` if it is not cached. | yes | no | +| `GitHubReposKey` | `func GitHubReposKey(org string) string` | `dappco.re/go/core/cache` | Returns the cache key for an organization's repo list. | yes | no | +| `GitHubRepoKey` | `func GitHubRepoKey(org, repo string) string` | `dappco.re/go/core/cache` | Returns the cache key for a specific repo's metadata. | yes | no | From 29ec99df120eaf0867b078313cefae0cc9f0d50d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 13:33:54 +0000 Subject: [PATCH 05/12] feat: upgrade to core v0.8.0-alpha.1, replace errors.Is with core.Is Co-Authored-By: Claude Opus 4.6 (1M context) --- cache.go | 5 ++--- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/cache.go b/cache.go index ec98738..11ce50e 100644 --- a/cache.go +++ b/cache.go @@ -5,7 +5,6 @@ package cache import ( "encoding/json" - "errors" "os" "time" @@ -119,7 +118,7 @@ func (c *Cache) Get(key string, dest any) (bool, error) { dataStr, err := c.medium.Read(path) if err != nil { - if errors.Is(err, os.ErrNotExist) { + if core.Is(err, os.ErrNotExist) { return false, nil } return false, coreerr.E("cache.Get", "failed to read cache file", err) @@ -195,7 +194,7 @@ func (c *Cache) Delete(key string) error { } err = c.medium.Delete(path) - if errors.Is(err, os.ErrNotExist) { + if core.Is(err, os.ErrNotExist) { return nil } if err != nil { diff --git a/go.mod b/go.mod index 495911f..e0e28ad 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module dappco.re/go/core/cache go 1.26.0 require ( - dappco.re/go/core v0.6.0 + dappco.re/go/core v0.8.0-alpha.1 dappco.re/go/core/io v0.2.0 dappco.re/go/core/log v0.1.0 ) diff --git a/go.sum b/go.sum index 3a59267..5a2e71c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -dappco.re/go/core v0.6.0 h1:0wmuO/UmCWXxJkxQ6XvVLnqkAuWitbd49PhxjCsplyk= -dappco.re/go/core v0.6.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= +dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= +dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E= dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= From 261a7ba9503396217ce31e367371c408e42fd4d9 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 26 Mar 2026 17:08:09 +0000 Subject: [PATCH 06/12] Polish AX v0.8.0 cache package Co-Authored-By: Virgil --- cache.go | 204 +++++++++++++++++++++----------------------------- cache_test.go | 194 ++++++++++++++++++++++++++++++++--------------- go.mod | 1 - go.sum | 2 - 4 files changed, 221 insertions(+), 180 deletions(-) diff --git a/cache.go b/cache.go index 11ce50e..acbe22f 100644 --- a/cache.go +++ b/cache.go @@ -4,13 +4,11 @@ package cache import ( - "encoding/json" - "os" + "io/fs" "time" "dappco.re/go/core" coreio "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" ) // DefaultTTL is the default cache expiry time. @@ -20,56 +18,46 @@ import ( // c, err := cache.New(coreio.NewMockMedium(), "/tmp/cache", cache.DefaultTTL) const DefaultTTL = 1 * time.Hour -// Cache represents a file-based cache. -// -// Usage example: -// -// c, err := cache.New(coreio.NewMockMedium(), "/tmp/cache", time.Minute) +// Cache stores JSON-encoded entries in a Medium-backed cache rooted at baseDir. type Cache struct { medium coreio.Medium baseDir string ttl time.Duration } -// Entry represents a cached item with metadata. -// -// Usage example: -// -// entry := cache.Entry{CachedAt: time.Now(), ExpiresAt: time.Now().Add(time.Minute)} +// Entry is the serialized cache record written to the backing Medium. type Entry struct { - Data json.RawMessage `json:"data"` - CachedAt time.Time `json:"cached_at"` - ExpiresAt time.Time `json:"expires_at"` + Data any `json:"data"` + CachedAt time.Time `json:"cached_at"` + ExpiresAt time.Time `json:"expires_at"` } -// New creates a new cache instance. -// If medium is nil, uses coreio.Local (filesystem). -// If baseDir is empty, uses .core/cache in current directory. +// New creates a cache and applies default Medium, base directory, and TTL values +// when callers pass zero values. // -// Usage example: -// -// c, err := cache.New(coreio.NewMockMedium(), "/tmp/cache", 30*time.Minute) +// c, err := cache.New(coreio.Local, "/tmp/cache", time.Hour) func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error) { if medium == nil { medium = coreio.Local } if baseDir == "" { - // Use .core/cache in current working directory - cwd, err := os.Getwd() - if err != nil { - return nil, coreerr.E("cache.New", "failed to get working directory", err) + cwd := currentDir() + if cwd == "" || cwd == "." { + return nil, core.E("cache.New", "failed to resolve current working directory", nil) } - baseDir = core.Path(cwd, ".core", "cache") + + baseDir = normalizePath(core.JoinPath(cwd, ".core", "cache")) + } else { + baseDir = absolutePath(baseDir) } if ttl == 0 { ttl = DefaultTTL } - // Ensure cache directory exists if err := medium.EnsureDir(baseDir); err != nil { - return nil, coreerr.E("cache.New", "failed to create cache directory", err) + return nil, core.E("cache.New", "failed to create cache directory", err) } return &Cache{ @@ -79,37 +67,25 @@ func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error }, nil } -// Path returns the full path for a cache key. -// Returns an error if the key attempts path traversal. -// -// Usage example: +// Path returns the storage path used for key and rejects path traversal +// attempts. // // path, err := c.Path("github/acme/repos") func (c *Cache) Path(key string) (string, error) { - path := joinPath(c.baseDir, key+".json") - - // Ensure the resulting path is still within baseDir to prevent traversal attacks - absBase, err := pathAbs(c.baseDir) - if err != nil { - return "", coreerr.E("cache.Path", "failed to get absolute path for baseDir", err) - } - absPath, err := pathAbs(path) - if err != nil { - return "", coreerr.E("cache.Path", "failed to get absolute path for key", err) - } + baseDir := absolutePath(c.baseDir) + path := absolutePath(core.JoinPath(baseDir, key+".json")) + pathPrefix := normalizePath(core.Concat(baseDir, pathSeparator())) - if !core.HasPrefix(absPath, absBase+pathSeparator()) && absPath != absBase { - return "", coreerr.E("cache.Path", "invalid cache key: path traversal attempt", nil) + if path != baseDir && !core.HasPrefix(path, pathPrefix) { + return "", core.E("cache.Path", "invalid cache key: path traversal attempt", nil) } return path, nil } -// Get retrieves a cached item if it exists and hasn't expired. -// -// Usage example: +// Get unmarshals the cached item into dest if it exists and has not expired. // -// found, err := c.Get("session/user-42", &dest) +// found, err := c.Get("github/acme/repos", &repos) func (c *Cache) Get(key string, dest any) (bool, error) { path, err := c.Path(key) if err != nil { @@ -118,75 +94,67 @@ func (c *Cache) Get(key string, dest any) (bool, error) { dataStr, err := c.medium.Read(path) if err != nil { - if core.Is(err, os.ErrNotExist) { + if core.Is(err, fs.ErrNotExist) { return false, nil } - return false, coreerr.E("cache.Get", "failed to read cache file", err) + return false, core.E("cache.Get", "failed to read cache file", err) } var entry Entry - if err := json.Unmarshal([]byte(dataStr), &entry); err != nil { - // Invalid cache file, treat as miss + entryResult := core.JSONUnmarshalString(dataStr, &entry) + if !entryResult.OK { return false, nil } - // Check expiry if time.Now().After(entry.ExpiresAt) { return false, nil } - // Unmarshal the actual data - if err := json.Unmarshal(entry.Data, dest); err != nil { - return false, coreerr.E("cache.Get", "failed to unmarshal cached data", err) + dataResult := core.JSONMarshal(entry.Data) + if !dataResult.OK { + return false, core.E("cache.Get", "failed to marshal cached data", dataResult.Value.(error)) + } + + if err := core.JSONUnmarshal(dataResult.Value.([]byte), dest); !err.OK { + return false, core.E("cache.Get", "failed to unmarshal cached data", err.Value.(error)) } return true, nil } -// Set stores an item in the cache. -// -// Usage example: +// Set marshals data and stores it in the cache. // -// err := c.Set("session/user-42", map[string]string{"name": "Ada"}) +// err := c.Set("github/acme/repos", repos) func (c *Cache) Set(key string, data any) error { path, err := c.Path(key) if err != nil { return err } - // Ensure parent directory exists if err := c.medium.EnsureDir(core.PathDir(path)); err != nil { - return coreerr.E("cache.Set", "failed to create directory", err) - } - - // Marshal the data - dataBytes, err := json.Marshal(data) - if err != nil { - return coreerr.E("cache.Set", "failed to marshal data", err) + return core.E("cache.Set", "failed to create directory", err) } entry := Entry{ - Data: dataBytes, + Data: data, CachedAt: time.Now(), ExpiresAt: time.Now().Add(c.ttl), } - entryBytes, err := json.MarshalIndent(entry, "", " ") - if err != nil { - return coreerr.E("cache.Set", "failed to marshal cache entry", err) + entryResult := core.JSONMarshal(entry) + if !entryResult.OK { + return core.E("cache.Set", "failed to marshal cache entry", entryResult.Value.(error)) } - if err := c.medium.Write(path, string(entryBytes)); err != nil { - return coreerr.E("cache.Set", "failed to write cache file", err) + if err := c.medium.Write(path, string(entryResult.Value.([]byte))); err != nil { + return core.E("cache.Set", "failed to write cache file", err) } return nil } -// Delete removes an item from the cache. -// -// Usage example: +// Delete removes the cached item for key. // -// err := c.Delete("session/user-42") +// err := c.Delete("github/acme/repos") func (c *Cache) Delete(key string) error { path, err := c.Path(key) if err != nil { @@ -194,32 +162,28 @@ func (c *Cache) Delete(key string) error { } err = c.medium.Delete(path) - if core.Is(err, os.ErrNotExist) { + if core.Is(err, fs.ErrNotExist) { return nil } if err != nil { - return coreerr.E("cache.Delete", "failed to delete cache file", err) + return core.E("cache.Delete", "failed to delete cache file", err) } return nil } -// Clear removes all cached items. -// -// Usage example: +// Clear removes all cached items under the cache base directory. // // err := c.Clear() func (c *Cache) Clear() error { if err := c.medium.DeleteAll(c.baseDir); err != nil { - return coreerr.E("cache.Clear", "failed to clear cache", err) + return core.E("cache.Clear", "failed to clear cache", err) } return nil } -// Age returns how old a cached item is, or -1 if not cached. +// Age reports how long ago key was cached, or -1 if it is missing or unreadable. // -// Usage example: -// -// age := c.Age("session/user-42") +// age := c.Age("github/acme/repos") func (c *Cache) Age(key string) time.Duration { path, err := c.Path(key) if err != nil { @@ -232,7 +196,8 @@ func (c *Cache) Age(key string) time.Duration { } var entry Entry - if err := json.Unmarshal([]byte(dataStr), &entry); err != nil { + entryResult := core.JSONUnmarshalString(dataStr, &entry) + if !entryResult.OK { return -1 } @@ -241,53 +206,58 @@ func (c *Cache) Age(key string) time.Duration { // GitHub-specific cache keys -// GitHubReposKey returns the cache key for an org's repo list. -// -// Usage example: +// 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", org, "repos") } -// GitHubRepoKey returns the cache key for a specific repo's metadata. -// -// Usage example: +// GitHubRepoKey returns the cache key used for a repository metadata entry. // // key := cache.GitHubRepoKey("acme", "widgets") func GitHubRepoKey(org, repo string) string { return core.JoinPath("github", org, repo, "meta") } -func joinPath(segments ...string) string { - return normalizePath(core.JoinPath(segments...)) +func pathSeparator() string { + if ds := core.Env("DS"); ds != "" { + return ds + } + + return "/" } -func pathAbs(path string) (string, error) { - path = normalizePath(path) - if core.PathIsAbs(path) { - return core.CleanPath(path, pathSeparator()), nil - } +func normalizePath(path string) string { + ds := pathSeparator() + normalized := core.Replace(path, "\\", ds) - cwd, err := os.Getwd() - if err != nil { - return "", err + if ds != "/" { + normalized = core.Replace(normalized, "/", ds) } - return core.Path(cwd, path), nil + return core.CleanPath(normalized, ds) } -func normalizePath(path string) string { - if pathSeparator() == "/" { - return path +func absolutePath(path string) string { + normalized := normalizePath(path) + if core.PathIsAbs(normalized) { + return normalized + } + + cwd := currentDir() + if cwd == "" || cwd == "." { + return normalized } - return core.Replace(path, "/", pathSeparator()) + + return normalizePath(core.JoinPath(cwd, normalized)) } -func pathSeparator() string { - sep := core.Env("DS") - if sep == "" { - return "/" +func currentDir() string { + cwd := normalizePath(core.Env("PWD")) + if cwd != "" && cwd != "." { + return cwd } - return sep + + return normalizePath(core.Env("DIR_CWD")) } diff --git a/cache_test.go b/cache_test.go index 15d122b..1dd7ab1 100644 --- a/cache_test.go +++ b/cache_test.go @@ -6,76 +6,170 @@ import ( "testing" "time" + "dappco.re/go/core" "dappco.re/go/core/cache" coreio "dappco.re/go/core/io" ) -func TestCache(t *testing.T) { +func newTestCache(t *testing.T, baseDir string, ttl time.Duration) (*cache.Cache, *coreio.MockMedium) { + t.Helper() + m := coreio.NewMockMedium() - // Use a path that MockMedium will understand - baseDir := "/tmp/cache" - c, err := cache.New(m, baseDir, 1*time.Minute) + 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 TestCache_New_Good(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + c, m := newTestCache(t, "", 0) + + const key = "defaults" + if err := c.Set(key, map[string]string{"foo": "bar"}); err != nil { + t.Fatalf("Set failed: %v", err) + } + + path, err := c.Path(key) + if err != nil { + t.Fatalf("Path failed: %v", 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) + } + + 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_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("Path failed: %v", 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) + + _, err := c.Path("../../etc/passwd") + if err == nil { + t.Fatal("expected error for path traversal key, got nil") + } +} + +func TestCache_Get_Good(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache", time.Minute) + key := "test-key" data := map[string]string{"foo": "bar"} - // Test Set if err := c.Set(key, data); err != nil { - t.Errorf("Set failed: %v", err) + t.Fatalf("Set failed: %v", err) } - // Test Get var retrieved map[string]string found, err := c.Get(key, &retrieved) if err != nil { - t.Errorf("Get failed: %v", err) + t.Fatalf("Get failed: %v", err) } if !found { - t.Error("expected to find cached item") + t.Fatal("expected to find cached item") } if retrieved["foo"] != "bar" { t.Errorf("expected foo=bar, got %v", retrieved["foo"]) } +} - // Test Age - age := c.Age(key) - if age < 0 { - t.Error("expected age >= 0") - } +func TestCache_Get_Ugly(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-expiry", 10*time.Millisecond) - // Test Delete - if err := c.Delete(key); err != nil { - t.Errorf("Delete failed: %v", err) + if err := c.Set("test-key", map[string]string{"foo": "bar"}); err != nil { + t.Fatalf("Set for expiry test failed: %v", err) } - found, err = c.Get(key, &retrieved) + + time.Sleep(50 * time.Millisecond) + + var retrieved map[string]string + found, err := c.Get("test-key", &retrieved) if err != nil { - t.Errorf("Get after delete returned an unexpected error: %v", err) + t.Fatalf("Get for expired item returned an unexpected error: %v", err) } if found { - t.Error("expected item to be deleted") + t.Error("expected item to be expired") } +} - // Test Expiry - cshort, err := cache.New(m, "/tmp/cache-short", 10*time.Millisecond) - if err != nil { - t.Fatalf("failed to create short-lived cache: %v", err) +func TestCache_Age_Good(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-age", time.Minute) + + if err := c.Set("test-key", map[string]string{"foo": "bar"}); err != nil { + t.Fatalf("Set failed: %v", err) } - if err := cshort.Set(key, data); err != nil { - t.Fatalf("Set for expiry test failed: %v", err) + + if age := c.Age("test-key"); age < 0 { + t.Errorf("expected age >= 0, got %v", age) } - time.Sleep(50 * time.Millisecond) - found, err = cshort.Get(key, &retrieved) +} + +func TestCache_Delete_Good(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-delete", time.Minute) + + if err := c.Set("test-key", map[string]string{"foo": "bar"}); err != nil { + t.Fatalf("Set failed: %v", err) + } + + if err := c.Delete("test-key"); err != nil { + t.Fatalf("Delete failed: %v", err) + } + + var retrieved map[string]string + found, err := c.Get("test-key", &retrieved) if err != nil { - t.Errorf("Get for expired item returned an unexpected error: %v", err) + t.Fatalf("Get after delete returned an unexpected error: %v", err) } if found { - t.Error("expected item to be expired") + t.Error("expected item to be deleted") } +} + +func TestCache_Clear_Good(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-clear", time.Minute) + data := map[string]string{"foo": "bar"} - // Test Clear if err := c.Set("key1", data); err != nil { t.Fatalf("Set for clear test failed for key1: %v", err) } @@ -83,49 +177,29 @@ func TestCache(t *testing.T) { t.Fatalf("Set for clear test failed for key2: %v", err) } if err := c.Clear(); err != nil { - t.Errorf("Clear failed: %v", err) + t.Fatalf("Clear failed: %v", err) } - found, err = c.Get("key1", &retrieved) + + var retrieved map[string]string + found, err := c.Get("key1", &retrieved) if err != nil { - t.Errorf("Get after clear returned an unexpected error: %v", err) + t.Fatalf("Get after clear returned an unexpected error: %v", err) } if found { t.Error("expected key1 to be cleared") } } -func TestCacheDefaults(t *testing.T) { - // Test default Medium (io.Local) and default TTL - c, err := cache.New(nil, "", 0) - if err != nil { - t.Fatalf("failed to create cache with defaults: %v", err) - } - if c == nil { - t.Fatal("expected cache instance") - } -} - -func TestGitHubKeys(t *testing.T) { +func TestCache_GitHubReposKey_Good(t *testing.T) { key := cache.GitHubReposKey("myorg") if key != "github/myorg/repos" { t.Errorf("unexpected GitHubReposKey: %q", key) } +} - key = cache.GitHubRepoKey("myorg", "myrepo") +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 TestPathTraversalRejected(t *testing.T) { - m := coreio.NewMockMedium() - c, err := cache.New(m, "/tmp/cache-traversal", 1*time.Minute) - if err != nil { - t.Fatalf("failed to create cache: %v", err) - } - - _, err = c.Path("../../etc/passwd") - if err == nil { - t.Error("expected error for path traversal key, got nil") - } -} diff --git a/go.mod b/go.mod index e0e28ad..7ff2452 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.26.0 require ( dappco.re/go/core v0.8.0-alpha.1 dappco.re/go/core/io v0.2.0 - dappco.re/go/core/log v0.1.0 ) require forge.lthn.ai/core/go-log v0.0.4 // indirect diff --git a/go.sum b/go.sum index 5a2e71c..bfbbbf3 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E= -dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= -dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs= forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= From 529e60f3ffb1e065d0322d640c16758db9e9b4e3 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 29 Mar 2026 23:46:45 +0000 Subject: [PATCH 07/12] fix(cache): preserve raw JSON payload in entries --- cache.go | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/cache.go b/cache.go index acbe22f..4d5e92d 100644 --- a/cache.go +++ b/cache.go @@ -4,6 +4,7 @@ package cache import ( + "encoding/json" "io/fs" "time" @@ -27,7 +28,7 @@ type Cache struct { // Entry is the serialized cache record written to the backing Medium. type Entry struct { - Data any `json:"data"` + Data json.RawMessage `json:"data"` CachedAt time.Time `json:"cached_at"` ExpiresAt time.Time `json:"expires_at"` } @@ -110,12 +111,7 @@ func (c *Cache) Get(key string, dest any) (bool, error) { return false, nil } - dataResult := core.JSONMarshal(entry.Data) - if !dataResult.OK { - return false, core.E("cache.Get", "failed to marshal cached data", dataResult.Value.(error)) - } - - if err := core.JSONUnmarshal(dataResult.Value.([]byte), dest); !err.OK { + if err := core.JSONUnmarshal(entry.Data, dest); !err.OK { return false, core.E("cache.Get", "failed to unmarshal cached data", err.Value.(error)) } @@ -135,8 +131,13 @@ func (c *Cache) Set(key string, data any) error { return core.E("cache.Set", "failed to create directory", err) } + dataResult := core.JSONMarshal(data) + if !dataResult.OK { + return core.E("cache.Set", "failed to marshal cache data", dataResult.Value.(error)) + } + entry := Entry{ - Data: data, + Data: dataResult.Value.([]byte), CachedAt: time.Now(), ExpiresAt: time.Now().Add(c.ttl), } From 41150c0548346e1ec1a8233b3a598fde458b4f98 Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 30 Mar 2026 00:31:21 +0000 Subject: [PATCH 08/12] fix(cache): harden nil receiver handling --- cache.go | 28 ++++++++++++++++++++++++++-- cache_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/cache.go b/cache.go index 4d5e92d..c1e4631 100644 --- a/cache.go +++ b/cache.go @@ -29,8 +29,8 @@ type Cache struct { // Entry is the serialized cache record written to the backing Medium. type Entry struct { Data json.RawMessage `json:"data"` - CachedAt time.Time `json:"cached_at"` - ExpiresAt time.Time `json:"expires_at"` + CachedAt time.Time `json:"cached_at"` + ExpiresAt time.Time `json:"expires_at"` } // New creates a cache and applies default Medium, base directory, and TTL values @@ -73,6 +73,10 @@ func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error // // path, err := c.Path("github/acme/repos") func (c *Cache) Path(key string) (string, error) { + if c == nil { + return "", core.E("cache.Path", "cache is nil", nil) + } + baseDir := absolutePath(c.baseDir) path := absolutePath(core.JoinPath(baseDir, key+".json")) pathPrefix := normalizePath(core.Concat(baseDir, pathSeparator())) @@ -88,6 +92,10 @@ func (c *Cache) Path(key string) (string, error) { // // found, err := c.Get("github/acme/repos", &repos) func (c *Cache) Get(key string, dest any) (bool, error) { + if c == nil { + return false, core.E("cache.Get", "cache is nil", nil) + } + path, err := c.Path(key) if err != nil { return false, err @@ -122,6 +130,10 @@ func (c *Cache) Get(key string, dest any) (bool, error) { // // err := c.Set("github/acme/repos", repos) func (c *Cache) Set(key string, data any) error { + if c == nil { + return core.E("cache.Set", "cache is nil", nil) + } + path, err := c.Path(key) if err != nil { return err @@ -157,6 +169,10 @@ func (c *Cache) Set(key string, data any) error { // // err := c.Delete("github/acme/repos") func (c *Cache) Delete(key string) error { + if c == nil { + return core.E("cache.Delete", "cache is nil", nil) + } + path, err := c.Path(key) if err != nil { return err @@ -176,6 +192,10 @@ func (c *Cache) Delete(key string) error { // // err := c.Clear() func (c *Cache) Clear() error { + if c == nil { + return core.E("cache.Clear", "cache is nil", nil) + } + if err := c.medium.DeleteAll(c.baseDir); err != nil { return core.E("cache.Clear", "failed to clear cache", err) } @@ -186,6 +206,10 @@ func (c *Cache) Clear() error { // // age := c.Age("github/acme/repos") func (c *Cache) Age(key string) time.Duration { + if c == nil { + return -1 + } + path, err := c.Path(key) if err != nil { return -1 diff --git a/cache_test.go b/cache_test.go index 1dd7ab1..e65f101 100644 --- a/cache_test.go +++ b/cache_test.go @@ -145,6 +145,35 @@ func TestCache_Age_Good(t *testing.T) { } } +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.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_Delete_Good(t *testing.T) { c, _ := newTestCache(t, "/tmp/cache-delete", time.Minute) From fbf410e630d6a4399862490987ed51a742a4377f Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 30 Mar 2026 01:28:22 +0000 Subject: [PATCH 09/12] fix(cache): harden uninitialised cache state --- cache.go | 58 ++++++++++++++++++++++++++++++++++++++++----------- cache_test.go | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/cache.go b/cache.go index c1e4631..0c36711 100644 --- a/cache.go +++ b/cache.go @@ -53,6 +53,10 @@ func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error baseDir = absolutePath(baseDir) } + if ttl < 0 { + return nil, core.E("cache.New", "ttl must be >= 0", nil) + } + if ttl == 0 { ttl = DefaultTTL } @@ -73,8 +77,8 @@ func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error // // path, err := c.Path("github/acme/repos") func (c *Cache) Path(key string) (string, error) { - if c == nil { - return "", core.E("cache.Path", "cache is nil", nil) + if err := c.ensureConfigured("cache.Path"); err != nil { + return "", err } baseDir := absolutePath(c.baseDir) @@ -92,8 +96,8 @@ func (c *Cache) Path(key string) (string, error) { // // found, err := c.Get("github/acme/repos", &repos) func (c *Cache) Get(key string, dest any) (bool, error) { - if c == nil { - return false, core.E("cache.Get", "cache is nil", nil) + if err := c.ensureReady("cache.Get"); err != nil { + return false, err } path, err := c.Path(key) @@ -130,8 +134,8 @@ func (c *Cache) Get(key string, dest any) (bool, error) { // // err := c.Set("github/acme/repos", repos) func (c *Cache) Set(key string, data any) error { - if c == nil { - return core.E("cache.Set", "cache is nil", nil) + if err := c.ensureReady("cache.Set"); err != nil { + return err } path, err := c.Path(key) @@ -148,10 +152,18 @@ func (c *Cache) Set(key string, data any) error { return core.E("cache.Set", "failed to marshal cache data", dataResult.Value.(error)) } + ttl := c.ttl + if ttl < 0 { + return core.E("cache.Set", "cache ttl must be >= 0", nil) + } + if ttl == 0 { + ttl = DefaultTTL + } + entry := Entry{ Data: dataResult.Value.([]byte), CachedAt: time.Now(), - ExpiresAt: time.Now().Add(c.ttl), + ExpiresAt: time.Now().Add(ttl), } entryResult := core.JSONMarshal(entry) @@ -169,8 +181,8 @@ func (c *Cache) Set(key string, data any) error { // // err := c.Delete("github/acme/repos") func (c *Cache) Delete(key string) error { - if c == nil { - return core.E("cache.Delete", "cache is nil", nil) + if err := c.ensureReady("cache.Delete"); err != nil { + return err } path, err := c.Path(key) @@ -192,8 +204,8 @@ func (c *Cache) Delete(key string) error { // // err := c.Clear() func (c *Cache) Clear() error { - if c == nil { - return core.E("cache.Clear", "cache is nil", nil) + if err := c.ensureReady("cache.Clear"); err != nil { + return err } if err := c.medium.DeleteAll(c.baseDir); err != nil { @@ -206,7 +218,7 @@ func (c *Cache) Clear() error { // // age := c.Age("github/acme/repos") func (c *Cache) Age(key string) time.Duration { - if c == nil { + if err := c.ensureReady("cache.Age"); err != nil { return -1 } @@ -286,3 +298,25 @@ func currentDir() string { 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) + } + + 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_test.go b/cache_test.go index e65f101..b38f57b 100644 --- a/cache_test.go +++ b/cache_test.go @@ -68,6 +68,13 @@ func TestCache_New_Good(t *testing.T) { } } +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_Path_Good(t *testing.T) { c, _ := newTestCache(t, "/tmp/cache-path", time.Minute) @@ -174,6 +181,35 @@ func TestCache_NilReceiver_Good(t *testing.T) { } } +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.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) From 248f542b08ccd7e836a485355fc6a539ac3c785f Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 05:20:29 +0000 Subject: [PATCH 10/12] fix(cache): pretty-print cache envelope Co-Authored-By: Virgil --- cache.go | 8 ++++---- cache_test.go | 4 ++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cache.go b/cache.go index 0c36711..8457794 100644 --- a/cache.go +++ b/cache.go @@ -166,12 +166,12 @@ func (c *Cache) Set(key string, data any) error { ExpiresAt: time.Now().Add(ttl), } - entryResult := core.JSONMarshal(entry) - if !entryResult.OK { - return core.E("cache.Set", "failed to marshal cache entry", entryResult.Value.(error)) + entryBytes, err := json.MarshalIndent(entry, "", " ") + if err != nil { + return core.E("cache.Set", "failed to marshal cache entry", err) } - if err := c.medium.Write(path, string(entryResult.Value.([]byte))); err != nil { + if err := c.medium.Write(path, string(entryBytes)); err != nil { return core.E("cache.Set", "failed to write cache file", err) } return nil diff --git a/cache_test.go b/cache_test.go index b38f57b..4011543 100644 --- a/cache_test.go +++ b/cache_test.go @@ -3,6 +3,7 @@ package cache_test import ( + "strings" "testing" "time" @@ -60,6 +61,9 @@ func TestCache_New_Good(t *testing.T) { if err != nil { t.Fatalf("Read failed: %v", err) } + if !strings.Contains(raw, "\n \"data\":") { + t.Fatalf("expected pretty-printed cache entry, got %q", raw) + } entry := readEntry(t, raw) ttl := entry.ExpiresAt.Sub(entry.CachedAt) From 19232c55752e37263ae133d253646c7df2495ed5 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 09:46:02 +0000 Subject: [PATCH 11/12] feat(cache): add batch eviction support --- cache.go | 26 ++++++++++++++++++++++++++ cache_test.go | 32 ++++++++++++++++++++++++++++++++ docs/api-contract.md | 1 + docs/architecture.md | 2 ++ 4 files changed, 61 insertions(+) diff --git a/cache.go b/cache.go index 8457794..f5c366c 100644 --- a/cache.go +++ b/cache.go @@ -200,6 +200,32 @@ func (c *Cache) Delete(key string) error { return nil } +// DeleteMany removes several cached items in one call. +// +// err := c.DeleteMany("github/acme/repos", "github/acme/meta") +func (c *Cache) DeleteMany(keys ...string) error { + if err := c.ensureReady("cache.DeleteMany"); err != nil { + return err + } + + for _, key := range keys { + path, err := c.Path(key) + if err != nil { + return err + } + + err = c.medium.Delete(path) + if core.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return core.E("cache.DeleteMany", "failed to delete cache file", err) + } + } + + return nil +} + // Clear removes all cached items under the cache base directory. // // err := c.Clear() diff --git a/cache_test.go b/cache_test.go index 4011543..f6b1922 100644 --- a/cache_test.go +++ b/cache_test.go @@ -235,6 +235,38 @@ func TestCache_Delete_Good(t *testing.T) { } } +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_Clear_Good(t *testing.T) { c, _ := newTestCache(t, "/tmp/cache-clear", time.Minute) data := map[string]string{"foo": "bar"} diff --git a/docs/api-contract.md b/docs/api-contract.md index 3410d48..e92dbc2 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -22,6 +22,7 @@ own usage example in a doc comment or Go example test. | `(*Cache).Get` | `func (c *Cache) Get(key string, dest any) (bool, error)` | `dappco.re/go/core/cache` | Retrieves a cached item if it exists and has not expired. | yes | no | | `(*Cache).Set` | `func (c *Cache) Set(key string, data any) error` | `dappco.re/go/core/cache` | Stores an item in the cache. | yes | no | | `(*Cache).Delete` | `func (c *Cache) Delete(key string) error` | `dappco.re/go/core/cache` | Removes an item from the cache. | yes | no | +| `(*Cache).DeleteMany` | `func (c *Cache) DeleteMany(keys ...string) error` | `dappco.re/go/core/cache` | Removes several items from the cache in one call. | yes | no | | `(*Cache).Clear` | `func (c *Cache) Clear() error` | `dappco.re/go/core/cache` | Removes all cached items. | yes | no | | `(*Cache).Age` | `func (c *Cache) Age(key string) time.Duration` | `dappco.re/go/core/cache` | Returns how old a cached item is, or `-1` if it is not cached. | yes | no | | `GitHubReposKey` | `func GitHubReposKey(org string) string` | `dappco.re/go/core/cache` | Returns the cache key for an organization's repo list. | yes | no | diff --git a/docs/architecture.md b/docs/architecture.md index 0f9f619..d445611 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -136,6 +136,8 @@ Key behaviours: - **`Delete(key)`** removes a single entry. If the file does not exist, the operation succeeds silently. +- **`DeleteMany(keys...)`** removes several entries in one call and ignores + missing files, using the same per-key path validation as `Delete()`. - **`Clear()`** calls `medium.DeleteAll(baseDir)`, removing the entire cache directory and all its contents. From 5eb611f5e2311a8715d74020dad75e995b8b684f Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 4 Apr 2026 16:21:12 +0100 Subject: [PATCH 12/12] fix: migrate module paths from forge.lthn.ai to dappco.re Co-Authored-By: Virgil --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 7ff2452..13e3c91 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,4 @@ require ( dappco.re/go/core/io v0.2.0 ) -require forge.lthn.ai/core/go-log v0.0.4 // indirect +require dappco.re/go/core/log v0.0.4 // indirect