[agent/codex] Fix ALL findings from issue #4. Read CLAUDE.md first. AX: fi... #9
5 changed files with 100 additions and 13 deletions
102
cache.go
102
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package cache_test
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
```
|
||||
|
|
|
|||
1
go.mod
1
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
|
||||
)
|
||||
|
|
|
|||
2
go.sum
2
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=
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue