Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5eb611f5e2 | ||
|
|
19232c5575 | ||
|
|
248f542b08 | ||
|
|
fbf410e630 | ||
|
|
41150c0548 | ||
|
|
529e60f3ff | ||
|
|
261a7ba950 | ||
|
|
29ec99df12 |
6 changed files with 410 additions and 181 deletions
274
cache.go
274
cache.go
|
|
@ -5,13 +5,11 @@ package cache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"io/fs"
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dappco.re/go/core"
|
"dappco.re/go/core"
|
||||||
coreio "dappco.re/go/core/io"
|
coreio "dappco.re/go/core/io"
|
||||||
coreerr "dappco.re/go/core/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultTTL is the default cache expiry time.
|
// DefaultTTL is the default cache expiry time.
|
||||||
|
|
@ -21,56 +19,50 @@ import (
|
||||||
// c, err := cache.New(coreio.NewMockMedium(), "/tmp/cache", cache.DefaultTTL)
|
// c, err := cache.New(coreio.NewMockMedium(), "/tmp/cache", cache.DefaultTTL)
|
||||||
const DefaultTTL = 1 * time.Hour
|
const DefaultTTL = 1 * time.Hour
|
||||||
|
|
||||||
// Cache represents a file-based cache.
|
// Cache stores JSON-encoded entries in a Medium-backed cache rooted at baseDir.
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// c, err := cache.New(coreio.NewMockMedium(), "/tmp/cache", time.Minute)
|
|
||||||
type Cache struct {
|
type Cache struct {
|
||||||
medium coreio.Medium
|
medium coreio.Medium
|
||||||
baseDir string
|
baseDir string
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entry represents a cached item with metadata.
|
// Entry is the serialized cache record written to the backing Medium.
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// entry := cache.Entry{CachedAt: time.Now(), ExpiresAt: time.Now().Add(time.Minute)}
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
Data json.RawMessage `json:"data"`
|
Data json.RawMessage `json:"data"`
|
||||||
CachedAt time.Time `json:"cached_at"`
|
CachedAt time.Time `json:"cached_at"`
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new cache instance.
|
// New creates a cache and applies default Medium, base directory, and TTL values
|
||||||
// If medium is nil, uses coreio.Local (filesystem).
|
// when callers pass zero values.
|
||||||
// If baseDir is empty, uses .core/cache in current directory.
|
|
||||||
//
|
//
|
||||||
// Usage example:
|
// c, err := cache.New(coreio.Local, "/tmp/cache", time.Hour)
|
||||||
//
|
|
||||||
// c, err := cache.New(coreio.NewMockMedium(), "/tmp/cache", 30*time.Minute)
|
|
||||||
func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error) {
|
func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error) {
|
||||||
if medium == nil {
|
if medium == nil {
|
||||||
medium = coreio.Local
|
medium = coreio.Local
|
||||||
}
|
}
|
||||||
|
|
||||||
if baseDir == "" {
|
if baseDir == "" {
|
||||||
// Use .core/cache in current working directory
|
cwd := currentDir()
|
||||||
cwd, err := os.Getwd()
|
if cwd == "" || cwd == "." {
|
||||||
if err != nil {
|
return nil, core.E("cache.New", "failed to resolve current working directory", nil)
|
||||||
return nil, coreerr.E("cache.New", "failed to get working directory", err)
|
|
||||||
}
|
}
|
||||||
baseDir = core.Path(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 {
|
if ttl == 0 {
|
||||||
ttl = DefaultTTL
|
ttl = DefaultTTL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure cache directory exists
|
|
||||||
if err := medium.EnsureDir(baseDir); err != nil {
|
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{
|
return &Cache{
|
||||||
|
|
@ -80,38 +72,34 @@ func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path returns the full path for a cache key.
|
// Path returns the storage path used for key and rejects path traversal
|
||||||
// Returns an error if the key attempts path traversal.
|
// attempts.
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
//
|
||||||
// path, err := c.Path("github/acme/repos")
|
// path, err := c.Path("github/acme/repos")
|
||||||
func (c *Cache) Path(key string) (string, error) {
|
func (c *Cache) Path(key string) (string, error) {
|
||||||
path := joinPath(c.baseDir, key+".json")
|
if err := c.ensureConfigured("cache.Path"); err != nil {
|
||||||
|
return "", err
|
||||||
// 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 {
|
baseDir := absolutePath(c.baseDir)
|
||||||
return "", coreerr.E("cache.Path", "invalid cache key: path traversal attempt", nil)
|
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
|
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("github/acme/repos", &repos)
|
||||||
//
|
|
||||||
// found, err := c.Get("session/user-42", &dest)
|
|
||||||
func (c *Cache) Get(key string, dest any) (bool, error) {
|
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)
|
path, err := c.Path(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
|
@ -119,109 +107,147 @@ func (c *Cache) Get(key string, dest any) (bool, error) {
|
||||||
|
|
||||||
dataStr, err := c.medium.Read(path)
|
dataStr, err := c.medium.Read(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if core.Is(err, fs.ErrNotExist) {
|
||||||
return false, nil
|
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
|
var entry Entry
|
||||||
if err := json.Unmarshal([]byte(dataStr), &entry); err != nil {
|
entryResult := core.JSONUnmarshalString(dataStr, &entry)
|
||||||
// Invalid cache file, treat as miss
|
if !entryResult.OK {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check expiry
|
|
||||||
if time.Now().After(entry.ExpiresAt) {
|
if time.Now().After(entry.ExpiresAt) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unmarshal the actual data
|
if err := core.JSONUnmarshal(entry.Data, dest); !err.OK {
|
||||||
if err := json.Unmarshal(entry.Data, dest); err != nil {
|
return false, core.E("cache.Get", "failed to unmarshal cached data", err.Value.(error))
|
||||||
return false, coreerr.E("cache.Get", "failed to unmarshal cached data", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set stores an item in the cache.
|
// Set marshals data and stores it in the cache.
|
||||||
//
|
//
|
||||||
// Usage example:
|
// err := c.Set("github/acme/repos", repos)
|
||||||
//
|
|
||||||
// err := c.Set("session/user-42", map[string]string{"name": "Ada"})
|
|
||||||
func (c *Cache) Set(key string, data any) error {
|
func (c *Cache) Set(key string, data any) error {
|
||||||
|
if err := c.ensureReady("cache.Set"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
path, err := c.Path(key)
|
path, err := c.Path(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure parent directory exists
|
|
||||||
if err := c.medium.EnsureDir(core.PathDir(path)); err != nil {
|
if err := c.medium.EnsureDir(core.PathDir(path)); err != nil {
|
||||||
return coreerr.E("cache.Set", "failed to create directory", err)
|
return core.E("cache.Set", "failed to create directory", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Marshal the data
|
dataResult := core.JSONMarshal(data)
|
||||||
dataBytes, err := json.Marshal(data)
|
if !dataResult.OK {
|
||||||
if err != nil {
|
return core.E("cache.Set", "failed to marshal cache data", dataResult.Value.(error))
|
||||||
return coreerr.E("cache.Set", "failed to marshal data", err)
|
}
|
||||||
|
|
||||||
|
ttl := c.ttl
|
||||||
|
if ttl < 0 {
|
||||||
|
return core.E("cache.Set", "cache ttl must be >= 0", nil)
|
||||||
|
}
|
||||||
|
if ttl == 0 {
|
||||||
|
ttl = DefaultTTL
|
||||||
}
|
}
|
||||||
|
|
||||||
entry := Entry{
|
entry := Entry{
|
||||||
Data: dataBytes,
|
Data: dataResult.Value.([]byte),
|
||||||
CachedAt: time.Now(),
|
CachedAt: time.Now(),
|
||||||
ExpiresAt: time.Now().Add(c.ttl),
|
ExpiresAt: time.Now().Add(ttl),
|
||||||
}
|
}
|
||||||
|
|
||||||
entryBytes, err := json.MarshalIndent(entry, "", " ")
|
entryBytes, err := json.MarshalIndent(entry, "", " ")
|
||||||
if err != nil {
|
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 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes an item from the cache.
|
// Delete removes the cached item for key.
|
||||||
//
|
//
|
||||||
// Usage example:
|
// err := c.Delete("github/acme/repos")
|
||||||
//
|
|
||||||
// err := c.Delete("session/user-42")
|
|
||||||
func (c *Cache) Delete(key string) error {
|
func (c *Cache) Delete(key string) error {
|
||||||
|
if err := c.ensureReady("cache.Delete"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
path, err := c.Path(key)
|
path, err := c.Path(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = c.medium.Delete(path)
|
err = c.medium.Delete(path)
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if core.Is(err, fs.ErrNotExist) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err != 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear removes all cached items.
|
// DeleteMany removes several cached items in one call.
|
||||||
//
|
//
|
||||||
// Usage example:
|
// 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 under the cache base directory.
|
||||||
//
|
//
|
||||||
// err := c.Clear()
|
// err := c.Clear()
|
||||||
func (c *Cache) Clear() error {
|
func (c *Cache) Clear() error {
|
||||||
|
if err := c.ensureReady("cache.Clear"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := c.medium.DeleteAll(c.baseDir); err != nil {
|
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
|
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("github/acme/repos")
|
||||||
//
|
|
||||||
// age := c.Age("session/user-42")
|
|
||||||
func (c *Cache) Age(key string) time.Duration {
|
func (c *Cache) Age(key string) time.Duration {
|
||||||
|
if err := c.ensureReady("cache.Age"); err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
path, err := c.Path(key)
|
path, err := c.Path(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return -1
|
return -1
|
||||||
|
|
@ -233,7 +259,8 @@ func (c *Cache) Age(key string) time.Duration {
|
||||||
}
|
}
|
||||||
|
|
||||||
var entry Entry
|
var entry Entry
|
||||||
if err := json.Unmarshal([]byte(dataStr), &entry); err != nil {
|
entryResult := core.JSONUnmarshalString(dataStr, &entry)
|
||||||
|
if !entryResult.OK {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -242,53 +269,80 @@ func (c *Cache) Age(key string) time.Duration {
|
||||||
|
|
||||||
// GitHub-specific cache keys
|
// 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.
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
//
|
||||||
// key := cache.GitHubReposKey("acme")
|
// key := cache.GitHubReposKey("acme")
|
||||||
func GitHubReposKey(org string) string {
|
func GitHubReposKey(org string) string {
|
||||||
return core.JoinPath("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.
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
//
|
||||||
// key := cache.GitHubRepoKey("acme", "widgets")
|
// key := cache.GitHubRepoKey("acme", "widgets")
|
||||||
func GitHubRepoKey(org, repo string) string {
|
func GitHubRepoKey(org, repo string) string {
|
||||||
return core.JoinPath("github", org, repo, "meta")
|
return core.JoinPath("github", org, repo, "meta")
|
||||||
}
|
}
|
||||||
|
|
||||||
func joinPath(segments ...string) string {
|
func pathSeparator() string {
|
||||||
return normalizePath(core.JoinPath(segments...))
|
if ds := core.Env("DS"); ds != "" {
|
||||||
}
|
return ds
|
||||||
|
|
||||||
func pathAbs(path string) (string, error) {
|
|
||||||
path = normalizePath(path)
|
|
||||||
if core.PathIsAbs(path) {
|
|
||||||
return core.CleanPath(path, pathSeparator()), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cwd, err := os.Getwd()
|
return "/"
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return core.Path(cwd, path), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizePath(path string) string {
|
func normalizePath(path string) string {
|
||||||
if pathSeparator() == "/" {
|
ds := pathSeparator()
|
||||||
return path
|
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 {
|
func absolutePath(path string) string {
|
||||||
sep := core.Env("DS")
|
normalized := normalizePath(path)
|
||||||
if sep == "" {
|
if core.PathIsAbs(normalized) {
|
||||||
return "/"
|
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"))
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
303
cache_test.go
303
cache_test.go
|
|
@ -3,79 +3,274 @@
|
||||||
package cache_test
|
package cache_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"dappco.re/go/core"
|
||||||
"dappco.re/go/core/cache"
|
"dappco.re/go/core/cache"
|
||||||
coreio "dappco.re/go/core/io"
|
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()
|
m := coreio.NewMockMedium()
|
||||||
// Use a path that MockMedium will understand
|
c, err := cache.New(m, baseDir, ttl)
|
||||||
baseDir := "/tmp/cache"
|
|
||||||
c, err := cache.New(m, baseDir, 1*time.Minute)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create cache: %v", err)
|
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"
|
key := "test-key"
|
||||||
data := map[string]string{"foo": "bar"}
|
data := map[string]string{"foo": "bar"}
|
||||||
|
|
||||||
// Test Set
|
|
||||||
if err := c.Set(key, data); err != nil {
|
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
|
var retrieved map[string]string
|
||||||
found, err := c.Get(key, &retrieved)
|
found, err := c.Get(key, &retrieved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Get failed: %v", err)
|
t.Fatalf("Get failed: %v", err)
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
t.Error("expected to find cached item")
|
t.Fatal("expected to find cached item")
|
||||||
}
|
}
|
||||||
if retrieved["foo"] != "bar" {
|
if retrieved["foo"] != "bar" {
|
||||||
t.Errorf("expected foo=bar, got %v", retrieved["foo"])
|
t.Errorf("expected foo=bar, got %v", retrieved["foo"])
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Test Age
|
func TestCache_Get_Ugly(t *testing.T) {
|
||||||
age := c.Age(key)
|
c, _ := newTestCache(t, "/tmp/cache-expiry", 10*time.Millisecond)
|
||||||
if age < 0 {
|
|
||||||
t.Error("expected age >= 0")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test Delete
|
if err := c.Set("test-key", map[string]string{"foo": "bar"}); err != nil {
|
||||||
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 {
|
|
||||||
t.Fatalf("Set for expiry test failed: %v", err)
|
t.Fatalf("Set for expiry test failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
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 {
|
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 {
|
if found {
|
||||||
t.Error("expected item to be expired")
|
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_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")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := c.Get("x", &target); err == nil {
|
||||||
|
t.Fatal("expected Get to fail on nil receiver")
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
var retrieved map[string]string
|
||||||
|
found, err := c.Get("key1", &retrieved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get after DeleteMany returned an unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
t.Error("expected key1 to be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
found, err = c.Get("key2", &retrieved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get after DeleteMany returned an unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
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 {
|
if err := c.Set("key1", data); err != nil {
|
||||||
t.Fatalf("Set for clear test failed for key1: %v", err)
|
t.Fatalf("Set for clear test failed for key1: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -83,49 +278,29 @@ func TestCache(t *testing.T) {
|
||||||
t.Fatalf("Set for clear test failed for key2: %v", err)
|
t.Fatalf("Set for clear test failed for key2: %v", err)
|
||||||
}
|
}
|
||||||
if err := c.Clear(); err != nil {
|
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 {
|
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 {
|
if found {
|
||||||
t.Error("expected key1 to be cleared")
|
t.Error("expected key1 to be cleared")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCacheDefaults(t *testing.T) {
|
func TestCache_GitHubReposKey_Good(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) {
|
|
||||||
key := cache.GitHubReposKey("myorg")
|
key := cache.GitHubReposKey("myorg")
|
||||||
if key != "github/myorg/repos" {
|
if key != "github/myorg/repos" {
|
||||||
t.Errorf("unexpected GitHubReposKey: %q", key)
|
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" {
|
if key != "github/myorg/myrepo/meta" {
|
||||||
t.Errorf("unexpected GitHubRepoKey: %q", key)
|
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ own usage example in a doc comment or Go example test.
|
||||||
| `(*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).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).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).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).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 |
|
| `(*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 |
|
| `GitHubReposKey` | `func GitHubReposKey(org string) string` | `dappco.re/go/core/cache` | Returns the cache key for an organization's repo list. | yes | no |
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,8 @@ Key behaviours:
|
||||||
|
|
||||||
- **`Delete(key)`** removes a single entry. If the file does not exist, the
|
- **`Delete(key)`** removes a single entry. If the file does not exist, the
|
||||||
operation succeeds silently.
|
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
|
- **`Clear()`** calls `medium.DeleteAll(baseDir)`, removing the entire cache
|
||||||
directory and all its contents.
|
directory and all its contents.
|
||||||
|
|
||||||
|
|
|
||||||
5
go.mod
5
go.mod
|
|
@ -3,9 +3,8 @@ module dappco.re/go/core/cache
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dappco.re/go/core v0.6.0
|
dappco.re/go/core v0.8.0-alpha.1
|
||||||
dappco.re/go/core/io v0.2.0
|
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
|
||||||
|
|
|
||||||
6
go.sum
6
go.sum
|
|
@ -1,9 +1,7 @@
|
||||||
dappco.re/go/core v0.6.0 h1:0wmuO/UmCWXxJkxQ6XvVLnqkAuWitbd49PhxjCsplyk=
|
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
||||||
dappco.re/go/core v0.6.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
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 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
|
||||||
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
|
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 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
||||||
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
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=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue