diff --git a/cache.go b/cache.go index f532b47..f5c366c 100644 --- a/cache.go +++ b/cache.go @@ -1,59 +1,68 @@ +// SPDX-License-Identifier: EUPL-1.2 + // Package cache provides a storage-agnostic, JSON-based cache backed by any io.Medium. 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. +// +// Usage example: +// +// c, err := cache.New(coreio.NewMockMedium(), "/tmp/cache", cache.DefaultTTL) const DefaultTTL = 1 * time.Hour -// Cache represents a file-based cache. +// 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. +// 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"` } -// 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. +// +// 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 { + return nil, core.E("cache.New", "ttl must be >= 0", nil) } 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{ @@ -63,30 +72,34 @@ 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. +// 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") - - // 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 err := c.ensureConfigured("cache.Path"); err != nil { + return "", err } - if !strings.HasPrefix(absPath, absBase+string(filepath.Separator)) && absPath != absBase { - return "", coreerr.E("cache.Path", "invalid cache key: path traversal attempt", nil) + baseDir := absolutePath(c.baseDir) + path := absolutePath(core.JoinPath(baseDir, key+".json")) + pathPrefix := normalizePath(core.Concat(baseDir, pathSeparator())) + + 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. +// +// found, err := c.Get("github/acme/repos", &repos) func (c *Cache) Get(key string, dest any) (bool, error) { + if err := c.ensureReady("cache.Get"); err != nil { + return false, err + } + path, err := c.Path(key) if err != nil { return false, err @@ -94,93 +107,147 @@ 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) + if err := core.JSONUnmarshal(entry.Data, 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. +// +// err := c.Set("github/acme/repos", repos) func (c *Cache) Set(key string, data any) error { + if err := c.ensureReady("cache.Set"); err != nil { + return err + } + 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) + if err := c.medium.EnsureDir(core.PathDir(path)); err != nil { + return core.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) + dataResult := core.JSONMarshal(data) + if !dataResult.OK { + return core.E("cache.Set", "failed to marshal cache data", dataResult.Value.(error)) + } + + ttl := c.ttl + if ttl < 0 { + return core.E("cache.Set", "cache ttl must be >= 0", nil) + } + if ttl == 0 { + ttl = DefaultTTL } entry := Entry{ - Data: dataBytes, + Data: dataResult.Value.([]byte), CachedAt: time.Now(), - ExpiresAt: time.Now().Add(c.ttl), + ExpiresAt: time.Now().Add(ttl), } entryBytes, err := json.MarshalIndent(entry, "", " ") if err != nil { - return coreerr.E("cache.Set", "failed to marshal cache entry", err) + return core.E("cache.Set", "failed to marshal cache entry", err) } if err := c.medium.Write(path, string(entryBytes)); err != nil { - return coreerr.E("cache.Set", "failed to write cache file", err) + 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. +// +// err := c.Delete("github/acme/repos") func (c *Cache) Delete(key string) error { + if err := c.ensureReady("cache.Delete"); err != nil { + return err + } + path, err := c.Path(key) if err != nil { return err } 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 +} + +// DeleteMany removes several cached items in one call. +// +// err := c.DeleteMany("github/acme/repos", "github/acme/meta") +func (c *Cache) DeleteMany(keys ...string) error { + if err := c.ensureReady("cache.DeleteMany"); err != nil { + return err } + + for _, key := range keys { + path, err := c.Path(key) + if err != nil { + return err + } + + err = c.medium.Delete(path) + if core.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return core.E("cache.DeleteMany", "failed to delete cache file", err) + } + } + return nil } -// Clear removes all cached items. +// Clear removes all cached items under the cache base directory. +// +// err := c.Clear() func (c *Cache) Clear() error { + if err := c.ensureReady("cache.Clear"); err != nil { + return err + } + 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. +// +// age := c.Age("github/acme/repos") func (c *Cache) Age(key string) time.Duration { + if err := c.ensureReady("cache.Age"); err != nil { + return -1 + } + path, err := c.Path(key) if err != nil { return -1 @@ -192,7 +259,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,12 +269,80 @@ func (c *Cache) Age(key string) time.Duration { // GitHub-specific cache keys -// GitHubReposKey returns the cache key for an org's repo list. +// 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 for a specific repo's metadata. +// 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")) +} + +func (c *Cache) ensureConfigured(op string) error { + if c == nil { + return core.E(op, "cache is nil", nil) + } + if c.baseDir == "" { + return core.E(op, "cache base directory is empty; construct with cache.New", nil) + } + + return nil +} + +func (c *Cache) ensureReady(op string) error { + if err := c.ensureConfigured(op); err != nil { + return err + } + if c.medium == nil { + return core.E(op, "cache medium is nil; construct with cache.New", nil) + } + + return nil } diff --git a/cache_test.go b/cache_test.go index c33996e..f6b1922 100644 --- a/cache_test.go +++ b/cache_test.go @@ -1,79 +1,276 @@ +// SPDX-License-Identifier: EUPL-1.2 + package cache_test import ( + "strings" "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) + } + if !strings.Contains(raw, "\n \"data\":") { + t.Fatalf("expected pretty-printed cache entry, got %q", raw) + } + + 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_New_Bad(t *testing.T) { + _, err := cache.New(coreio.NewMockMedium(), "/tmp/cache-negative-ttl", -time.Second) + if err == nil { + t.Fatal("expected New to reject negative ttl, got nil") + } +} + +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"]) } +} + +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 { + t.Fatalf("Set failed: %v", err) + } + + if age := c.Age("test-key"); age < 0 { + t.Errorf("expected age >= 0, got %v", age) + } +} - // Test Age - age := c.Age(key) - if age < 0 { - t.Error("expected age >= 0") +func TestCache_NilReceiver_Good(t *testing.T) { + var c *cache.Cache + var target map[string]string + + if _, err := c.Path("x"); err == nil { + t.Fatal("expected Path to fail on nil receiver") } - // Test Delete - if err := c.Delete(key); err != nil { - t.Errorf("Delete failed: %v", err) + if _, err := c.Get("x", &target); err == nil { + t.Fatal("expected Get to fail on nil receiver") } - found, err = c.Get(key, &retrieved) + + if err := c.Set("x", map[string]string{"foo": "bar"}); err == nil { + t.Fatal("expected Set to fail on nil receiver") + } + + if err := c.Delete("x"); err == nil { + t.Fatal("expected Delete to fail on nil receiver") + } + + if err := c.Clear(); err == nil { + t.Fatal("expected Clear to fail on nil receiver") + } + + if age := c.Age("x"); age != -1 { + t.Fatalf("expected Age to return -1 on nil receiver, got %v", age) + } +} + +func TestCache_ZeroValue_Ugly(t *testing.T) { + var c cache.Cache + var target map[string]string + + if _, err := c.Path("x"); err == nil { + t.Fatal("expected Path to fail on zero-value cache") + } + + if _, err := c.Get("x", &target); err == nil { + t.Fatal("expected Get to fail on zero-value cache") + } + + if err := c.Set("x", map[string]string{"foo": "bar"}); err == nil { + t.Fatal("expected Set to fail on zero-value cache") + } + + if err := c.Delete("x"); err == nil { + t.Fatal("expected Delete to fail on zero-value cache") + } + + if err := c.Clear(); err == nil { + t.Fatal("expected Clear to fail on zero-value cache") + } + + if age := c.Age("x"); age != -1 { + t.Fatalf("expected Age to return -1 on zero-value cache, 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.Errorf("Get after delete returned an unexpected error: %v", err) + t.Fatalf("Get after delete returned an unexpected error: %v", err) } if found { t.Error("expected item to be deleted") } +} + +func TestCache_DeleteMany_Good(t *testing.T) { + c, _ := newTestCache(t, "/tmp/cache-delete-many", time.Minute) + data := map[string]string{"foo": "bar"} + + if err := c.Set("key1", data); err != nil { + t.Fatalf("Set failed for key1: %v", err) + } + if err := c.Set("key2", data); err != nil { + t.Fatalf("Set failed for key2: %v", err) + } + if err := c.DeleteMany("key1", "missing", "key2"); err != nil { + t.Fatalf("DeleteMany failed: %v", err) + } - // Test Expiry - cshort, err := cache.New(m, "/tmp/cache-short", 10*time.Millisecond) + var retrieved map[string]string + found, err := c.Get("key1", &retrieved) if err != nil { - t.Fatalf("failed to create short-lived cache: %v", err) + t.Fatalf("Get after DeleteMany returned an unexpected error: %v", err) } - if err := cshort.Set(key, data); err != nil { - t.Fatalf("Set for expiry test failed: %v", err) + if found { + t.Error("expected key1 to be deleted") } - time.Sleep(50 * time.Millisecond) - found, err = cshort.Get(key, &retrieved) + + found, err = c.Get("key2", &retrieved) if err != nil { - t.Errorf("Get for expired item returned an unexpected error: %v", err) + t.Fatalf("Get after DeleteMany returned an unexpected error: %v", err) } if found { - t.Error("expected item to be expired") + t.Error("expected key2 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) } @@ -81,49 +278,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/docs/api-contract.md b/docs/api-contract.md new file mode 100644 index 0000000..e92dbc2 --- /dev/null +++ b/docs/api-contract.md @@ -0,0 +1,29 @@ +--- +title: API Contract +description: Exported API contract for dappco.re/go/core/cache. +--- + +# API Contract + +This table lists every exported constant, type, function, and method in +`dappco.re/go/core/cache`. + +`Test coverage` is `yes` when the export is directly exercised by +`cache_test.go`. `Usage-example comment` is `yes` only when the symbol has its +own usage example in a doc comment or Go example test. + +| Name | Signature | Package Path | Description | Test Coverage | Usage-Example Comment | +|------|-----------|--------------|-------------|---------------|-----------------------| +| `DefaultTTL` | `const DefaultTTL = 1 * time.Hour` | `dappco.re/go/core/cache` | Default cache expiry time. | no | no | +| `Cache` | `type Cache struct { /* unexported fields */ }` | `dappco.re/go/core/cache` | File-based cache handle. | yes | no | +| `Entry` | `type Entry struct { Data json.RawMessage; CachedAt time.Time; ExpiresAt time.Time }` | `dappco.re/go/core/cache` | Cached item envelope with payload and timestamps. | no | no | +| `New` | `func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error)` | `dappco.re/go/core/cache` | Creates a cache instance, applying default medium, base directory, and TTL when zero-valued inputs are provided. | yes | no | +| `(*Cache).Path` | `func (c *Cache) Path(key string) (string, error)` | `dappco.re/go/core/cache` | Returns the full path for a cache key and rejects path traversal. | yes | no | +| `(*Cache).Get` | `func (c *Cache) Get(key string, dest any) (bool, error)` | `dappco.re/go/core/cache` | Retrieves a cached item if it exists and has not expired. | yes | no | +| `(*Cache).Set` | `func (c *Cache) Set(key string, data any) error` | `dappco.re/go/core/cache` | Stores an item in the cache. | yes | no | +| `(*Cache).Delete` | `func (c *Cache) Delete(key string) error` | `dappco.re/go/core/cache` | Removes an item from the cache. | yes | no | +| `(*Cache).DeleteMany` | `func (c *Cache) DeleteMany(keys ...string) error` | `dappco.re/go/core/cache` | Removes several items from the cache in one call. | yes | no | +| `(*Cache).Clear` | `func (c *Cache) Clear() error` | `dappco.re/go/core/cache` | Removes all cached items. | yes | no | +| `(*Cache).Age` | `func (c *Cache) Age(key string) time.Duration` | `dappco.re/go/core/cache` | Returns how old a cached item is, or `-1` if it is not cached. | yes | no | +| `GitHubReposKey` | `func GitHubReposKey(org string) string` | `dappco.re/go/core/cache` | Returns the cache key for an organization's repo list. | yes | no | +| `GitHubRepoKey` | `func GitHubRepoKey(org, repo string) string` | `dappco.re/go/core/cache` | Returns the cache key for a specific repo's metadata. | yes | no | diff --git a/docs/architecture.md b/docs/architecture.md index b2199b0..d445611 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -136,6 +136,8 @@ Key behaviours: - **`Delete(key)`** removes a single entry. If the file does not exist, the operation succeeds silently. +- **`DeleteMany(keys...)`** removes several entries in one call and ignores + missing files, using the same per-key path validation as `Delete()`. - **`Clear()`** calls `medium.DeleteAll(baseDir)`, removing the entire cache directory and all its contents. @@ -162,11 +164,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") } ``` @@ -178,7 +180,7 @@ 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: ```go -if !strings.HasPrefix(absPath, absBase) { +if !core.HasPrefix(absPath, absBase+pathSeparator()) && absPath != absBase { return "", coreerr.E("cache.Path", "invalid cache key: path traversal attempt", nil) } ``` diff --git a/docs/security-attack-vector-mapping.md b/docs/security-attack-vector-mapping.md new file mode 100644 index 0000000..852902e --- /dev/null +++ b/docs/security-attack-vector-mapping.md @@ -0,0 +1,29 @@ +# Security Attack Vector Mapping + +Scope: `dappco.re/go/core/cache` public API and backend read paths in `cache.go`. This package exposes a library surface only; it has no HTTP handlers or CLI argument parsing in-repo. + +| Function | File:line | Input source | Flows into | Current validation | Potential attack vector | +| --- | --- | --- | --- | --- | --- | +| `New` | `cache.go:36` | `medium` constructor argument from the consumer | Stored on `Cache.medium`; used immediately by `medium.EnsureDir(baseDir)` and later by all `Read`/`Write`/`Delete` calls | Only `nil` is replaced with `coreio.Local`; no capability or sandbox check in this package | Backend policy bypass. A caller can supply an unsafe medium, and `nil` falls back to unsandboxed local filesystem access (`io.Local` is rooted at `/`), increasing the impact of later key or `baseDir` misuse. | +| `New` | `cache.go:36` | `baseDir` constructor argument from the consumer | `medium.EnsureDir(baseDir)`; persisted on `Cache.baseDir`; later consumed by `Path` and `Clear` | Empty string defaults to `filepath.Join(cwd, ".core", "cache")`; otherwise no normalization, allowlist, or sandbox enforcement in this package | Arbitrary path selection. If `baseDir` is user-controlled or misconfigured, cache reads/writes/deletes can be redirected to attacker-chosen locations. With default `io.Local`, `Clear` can recurse-delete arbitrary directories other than `/` and `$HOME`, and `Set` can write cache JSON into unexpected filesystem locations. | +| `New` | `cache.go:41` | Process working directory from `os.Getwd()` when `baseDir == ""` | `filepath.Join(cwd, ".core", "cache")` | No validation beyond `Getwd` succeeding | Environment-controlled cache placement. Running the consumer from an attacker-influenced working directory redirects cache storage into that tree, which can expose data to other users/processes or alter which cache is later cleared. | +| `New` | `cache.go:36` | `ttl` constructor argument from the consumer | Stored on `Cache.ttl`; later used by `time.Now().Add(c.ttl)` in `Set` | Only `0` is replaced with `DefaultTTL`; negative or very large durations are accepted | Availability and data-staleness abuse. Negative TTL values force immediate misses; very large TTLs preserve stale or poisoned cache content longer than intended. | +| `Path` | `cache.go:68` | `key` method argument from the caller | `filepath.Join(c.baseDir, key+".json")`; returned path is later consumed by medium operations | Resolves `absBase` and `absPath` and rejects results outside `baseDir` prefix | Direct `../` traversal is blocked, but long or deeply nested keys can still create path-length issues, inode/file-count exhaustion, or namespace confusion within `baseDir`. Dot-segments and separators are normalized, which can collapse distinct logical keys into the same on-disk path. | +| `Get` | `cache.go:89` | `key` method argument from the caller | `Path(key)` then `c.medium.Read(path)` | Inherits `Path` traversal guard | Cache oracle and cross-tenant read risk inside the allowed namespace. An attacker who can choose keys can probe for existence/timing of other entries in a shared cache or read another principal's cached object if the consumer does not namespace keys. | +| `Get` | `cache.go:95` | Backend content returned by `c.medium.Read(path)` | `json.Unmarshal([]byte(dataStr), &entry)`, expiry check, then `json.Unmarshal(entry.Data, dest)` | Missing files become cache misses; invalid envelope JSON becomes a cache miss; there is no size limit, schema check, or integrity/authenticity check | Malicious or compromised storage can feed oversized JSON for memory/CPU exhaustion, forge `ExpiresAt` far into the future to keep poisoned data live, or substitute crafted `data` payloads that alter downstream program behavior after unmarshal. | +| `Get` | `cache.go:89` | `dest` method argument from the caller | `json.Unmarshal(entry.Data, dest)` | Relies entirely on Go's JSON decoder and the caller-provided destination type | Type-driven resource abuse or logic confusion. If storage is attacker-controlled, decoding into permissive targets such as `map[string]any`, slices, or interfaces can trigger large allocations or smuggle unexpected structure into the consumer. | +| `Set` | `cache.go:123` | `key` method argument from the caller | `Path(key)`, `EnsureDir(filepath.Dir(path))`, then `Write(path, string(entryBytes))` | Inherits `Path` traversal guard | Namespace collision or storage exhaustion inside `baseDir`. An attacker-controlled key can create many directories/files, overwrite another tenant's cache entry, or consume disk/inodes within the permitted cache root. | +| `Set` | `cache.go:123` | `data` method argument from the caller | `json.Marshal(data)` into `Entry.Data`, then `json.MarshalIndent(entry)` and `c.medium.Write(path, string(entryBytes))` | Only successful JSON marshaling is required; no content, sensitivity, or size validation | Large or adversarial objects can consume CPU/memory during marshal and write. Sensitive data is stored as plaintext JSON, and with the default local backend the write path uses default file mode `0644`, creating local disclosure risk for cache contents. | +| `Delete` | `cache.go:158` | `key` method argument from the caller | `Path(key)` then `c.medium.Delete(path)` | Inherits `Path` traversal guard; `os.ErrNotExist` is ignored | Attacker-chosen eviction of entries inside `baseDir`. In a shared cache namespace this enables targeted cache invalidation or poisoning by deleting another principal's cached item. | +| `Clear` | `cache.go:175` | `c.baseDir` set earlier by constructor input/environment | `c.medium.DeleteAll(c.baseDir)` | No validation at call time in this package | Destructive recursive delete. If `baseDir` is user-controlled or misconfigured, `Clear` removes whatever tree the medium resolves that path to. With default unsandboxed `io.Local`, only `/` and `$HOME` are explicitly protected in the backend, leaving other directories in scope. | +| `Age` | `cache.go:183` | `key` method argument from the caller | `Path(key)` then `c.medium.Read(path)` | Inherits `Path` traversal guard; any error returns `-1` | Metadata oracle within `baseDir`. An attacker can probe whether specific keys exist and silently suppress backend/path failures because all errors collapse to `-1`. | +| `Age` | `cache.go:189` | Backend content returned by `c.medium.Read(path)` | `json.Unmarshal([]byte(dataStr), &entry)` then `time.Since(entry.CachedAt)` | Invalid JSON returns `-1`; no size limit or timestamp sanity check | Malicious storage can return oversized JSON for resource exhaustion or forge timestamps, producing misleading negative or extreme ages that can distort caller refresh decisions. | +| `GitHubReposKey` | `cache.go:205` | `org` argument from the caller | `filepath.Join("github", org, "repos")`, typically later consumed as a cache key by `Path`/`Set`/`Get` | No validation | Key normalization and collision risk. Inputs containing separators or dot-segments are normalized by `filepath.Join`, so unexpected values can collapse into another logical cache key. Direct traversal only gets blocked later if the resulting key reaches `Path`. | +| `GitHubRepoKey` | `cache.go:210` | `org` argument from the caller | `filepath.Join("github", org, repo, "meta")` | No validation | Same collision/normalization issue as `GitHubReposKey`; a crafted org component can collapse onto another key path before the cache methods apply traversal checks. | +| `GitHubRepoKey` | `cache.go:210` | `repo` argument from the caller | `filepath.Join("github", org, repo, "meta")` | No validation | Same collision/normalization issue as the org input; crafted repo names containing separators or dot-segments can steer multiple logical repos onto the same cache key. | + +## Notes + +- The package's strongest built-in control is the path-traversal guard in `Cache.Path()`. It protects `Get`, `Set`, `Delete`, and `Age` against simple `../` escapes relative to `baseDir`. +- The highest-impact residual risk is not `key` traversal but unchecked control over `baseDir` and backend choice in `New()`, especially because the default `coreio.Local` medium is unsandboxed. +- Read-side trust is weak by design: cache files are accepted without integrity protection, size limits, or schema validation, so any actor that can modify the backing medium can turn the cache into a poisoning or denial-of-service surface. diff --git a/go.mod b/go.mod index c7424fc..13e3c91 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 +require dappco.re/go/core/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=