diff --git a/cache.go b/cache.go index 356628a..2a14d11 100644 --- a/cache.go +++ b/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) diff --git a/cache_test.go b/cache_test.go index edb856c..8f1dfa1 100644 --- a/cache_test.go +++ b/cache_test.go @@ -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) diff --git a/docs/architecture.md b/docs/architecture.md index b2199b0..2a6132b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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: , - CachedAt: time.Now(), - ExpiresAt: time.Now().Add(ttl), + Data: , + 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. diff --git a/docs/development.md b/docs/development.md index ffde628..0a71758 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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) diff --git a/docs/index.md b/docs/index.md index 8b07c06..a5cf928 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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