diff --git a/cache.go b/cache.go index f532b47..ec98738 100644 --- a/cache.go +++ b/cache.go @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: EUPL-1.2 + // Package cache provides a storage-agnostic, JSON-based cache backed by any io.Medium. package cache @@ -5,18 +7,25 @@ import ( "encoding/json" "errors" "os" - "path/filepath" - "strings" "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. +// +// Usage example: +// +// c, err := cache.New(coreio.NewMockMedium(), "/tmp/cache", time.Minute) type Cache struct { medium coreio.Medium baseDir string @@ -24,6 +33,10 @@ type Cache struct { } // Entry represents a cached item with metadata. +// +// Usage example: +// +// entry := cache.Entry{CachedAt: time.Now(), ExpiresAt: time.Now().Add(time.Minute)} type Entry struct { Data json.RawMessage `json:"data"` CachedAt time.Time `json:"cached_at"` @@ -33,6 +46,10 @@ type Entry struct { // New creates a new cache instance. // If medium is nil, uses coreio.Local (filesystem). // If baseDir is empty, uses .core/cache in current directory. +// +// Usage example: +// +// c, err := cache.New(coreio.NewMockMedium(), "/tmp/cache", 30*time.Minute) func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error) { if medium == nil { medium = coreio.Local @@ -44,7 +61,7 @@ func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error if err != nil { return nil, coreerr.E("cache.New", "failed to get working directory", err) } - baseDir = filepath.Join(cwd, ".core", "cache") + baseDir = core.Path(cwd, ".core", "cache") } if ttl == 0 { @@ -65,20 +82,24 @@ func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error // Path returns the full path for a cache key. // Returns an error if the key attempts path traversal. +// +// Usage example: +// +// path, err := c.Path("github/acme/repos") func (c *Cache) Path(key string) (string, error) { - path := filepath.Join(c.baseDir, key+".json") + path := joinPath(c.baseDir, key+".json") // Ensure the resulting path is still within baseDir to prevent traversal attacks - absBase, err := filepath.Abs(c.baseDir) + absBase, err := pathAbs(c.baseDir) if err != nil { return "", coreerr.E("cache.Path", "failed to get absolute path for baseDir", err) } - absPath, err := filepath.Abs(path) + absPath, err := pathAbs(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 { + if !core.HasPrefix(absPath, absBase+pathSeparator()) && absPath != absBase { return "", coreerr.E("cache.Path", "invalid cache key: path traversal attempt", nil) } @@ -86,6 +107,10 @@ func (c *Cache) Path(key string) (string, error) { } // Get retrieves a cached item if it exists and hasn't expired. +// +// Usage example: +// +// found, err := c.Get("session/user-42", &dest) func (c *Cache) Get(key string, dest any) (bool, error) { path, err := c.Path(key) if err != nil { @@ -120,6 +145,10 @@ func (c *Cache) Get(key string, dest any) (bool, error) { } // Set stores an item in the cache. +// +// Usage example: +// +// err := c.Set("session/user-42", map[string]string{"name": "Ada"}) func (c *Cache) Set(key string, data any) error { path, err := c.Path(key) if err != nil { @@ -127,7 +156,7 @@ func (c *Cache) Set(key string, data any) error { } // Ensure parent directory exists - if err := c.medium.EnsureDir(filepath.Dir(path)); err != nil { + if err := c.medium.EnsureDir(core.PathDir(path)); err != nil { return coreerr.E("cache.Set", "failed to create directory", err) } @@ -155,6 +184,10 @@ func (c *Cache) Set(key string, data any) error { } // Delete removes an item from the cache. +// +// Usage example: +// +// err := c.Delete("session/user-42") func (c *Cache) Delete(key string) error { path, err := c.Path(key) if err != nil { @@ -172,6 +205,10 @@ func (c *Cache) Delete(key string) error { } // Clear removes all cached items. +// +// Usage example: +// +// 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) @@ -180,6 +217,10 @@ func (c *Cache) Clear() error { } // Age returns how old a cached item is, or -1 if not cached. +// +// Usage example: +// +// age := c.Age("session/user-42") func (c *Cache) Age(key string) time.Duration { path, err := c.Path(key) if err != nil { @@ -202,11 +243,52 @@ 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: +// +// 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. +// +// Usage example: +// +// 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 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 + } + + cwd, err := os.Getwd() + if err != nil { + return "", err + } + + return core.Path(cwd, path), nil +} + +func normalizePath(path string) string { + if pathSeparator() == "/" { + return path + } + return core.Replace(path, "/", pathSeparator()) +} + +func pathSeparator() string { + sep := core.Env("DS") + if sep == "" { + return "/" + } + return sep } diff --git a/cache_test.go b/cache_test.go index c33996e..15d122b 100644 --- a/cache_test.go +++ b/cache_test.go @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: EUPL-1.2 + package cache_test import ( diff --git a/docs/architecture.md b/docs/architecture.md index b2199b0..0f9f619 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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") } ``` @@ -178,7 +178,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/go.mod b/go.mod index c7424fc..495911f 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module dappco.re/go/core/cache go 1.26.0 require ( + dappco.re/go/core v0.6.0 dappco.re/go/core/io v0.2.0 dappco.re/go/core/log v0.1.0 ) diff --git a/go.sum b/go.sum index 76d01ec..3a59267 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +dappco.re/go/core v0.6.0 h1:0wmuO/UmCWXxJkxQ6XvVLnqkAuWitbd49PhxjCsplyk= +dappco.re/go/core v0.6.0/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=