fix(cache): complete AX compliance cleanup

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-27 04:21:31 +00:00
parent e8105f7385
commit e5fbd27563
5 changed files with 66 additions and 65 deletions

View file

@ -129,10 +129,11 @@ func (c *Cache) Set(key string, data any) error {
return core.E("cache.Set", "failed to create directory", err)
}
now := time.Now()
entry := Entry{
Data: data,
CachedAt: time.Now(),
ExpiresAt: time.Now().Add(c.ttl),
CachedAt: now,
ExpiresAt: now.Add(c.ttl),
}
entryResult := core.JSONMarshal(entry)

View file

@ -33,7 +33,7 @@ func readEntry(t *testing.T, raw string) cache.Entry {
return entry
}
func TestCache_New_Good(t *testing.T) {
func TestCache_New_UsesDefaultBaseDirAndTTL(t *testing.T) {
tmpDir := t.TempDir()
t.Chdir(tmpDir)
@ -66,7 +66,7 @@ func TestCache_New_Good(t *testing.T) {
}
}
func TestCache_Path_Good(t *testing.T) {
func TestCache_Path_ReturnsStoragePath(t *testing.T) {
c, _ := newTestCache(t, "/tmp/cache-path", time.Minute)
path, err := c.Path("github/acme/repos")
@ -80,7 +80,7 @@ func TestCache_Path_Good(t *testing.T) {
}
}
func TestCache_Path_Bad(t *testing.T) {
func TestCache_Path_RejectsTraversal(t *testing.T) {
c, _ := newTestCache(t, "/tmp/cache-traversal", time.Minute)
_, err := c.Path("../../etc/passwd")
@ -89,7 +89,7 @@ func TestCache_Path_Bad(t *testing.T) {
}
}
func TestCache_Get_Good(t *testing.T) {
func TestCache_Get_ReturnsCachedValue(t *testing.T) {
c, _ := newTestCache(t, "/tmp/cache", time.Minute)
key := "test-key"
@ -112,7 +112,7 @@ func TestCache_Get_Good(t *testing.T) {
}
}
func TestCache_Get_Ugly(t *testing.T) {
func TestCache_Get_TreatsExpiredEntryAsMiss(t *testing.T) {
c, _ := newTestCache(t, "/tmp/cache-expiry", 10*time.Millisecond)
if err := c.Set("test-key", map[string]string{"foo": "bar"}); err != nil {
@ -131,7 +131,7 @@ func TestCache_Get_Ugly(t *testing.T) {
}
}
func TestCache_Age_Good(t *testing.T) {
func TestCache_Age_ReturnsElapsedDuration(t *testing.T) {
c, _ := newTestCache(t, "/tmp/cache-age", time.Minute)
if err := c.Set("test-key", map[string]string{"foo": "bar"}); err != nil {
@ -143,7 +143,7 @@ func TestCache_Age_Good(t *testing.T) {
}
}
func TestCache_Delete_Good(t *testing.T) {
func TestCache_Delete_RemovesEntry(t *testing.T) {
c, _ := newTestCache(t, "/tmp/cache-delete", time.Minute)
if err := c.Set("test-key", map[string]string{"foo": "bar"}); err != nil {
@ -164,7 +164,7 @@ func TestCache_Delete_Good(t *testing.T) {
}
}
func TestCache_Clear_Good(t *testing.T) {
func TestCache_Clear_RemovesAllEntries(t *testing.T) {
c, _ := newTestCache(t, "/tmp/cache-clear", time.Minute)
data := map[string]string{"foo": "bar"}
@ -188,14 +188,14 @@ func TestCache_Clear_Good(t *testing.T) {
}
}
func TestCache_GitHubReposKey_Good(t *testing.T) {
func TestCache_GitHubReposKey_ReturnsReposPath(t *testing.T) {
key := cache.GitHubReposKey("myorg")
if key != "github/myorg/repos" {
t.Errorf("unexpected GitHubReposKey: %q", key)
}
}
func TestCache_GitHubRepoKey_Good(t *testing.T) {
func TestCache_GitHubRepoKey_ReturnsMetadataPath(t *testing.T) {
key := cache.GitHubRepoKey("myorg", "myrepo")
if key != "github/myorg/myrepo/meta" {
t.Errorf("unexpected GitHubRepoKey: %q", key)

View file

@ -6,7 +6,7 @@ description: Internals of go-cache -- types, data flow, storage format, and secu
# Architecture
This document explains how `go-cache` works internally, covering its type
system, on-disc format, data flow, and security considerations.
system, on-disk format, data flow, and security considerations.
## Core Types
@ -35,16 +35,16 @@ immutable for the lifetime of the instance.
```go
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"`
}
```
`Entry` is the envelope written to storage. It wraps the caller's data as raw
JSON and adds two timestamps for expiry tracking. Using `json.RawMessage` means
the data payload is stored verbatim -- no intermediate deserialisation happens
during writes.
`Entry` is the envelope written to storage. It wraps the caller's value and
adds two timestamps for expiry tracking. The entry is JSON-encoded as a whole
when written, and `Data` is re-marshalled on reads before decoding into the
caller-provided destination value.
## Constructor Defaults
@ -70,23 +70,20 @@ directory exists before any reads or writes.
caller data
|
v
json.Marshal(data) -- serialise caller's value
|
v
wrap in Entry{ -- add timestamps
Data: <marshalled>,
CachedAt: time.Now(),
ExpiresAt: time.Now().Add(ttl),
Data: <value>,
CachedAt: now,
ExpiresAt: now.Add(ttl),
}
|
v
json.MarshalIndent(entry) -- human-readable JSON
core.JSONMarshal(entry) -- serialise the full cache entry
|
v
medium.Write(path, string) -- persist via the storage backend
```
The resulting file on disc (or equivalent record in another medium) looks like:
The resulting file on disk (or equivalent record in another medium) looks like:
```json
{
@ -106,7 +103,7 @@ automatically via `medium.EnsureDir()`.
medium.Read(path)
|
v
json.Unmarshal -> Entry -- parse the envelope
core.JSONUnmarshalString -> Entry -- parse the envelope
|
v
time.Now().After(ExpiresAt)? -- check TTL
@ -114,21 +111,24 @@ time.Now().After(ExpiresAt)? -- check TTL
yes no
| |
v v
return false json.Unmarshal(entry.Data, dest)
return false core.JSONMarshal(entry.Data)
(cache miss) |
v
core.JSONUnmarshal(..., dest)
|
v
return true
(cache hit)
```
Key behaviours:
- If the file does not exist (`os.ErrNotExist`), `Get` returns `(false, nil)` --
- If the file does not exist (`fs.ErrNotExist`), `Get` returns `(false, nil)` --
a miss, not an error.
- If the file contains invalid JSON, it is treated as a miss (not an error).
This prevents corrupted files from blocking the caller.
- If the entry exists but has expired, it is treated as a miss. The stale file
is **not** deleted eagerly -- it remains on disc until explicitly removed or
is **not** deleted eagerly -- it remains on disk until explicitly removed or
overwritten.
@ -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")
}
```
@ -174,12 +174,12 @@ func GitHubRepoKey(org, repo string) string {
## Security: Path Traversal Prevention
The `Path()` method guards against directory traversal attacks. After computing
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:
the full path, it normalises both the base directory and the result to absolute
paths, then checks that the result still lives under the cache root:
```go
if !strings.HasPrefix(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)
}
```
@ -201,10 +201,10 @@ goroutine (e.g. a CLI command fetching GitHub data) and read by others, which
avoids contention.
## Relationship to go-io
## Relationship to core/io
`go-cache` delegates all storage operations to the `io.Medium` interface from
`go-io`. It uses only five methods:
`dappco.re/go/core/io`. It uses only five methods:
| Method | Used by |
|--------------|---------------------|
@ -216,4 +216,4 @@ avoids contention.
This minimal surface makes it straightforward to swap storage backends. For
tests, `io.NewMockMedium()` provides a fully in-memory implementation with no
disc access.
disk access.

View file

@ -47,7 +47,7 @@ go test -run TestCache ./...
```
The test suite uses `io.NewMockMedium()` for all storage operations, so no
files are written to disc and tests run quickly in any environment.
files are written to disk and tests run quickly in any environment.
## Test Coverage
@ -102,19 +102,22 @@ Tests follow the standard Go testing conventions. The codebase uses
1. Use `io.NewMockMedium()` rather than the real filesystem.
2. Keep TTLs short (milliseconds) when testing expiry behaviour.
3. Name test functions descriptively: `TestCacheExpiry`, `TestCacheUsesDefaultBaseDirAndTTL`, etc.
3. Name test functions descriptively: `TestCache_Get_TreatsExpiredEntryAsMiss`,
`TestCache_New_UsesDefaultBaseDirAndTTL`, etc.
Example of testing cache expiry:
```go
func TestCacheExpiry(t *testing.T) {
func TestCache_Get_TreatsExpiredEntryAsMiss(t *testing.T) {
m := io.NewMockMedium()
c, err := cache.New(m, "/tmp/test", 10*time.Millisecond)
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
c.Set("key", "value")
if err := c.Set("key", "value"); err != nil {
t.Fatalf("failed to set cache entry: %v", err)
}
time.Sleep(50 * time.Millisecond)
var result string
@ -160,9 +163,10 @@ binaries.
## Adding a New Storage Backend
To use the cache with a different storage medium, implement the `io.Medium`
interface from `go-io` and pass it to `cache.New()`. The cache only requires
five methods: `EnsureDir`, `Read`, `Write`, `Delete`, and `DeleteAll`. See
the [architecture](architecture.md) document for the full method mapping.
interface from `dappco.re/go/core/io` and pass it to `cache.New()`. The cache
only requires five methods: `EnsureDir`, `Read`, `Write`, `Delete`, and
`DeleteAll`. See the [architecture](architecture.md) document for the full
method mapping.
```go
import (
@ -175,7 +179,7 @@ import (
// Use SQLite as the cache backend
medium, err := store.NewMedium("/path/to/cache.db")
if err != nil {
panic(err)
return
}
c, err := cache.New(medium, "cache", 30*time.Minute)

View file

@ -16,12 +16,7 @@ JSON-serialised entries with automatic TTL expiry and path-traversal protection.
## Quick Start
```go
import (
"fmt"
"time"
"dappco.re/go/core/cache"
)
import "dappco.re/go/core/cache"
func main() {
// Create a cache with default settings:
@ -30,7 +25,7 @@ func main() {
// - TTL: 1 hour
c, err := cache.New(nil, "", 0)
if err != nil {
panic(err)
return
}
// Store a value
@ -39,17 +34,17 @@ func main() {
"role": "admin",
})
if err != nil {
panic(err)
return
}
// Retrieve it (returns false if missing or expired)
var profile map[string]string
found, err := c.Get("user/profile", &profile)
if err != nil {
panic(err)
return
}
if found {
fmt.Println(profile["name"]) // Alice
_ = profile["name"] // Use the cached value.
}
}
```
@ -66,13 +61,14 @@ func main() {
## Dependencies
| Module | Version | Role |
|-------------------------------|---------|---------------------------------------------|
| `dappco.re/go/core/io` | v0.2.0 | Storage abstraction (`Medium` interface) |
| `dappco.re/go/core/log` | v0.1.0 | Structured error wrapping used by the package |
| Module | Version | Role |
|------------------------|------------------|------------------------------------------------|
| `dappco.re/go/core` | `v0.8.0-alpha.1` | JSON, path, string, and error helper utilities |
| `dappco.re/go/core/io` | `v0.2.0` | Storage abstraction (`Medium` interface) |
There are no other runtime dependencies. The test suite uses the standard
library only (plus the `MockMedium` from `core/io`).
The cache package imports only `dappco.re/go/core` and `dappco.re/go/core/io`.
The resolved build list also includes transitive dependencies from `core/io`'s
optional storage backends.
## Key Concepts