fix(cache): complete AX compliance cleanup
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
e8105f7385
commit
e5fbd27563
5 changed files with 66 additions and 65 deletions
5
cache.go
5
cache.go
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue