Merge pull request 'dev' (#16) from dev into main
Some checks failed
CI / test (push) Failing after 3s
CI / auto-fix (push) Failing after 0s
CI / auto-merge (push) Failing after 0s

Reviewed-on: #16
This commit is contained in:
Snider 2026-03-23 20:40:23 +00:00
commit 4d8afbcf68
7 changed files with 157 additions and 13 deletions

102
cache.go
View file

@ -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
}

View file

@ -1,3 +1,5 @@
// SPDX-License-Identifier: EUPL-1.2
package cache_test
import (

28
docs/api-contract.md Normal file
View file

@ -0,0 +1,28 @@
---
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).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 |

View file

@ -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)
}
```

View file

@ -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.

1
go.mod
View file

@ -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
View file

@ -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=