diff --git a/cache.go b/cache.go index cff8458..356628a 100644 --- a/cache.go +++ b/cache.go @@ -2,15 +2,11 @@ package cache import ( - "encoding/json" - "errors" - "os" - "path/filepath" - "strings" + "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. @@ -25,34 +21,37 @@ type Cache struct { // 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 cache and applies default Medium, base directory, and TTL values // when callers pass zero values. +// +// 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 = filepath.Join(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{ @@ -64,27 +63,23 @@ func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error // 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 := filepath.Join(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 := filepath.Abs(c.baseDir) - if err != nil { - return "", coreerr.E("cache.Path", "failed to get absolute path for baseDir", err) - } - absPath, err := filepath.Abs(path) - if err != nil { - return "", coreerr.E("cache.Path", "failed to get absolute path for key", err) - } - - if !strings.HasPrefix(absPath, absBase+string(filepath.Separator)) && 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 unmarshals the cached item into dest if it exists and has not expired. +// +// 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 { @@ -93,67 +88,67 @@ func (c *Cache) Get(key string, dest any) (bool, error) { dataStr, err := c.medium.Read(path) if err != nil { - if errors.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 marshals data and stores it in the cache. +// +// 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(filepath.Dir(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) + if err := c.medium.EnsureDir(core.PathDir(path)); err != nil { + 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 the cached item for key. +// +// err := c.Delete("github/acme/repos") func (c *Cache) Delete(key string) error { path, err := c.Path(key) if err != nil { @@ -161,24 +156,28 @@ func (c *Cache) Delete(key string) error { } err = c.medium.Delete(path) - if errors.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 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 reports how long ago key was cached, or -1 if it is missing or unreadable. +// +// age := c.Age("github/acme/repos") func (c *Cache) Age(key string) time.Duration { path, err := c.Path(key) if err != nil { @@ -191,7 +190,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 } @@ -201,11 +201,57 @@ func (c *Cache) Age(key string) time.Duration { // GitHub-specific cache keys // GitHubReposKey returns the cache key used for an organisation's repo list. +// +// key := cache.GitHubReposKey("acme") func GitHubReposKey(org string) string { - return filepath.Join("github", org, "repos") + return core.JoinPath("github", org, "repos") } // GitHubRepoKey returns the cache key used for a repository metadata entry. +// +// key := cache.GitHubRepoKey("acme", "widgets") func GitHubRepoKey(org, repo string) string { - return filepath.Join("github", org, repo, "meta") + return core.JoinPath("github", org, repo, "meta") +} + +func pathSeparator() string { + if ds := core.Env("DS"); ds != "" { + return ds + } + + return "/" +} + +func normalizePath(path string) string { + ds := pathSeparator() + normalized := core.Replace(path, "\\", ds) + + if ds != "/" { + normalized = core.Replace(normalized, "/", ds) + } + + return core.CleanPath(normalized, ds) +} + +func absolutePath(path string) string { + normalized := normalizePath(path) + if core.PathIsAbs(normalized) { + return normalized + } + + 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 8314ce6..edb856c 100644 --- a/cache_test.go +++ b/cache_test.go @@ -1,11 +1,10 @@ package cache_test import ( - "encoding/json" - "path/filepath" "testing" "time" + "dappco.re/go/core" "dappco.re/go/core/cache" coreio "dappco.re/go/core/io" ) @@ -22,7 +21,75 @@ func newTestCache(t *testing.T, baseDir string, ttl time.Duration) (*cache.Cache return c, m } -func TestCacheSetAndGet(t *testing.T) { +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" @@ -45,7 +112,26 @@ func TestCacheSetAndGet(t *testing.T) { } } -func TestCacheAge(t *testing.T) { +func TestCache_Get_Ugly(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 { + t.Fatalf("Set for expiry test failed: %v", err) + } + + time.Sleep(50 * time.Millisecond) + + var retrieved map[string]string + found, err := c.Get("test-key", &retrieved) + if err != nil { + 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 { @@ -57,7 +143,7 @@ func TestCacheAge(t *testing.T) { } } -func TestCacheDelete(t *testing.T) { +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 { @@ -78,26 +164,7 @@ func TestCacheDelete(t *testing.T) { } } -func TestCacheExpiry(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 { - t.Fatalf("Set for expiry test failed: %v", err) - } - - time.Sleep(50 * time.Millisecond) - - var retrieved map[string]string - found, err := c.Get("test-key", &retrieved) - if err != nil { - t.Fatalf("Get for expired item returned an unexpected error: %v", err) - } - if found { - t.Error("expected item to be expired") - } -} - -func TestCacheClear(t *testing.T) { +func TestCache_Clear_Good(t *testing.T) { c, _ := newTestCache(t, "/tmp/cache-clear", time.Minute) data := map[string]string{"foo": "bar"} @@ -121,62 +188,16 @@ func TestCacheClear(t *testing.T) { } } -func TestCacheUsesDefaultBaseDirAndTTL(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 := filepath.Join(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) - } - - var entry cache.Entry - if err := json.Unmarshal([]byte(raw), &entry); err != nil { - t.Fatalf("failed to unmarshal cache entry: %v", err) - } - - 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 TestGitHubReposKey(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) } } -func TestGitHubRepoKey(t *testing.T) { +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 TestCacheRejectsPathTraversal(t *testing.T) { - c, _ := newTestCache(t, "/tmp/cache-traversal", time.Minute) - - _, 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 c7424fc..7ff2452 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module dappco.re/go/core/cache 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 76d01ec..bfbbbf3 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ +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=