Polish AX v0.8.0 cache package
Some checks failed
CI / test (push) Failing after 2s
CI / auto-fix (push) Failing after 0s
CI / auto-merge (push) Failing after 0s

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-26 17:08:09 +00:00
parent 29ec99df12
commit 261a7ba950
4 changed files with 227 additions and 186 deletions

208
cache.go
View file

@ -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")
baseDir := absolutePath(c.baseDir)
path := absolutePath(core.JoinPath(baseDir, key+".json"))
pathPrefix := normalizePath(core.Concat(baseDir, pathSeparator()))
// 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)
}
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.
// Get unmarshals the cached item into dest if it exists and has not expired.
//
// Usage example:
//
// 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.
// Set marshals data and stores it in the cache.
//
// Usage example:
//
// 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.
// Delete removes the cached item for key.
//
// Usage example:
//
// 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 pathAbs(path string) (string, error) {
path = normalizePath(path)
if core.PathIsAbs(path) {
return core.CleanPath(path, pathSeparator()), nil
func pathSeparator() string {
if ds := core.Env("DS"); ds != "" {
return ds
}
cwd, err := os.Getwd()
if err != nil {
return "", err
}
return core.Path(cwd, path), nil
return "/"
}
func normalizePath(path string) string {
if pathSeparator() == "/" {
return path
ds := pathSeparator()
normalized := core.Replace(path, "\\", ds)
if ds != "/" {
normalized = core.Replace(normalized, "/", ds)
}
return core.Replace(path, "/", pathSeparator())
return core.CleanPath(normalized, ds)
}
func pathSeparator() string {
sep := core.Env("DS")
if sep == "" {
return "/"
func absolutePath(path string) string {
normalized := normalizePath(path)
if core.PathIsAbs(normalized) {
return normalized
}
return sep
cwd := currentDir()
if cwd == "" || cwd == "." {
return normalized
}
return normalizePath(core.JoinPath(cwd, normalized))
}
func currentDir() string {
cwd := normalizePath(core.Env("PWD"))
if cwd != "" && cwd != "." {
return cwd
}
return normalizePath(core.Env("DIR_CWD"))
}

View file

@ -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)
}
found, err = c.Get(key, &retrieved)
if err != nil {
t.Errorf("Get after delete returned an unexpected error: %v", err)
}
if found {
t.Error("expected item to be deleted")
}
// 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)
}
if err := cshort.Set(key, data); err != nil {
if err := c.Set("test-key", map[string]string{"foo": "bar"}); err != nil {
t.Fatalf("Set for expiry test failed: %v", err)
}
time.Sleep(50 * time.Millisecond)
found, err = cshort.Get(key, &retrieved)
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 for expired item returned an unexpected error: %v", err)
}
if found {
t.Error("expected item to be expired")
}
}
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 age := c.Age("test-key"); age < 0 {
t.Errorf("expected age >= 0, got %v", age)
}
}
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.Fatalf("Get after delete returned an unexpected error: %v", err)
}
if found {
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")
}
}

1
go.mod
View file

@ -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

2
go.sum
View file

@ -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=