Files
kandev/apps/backend/internal/system/storage/gocache/provider.go
T
2026-09-23 13:45:37 +01:00

586 lines
18 KiB
Go

// Package gocache owns Kandev's opt-in local Go build cache.
package gocache
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"time"
"github.com/google/uuid"
"github.com/kandev/kandev/internal/system/storage"
"github.com/kandev/kandev/internal/system/storage/filescan"
)
var (
// ErrNotOwned is returned when the managed path lacks Kandev's marker.
ErrNotOwned = errors.New("go cache is not owned by Kandev")
ErrAdoptionConfirmation = errors.New("go cache adoption requires ADOPT confirmation")
)
const (
markerName = ".go-build.kandev-owned"
markerContent = "kandev-managed-go-cache\n"
)
// SettingsSource returns the persisted install-wide storage settings.
type SettingsSource interface {
GetSettings(ctx context.Context) (storage.StorageMaintenanceSettings, error)
}
// QuarantineStore persists cache rotations before filesystem mutation.
type QuarantineStore interface {
CreateQuarantineEntry(ctx context.Context, entry *storage.QuarantineEntry) error
TransitionQuarantineEntry(ctx context.Context, id string, next storage.QuarantineState, lastError string) (storage.QuarantineEntry, error)
ListQuarantineEntries(ctx context.Context, includeTerminal bool) ([]storage.QuarantineEntry, error)
}
// Config contains the provider's install-owned paths and persistence dependencies.
type Config struct {
HomeDir string
TrashDir string
Settings SettingsSource
Store QuarantineStore
Scanner *filescan.Limiter
OnProgress func(filescan.Progress)
}
// Provider manages the single Go cache selected by persisted settings.
type Provider struct {
config Config
}
// Analysis describes the configured cache without changing it.
type Analysis struct {
Path string `json:"path"`
SizeBytes int64 `json:"size_bytes"`
Owned bool `json:"owned"`
Enabled bool `json:"enabled"`
UnmanagedPath string `json:"unmanaged_path,omitempty"`
// A nil size means that the distinct user cache was not measured. A pointer
// preserves an explicitly measured zero in the successful response.
UnmanagedSizeBytes *int64 `json:"unmanaged_size_bytes,omitempty"`
}
// CleanupResult describes one cache rotation.
type CleanupResult struct {
Path string `json:"path"`
Skipped bool `json:"skipped"`
Reason string `json:"reason,omitempty"`
BytesBefore int64 `json:"bytes_before"`
BytesAfter int64 `json:"bytes_after"`
ReclaimedBytes int64 `json:"reclaimed_bytes"`
QuarantineEntry *storage.QuarantineEntry `json:"quarantine_entry"`
}
// New creates a managed Go-cache provider.
func New(config Config) *Provider {
return &Provider{config: config}
}
// ExecutionEnvironment returns variables injected into new local executions.
func (p *Provider) ExecutionEnvironment(ctx context.Context) (map[string]string, error) {
settings, err := p.loadSettings(ctx)
if err != nil {
return nil, err
}
if !settings.GoCache.Enabled {
return nil, nil
}
cachePath, adopted, err := p.cachePath(settings)
if err != nil {
return nil, err
}
if err := p.validateCachePath(cachePath); err != nil {
return nil, err
}
if err := os.MkdirAll(cachePath, 0o755); err != nil {
return nil, fmt.Errorf("create managed Go cache: %w", err)
}
if !adopted {
if err := writeMarker(cachePath); err != nil {
return nil, fmt.Errorf("create Go-cache ownership marker: %w", err)
}
}
return map[string]string{"GOCACHE": cachePath}, nil
}
// Analyze reports the selected cache's current usage.
func (p *Provider) Analyze(ctx context.Context) (Analysis, error) {
settings, err := p.loadSettings(ctx)
if err != nil {
return Analysis{}, err
}
cachePath, adopted, err := p.cachePath(settings)
if err != nil {
return Analysis{}, err
}
if err := p.validateCachePath(cachePath); err != nil {
return Analysis{}, err
}
owned := adopted || hasValidMarker(cachePath)
scanner := p.config.Scanner
if scanner == nil {
scanner = filescan.NewLimiter(4)
}
measurementRoots := []filescan.Root{
{
Path: cachePath, MissingOK: true, SymlinkPolicy: filescan.RejectSymlinks,
Exclude: func(path string, _ fs.DirEntry) bool { return path == markerPath(cachePath) },
},
}
unmanagedPath, hasUnmanagedPath := defaultGoCachePath()
if hasUnmanagedPath && unmanagedPath != cachePath {
measurementRoots = append(measurementRoots, filescan.Root{
Path: unmanagedPath, MissingOK: true, SymlinkPolicy: filescan.SkipSymlinks,
})
}
measurements := scanner.Measure(ctx, measurementRoots, p.config.OnProgress)
if len(measurements) != len(measurementRoots) {
return Analysis{}, errors.New("go-cache scanner returned an invalid result")
}
if err := measurements[0].Err; err != nil {
return Analysis{}, fmt.Errorf("measure Go cache: %w", err)
}
analysis := Analysis{
Path: cachePath, SizeBytes: measurements[0].Bytes, Owned: owned, Enabled: settings.GoCache.Enabled,
}
if !hasUnmanagedPath || unmanagedPath == cachePath {
return analysis, nil
}
if err := measurements[1].Err; err != nil {
return Analysis{}, fmt.Errorf("measure Go cache: %w", err)
}
analysis.UnmanagedPath = unmanagedPath
unmanagedSizeBytes := measurements[1].Bytes
analysis.UnmanagedSizeBytes = &unmanagedSizeBytes
return analysis, nil
}
func (p *Provider) AnalyzeWithProgress(
ctx context.Context,
onProgress func(filescan.Progress),
) (Analysis, error) {
copy := *p
copy.config.OnProgress = onProgress
return copy.Analyze(ctx)
}
// MeasurementRoots returns every filesystem root included by Analyze for the
// supplied settings. Callers use these roots to avoid attributing one file to
// multiple storage categories.
func (p *Provider) MeasurementRoots(settings storage.StorageMaintenanceSettings) ([]string, error) {
cachePath, _, err := p.cachePath(settings)
if err != nil {
return nil, err
}
roots := []string{cachePath}
unmanagedPath, hasUnmanagedPath := defaultGoCachePath()
if hasUnmanagedPath && unmanagedPath != cachePath {
roots = append(roots, unmanagedPath)
}
return roots, nil
}
func defaultGoCachePath() (string, bool) {
if configured := os.Getenv("GOCACHE"); configured != "" {
if configured == "off" || !filepath.IsAbs(configured) {
return "", false
}
return filepath.Clean(configured), true
}
cacheDir, err := os.UserCacheDir()
if err != nil || !filepath.IsAbs(cacheDir) {
return "", false
}
return filepath.Join(cacheDir, "go-build"), true
}
// Cleanup rotates an above-threshold cache into Kandev trash.
func (p *Provider) Cleanup(ctx context.Context) (CleanupResult, error) {
return p.cleanup(ctx, false)
}
// CleanupExplicit rotates an above-threshold cache even when scheduled maintenance is disabled.
func (p *Provider) CleanupExplicit(ctx context.Context) (CleanupResult, error) {
return p.cleanup(ctx, true)
}
func (p *Provider) cleanup(ctx context.Context, explicit bool) (CleanupResult, error) {
settings, err := p.loadSettings(ctx)
if err != nil {
return CleanupResult{}, err
}
cachePath, adopted, err := p.cachePath(settings)
if err != nil {
return CleanupResult{}, err
}
result := CleanupResult{Path: cachePath}
if !settings.GoCache.Enabled && !explicit {
return result, nil
}
if err := p.validateCacheAndTrash(cachePath); err != nil {
return result, err
}
if !adopted && !hasValidMarker(cachePath) {
return result, ErrNotOwned
}
result.BytesBefore, err = directorySize(cachePath)
if err != nil {
return result, err
}
if result.BytesBefore <= settings.GoCache.MaxBytes {
result.BytesAfter = result.BytesBefore
return result, nil
}
entry, err := p.rotate(ctx, cachePath, result.BytesBefore, settings.QuarantineRetentionHours, adopted)
if err != nil {
var activeErr *storage.ActiveQuarantineIntentError
if errors.As(err, &activeErr) {
result.BytesAfter = result.BytesBefore
result.Skipped = true
result.Reason = "active_quarantine"
return result, nil
}
return result, err
}
result.QuarantineEntry = entry
result.ReclaimedBytes = result.BytesBefore
return result, nil
}
// ValidateAdoption verifies an explicitly confirmed external cache path.
func (p *Provider) ValidateAdoption(_ context.Context, path, confirmation string) error {
if confirmation != "ADOPT" {
return ErrAdoptionConfirmation
}
if !filepath.IsAbs(path) {
return fmt.Errorf("adopted Go-cache path must be absolute: %q", path)
}
path = filepath.Clean(path)
if path == filepath.VolumeName(path)+string(filepath.Separator) {
return errors.New("filesystem root cannot be adopted as a Go cache")
}
trashRoot := filepath.Clean(p.config.TrashDir)
if !filepath.IsAbs(trashRoot) {
return fmt.Errorf("go-cache trash path must be absolute: %q", trashRoot)
}
if pathsOverlap(path, trashRoot) {
return errors.New("adopted Go cache and Kandev trash must not contain each other")
}
if err := p.validateCacheAndTrash(path); err != nil {
return err
}
return probeAtomicRename(path, trashRoot)
}
func (p *Provider) loadSettings(ctx context.Context) (storage.StorageMaintenanceSettings, error) {
if p.config.Settings == nil {
return storage.StorageMaintenanceSettings{}, errors.New("go-cache settings source is required")
}
settings, err := p.config.Settings.GetSettings(ctx)
if err != nil {
return storage.StorageMaintenanceSettings{}, fmt.Errorf("load Go-cache settings: %w", err)
}
return settings, nil
}
func (p *Provider) cachePath(settings storage.StorageMaintenanceSettings) (string, bool, error) {
path := settings.GoCache.AdoptedPath
adopted := path != ""
if !adopted {
path = filepath.Join(p.config.HomeDir, "cache", "go-build")
}
if !filepath.IsAbs(path) {
return "", false, fmt.Errorf("managed Go-cache path must be absolute: %q", path)
}
return filepath.Clean(path), adopted, nil
}
func (p *Provider) rotate(
ctx context.Context,
cachePath string,
sizeBytes int64,
retentionHours int,
adopted bool,
) (*storage.QuarantineEntry, error) {
if p.config.Store == nil {
return nil, errors.New("go-cache quarantine store is required")
}
trashRoot := filepath.Clean(p.config.TrashDir)
if !filepath.IsAbs(trashRoot) {
return nil, fmt.Errorf("go-cache trash path must be absolute: %q", trashRoot)
}
if err := p.validateCacheAndTrash(cachePath); err != nil {
return nil, err
}
quarantineDir := filepath.Join(trashRoot, "go-cache")
anchor, err := storage.CommonPath(p.config.HomeDir, cachePath, trashRoot)
if err != nil {
return nil, err
}
if err := storage.ValidateNoSymlinkPath(anchor, quarantineDir); err != nil {
return nil, fmt.Errorf("validate Go-cache quarantine directory: %w", err)
}
if err := os.MkdirAll(quarantineDir, 0o700); err != nil {
return nil, fmt.Errorf("create Go-cache trash: %w", err)
}
if _, err := storage.ReleaseFailedQuarantineIntent(
ctx, p.config.Store, storage.ResourceTypeGoCache, cachePath,
); err != nil {
return nil, err
}
now := time.Now().UTC()
id := uuid.NewString()
ownership := "managed"
if adopted {
ownership = "adopted"
}
metadata, _ := json.Marshal(map[string]string{"ownership": ownership})
entry := &storage.QuarantineEntry{
ID: id,
ResourceType: storage.ResourceTypeGoCache,
OriginalPath: cachePath,
QuarantinePath: filepath.Join(quarantineDir, id),
SizeBytes: sizeBytes,
State: storage.QuarantineStateQuarantined,
QuarantinedAt: now,
DeleteAfter: now.Add(time.Duration(retentionHours) * time.Hour),
Metadata: metadata,
}
if err := p.config.Store.CreateQuarantineEntry(ctx, entry); err != nil {
return nil, fmt.Errorf("persist Go-cache quarantine intent: %w", err)
}
if err := os.Rename(cachePath, entry.QuarantinePath); err != nil {
_, _ = p.config.Store.TransitionQuarantineEntry(ctx, id, storage.QuarantineStateFailed, err.Error())
return nil, fmt.Errorf("quarantine Go cache: %w", err)
}
if err := p.validateCachePath(cachePath); err != nil {
return nil, fmt.Errorf("validate replacement Go cache: %w", err)
}
if err := os.MkdirAll(cachePath, 0o755); err != nil {
return nil, fmt.Errorf("recreate managed Go cache: %w", err)
}
if !adopted {
if err := writeMarker(cachePath); err != nil {
return nil, fmt.Errorf("restore Go-cache ownership marker: %w", err)
}
}
return entry, nil
}
func writeMarker(cachePath string) error {
marker := markerPath(cachePath)
info, err := os.Lstat(marker)
if err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return ErrNotOwned
}
existing, readErr := os.ReadFile(marker)
if readErr != nil || string(existing) != markerContent {
return ErrNotOwned
}
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("inspect Go-cache ownership marker: %w", err)
}
return os.WriteFile(marker, []byte(markerContent), 0o600)
}
func hasValidMarker(cachePath string) bool {
path := markerPath(cachePath)
info, err := os.Lstat(path)
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return false
}
marker, err := os.ReadFile(path)
return err == nil && string(marker) == markerContent
}
func markerPath(cachePath string) string {
return filepath.Join(cachePath, markerName)
}
// RemoveRestorePlaceholder removes the empty cache directory recreated after
// rotation. Managed caches must contain only their bound ownership marker;
// adopted caches must be completely empty.
func RemoveRestorePlaceholder(cachePath string, adopted bool) (bool, error) {
placeholder, err := IsRestorePlaceholder(cachePath, adopted)
if err != nil || !placeholder {
return placeholder, err
}
if !adopted {
if err := os.Remove(markerPath(cachePath)); err != nil {
return false, fmt.Errorf("remove Go-cache restore marker: %w", err)
}
}
if err := os.Remove(cachePath); err != nil {
return false, fmt.Errorf("remove Go-cache restore placeholder: %w", err)
}
return true, nil
}
// IsRestorePlaceholder reports whether cachePath is the empty replacement
// created after a cache rotation, without modifying it.
func IsRestorePlaceholder(cachePath string, adopted bool) (bool, error) {
info, err := os.Lstat(cachePath)
if err != nil {
return false, err
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return false, nil
}
entries, err := os.ReadDir(cachePath)
if err != nil {
return false, fmt.Errorf("inspect Go-cache restore placeholder: %w", err)
}
if adopted {
return len(entries) == 0, nil
}
return len(entries) == 1 && entries[0].Name() == markerName && hasValidMarker(cachePath), nil
}
func (p *Provider) validateCachePath(cachePath string) error {
anchor, err := storage.CommonPath(p.config.HomeDir, cachePath)
if err != nil {
return err
}
if err := storage.ValidateNoSymlinkPath(anchor, cachePath); err != nil {
return fmt.Errorf("validate Go-cache path: %w", err)
}
return rejectSymlink(cachePath)
}
func (p *Provider) validateCacheAndTrash(cachePath string) error {
trashRoot := filepath.Clean(p.config.TrashDir)
anchor, err := storage.CommonPath(p.config.HomeDir, cachePath, trashRoot)
if err != nil {
return err
}
for name, path := range map[string]string{"cache": cachePath, "trash": trashRoot} {
if err := storage.ValidateNoSymlinkPath(anchor, path); err != nil {
return fmt.Errorf("validate Go-cache %s path: %w", name, err)
}
}
return rejectSymlink(cachePath)
}
func rejectSymlink(path string) error {
info, err := os.Lstat(path) // codeql[go/path-injection] path is constrained by ValidateNoSymlinkPath.
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("inspect Go-cache path: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("go-cache path must not be a symlink: %s", path)
}
if !info.IsDir() {
return fmt.Errorf("go-cache path is not a directory: %s", path)
}
return nil
}
func directorySize(root string) (int64, error) {
return directorySizeWithSymlinkPolicy(root, false)
}
func directorySizeNoFollow(root string) (int64, error) {
return directorySizeWithSymlinkPolicy(root, true)
}
func directorySizeWithSymlinkPolicy(root string, skipSymlinks bool) (int64, error) {
if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) {
return 0, nil
} else if err != nil {
return 0, fmt.Errorf("inspect Go-cache path: %w", err)
}
var total int64
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.Type()&os.ModeSymlink != 0 {
if skipSymlinks {
return nil
}
return fmt.Errorf("symlink found in Go cache: %s", entry.Name())
}
if path == markerPath(root) {
return nil
}
if entry.Type().IsRegular() {
info, err := entry.Info()
if err != nil {
return err
}
total += info.Size()
}
return nil
})
if err != nil {
return 0, fmt.Errorf("measure Go cache: %w", err)
}
return total, nil
}
func pathsOverlap(first, second string) bool {
return pathContains(first, second) || pathContains(second, first)
}
func pathContains(parent, child string) bool {
rel, err := filepath.Rel(parent, child)
return err == nil && rel != ".." && !filepath.IsAbs(rel) && !startsWithParent(rel)
}
func startsWithParent(rel string) bool {
return len(rel) >= 3 && rel[:3] == ".."+string(filepath.Separator)
}
func probeAtomicRename(cachePath, trashRoot string) error {
anchor, err := storage.CommonPath(cachePath, trashRoot)
if err != nil {
return err
}
root, err := os.OpenRoot(anchor)
if err != nil {
return fmt.Errorf("open Go-cache adoption root: %w", err)
}
defer func() { _ = root.Close() }()
cacheRel, err := filepath.Rel(anchor, cachePath)
if err != nil {
return fmt.Errorf("resolve Go-cache probe path: %w", err)
}
trashRel, err := filepath.Rel(anchor, trashRoot)
if err != nil {
return fmt.Errorf("resolve Go-cache trash path: %w", err)
}
if err := root.MkdirAll(trashRel, 0o700); err != nil {
return fmt.Errorf("create Go-cache trash: %w", err)
}
probeName := ".kandev-adoption-probe-" + uuid.NewString()
source := filepath.Join(cacheRel, probeName)
probe, err := root.OpenFile(source, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return fmt.Errorf("create adoption probe: %w", err)
}
if err := probe.Close(); err != nil {
_ = root.Remove(source)
return fmt.Errorf("close adoption probe: %w", err)
}
destination := filepath.Join(trashRel, probeName)
if err := root.Rename(source, destination); err != nil {
_ = root.Remove(source)
return fmt.Errorf("adopted Go cache must support atomic rename into Kandev trash: %w", err)
}
if err := root.Remove(destination); err != nil {
return fmt.Errorf("remove adoption probe: %w", err)
}
return nil
}