From 261a7ba9503396217ce31e367371c408e42fd4d9 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 26 Mar 2026 17:08:09 +0000 Subject: [PATCH] Polish AX v0.8.0 cache package Co-Authored-By: Virgil --- cache.go | 208 +++++++++++++++++++++----------------------------- cache_test.go | 202 ++++++++++++++++++++++++++++++++---------------- go.mod | 1 - go.sum | 2 - 4 files changed, 227 insertions(+), 186 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") + 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")) } 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) - } - 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") - } -} 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=