294 lines
6.6 KiB
Go
294 lines
6.6 KiB
Go
// 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"
|
|
"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
|
|
ttl time.Duration
|
|
}
|
|
|
|
// 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"`
|
|
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.
|
|
//
|
|
// 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
|
|
}
|
|
|
|
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)
|
|
}
|
|
baseDir = core.Path(cwd, ".core", "cache")
|
|
}
|
|
|
|
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 &Cache{
|
|
medium: medium,
|
|
baseDir: baseDir,
|
|
ttl: ttl,
|
|
}, nil
|
|
}
|
|
|
|
// 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 := joinPath(c.baseDir, key+".json")
|
|
|
|
// 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 {
|
|
return "", coreerr.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.
|
|
//
|
|
// 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 {
|
|
return false, err
|
|
}
|
|
|
|
dataStr, err := c.medium.Read(path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return false, nil
|
|
}
|
|
return false, coreerr.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
|
|
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)
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
// 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 {
|
|
return err
|
|
}
|
|
|
|
// Ensure parent directory exists
|
|
if err := c.medium.EnsureDir(core.PathDir(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)
|
|
}
|
|
|
|
entry := Entry{
|
|
Data: dataBytes,
|
|
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)
|
|
}
|
|
|
|
if err := c.medium.Write(path, string(entryBytes)); err != nil {
|
|
return coreerr.E("cache.Set", "failed to write cache file", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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 {
|
|
return err
|
|
}
|
|
|
|
err = c.medium.Delete(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return coreerr.E("cache.Delete", "failed to delete cache file", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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 {
|
|
return -1
|
|
}
|
|
|
|
dataStr, err := c.medium.Read(path)
|
|
if err != nil {
|
|
return -1
|
|
}
|
|
|
|
var entry Entry
|
|
if err := json.Unmarshal([]byte(dataStr), &entry); err != nil {
|
|
return -1
|
|
}
|
|
|
|
return time.Since(entry.CachedAt)
|
|
}
|
|
|
|
// 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 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 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
|
|
}
|