zsvo/pkg/cache/package_index.go
2026-03-15 17:24:01 +03:00

286 lines
6.5 KiB
Go

package cache
import (
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// PackageIndex represents a cached index of Debian packages
type PackageIndex struct {
mu sync.RWMutex
packages map[string]*PackageEntry
binaries map[string]string // binary package -> source package mapping
lastUpdate time.Time
version string
}
// PackageEntry represents a single package entry in the index
type PackageEntry struct {
Package string
Version string
Directory string
DSCName string
DSCSHA256 string
Binaries []string
BuildDepends []string
}
// IndexCache manages persistent package indices
type IndexCache struct {
cacheDir string
indices map[string]*PackageIndex // key: mirror:suite:component
mu sync.RWMutex
}
// NewIndexCache creates a new index cache manager
func NewIndexCache(cacheDir string) *IndexCache {
if cacheDir == "" {
cacheDir = defaultCacheDir()
}
return &IndexCache{
cacheDir: cacheDir,
indices: make(map[string]*PackageIndex),
}
}
// GetIndex retrieves or creates a package index for the given mirror/suite/component
func (c *IndexCache) GetIndex(mirror, suite, component string) (*PackageIndex, error) {
key := fmt.Sprintf("%s:%s:%s", mirror, suite, component)
// Fast path: check if already loaded
c.mu.RLock()
if idx, exists := c.indices[key]; exists {
c.mu.RUnlock()
return idx, nil
}
c.mu.RUnlock()
// Slow path: load from disk or create new
c.mu.Lock()
defer c.mu.Unlock()
// Double-check after acquiring write lock
if idx, exists := c.indices[key]; exists {
return idx, nil
}
// Try to load from disk
cacheFile := c.cacheFilePath(mirror, suite, component)
if idx, err := c.loadIndexFromFile(cacheFile); err == nil {
c.indices[key] = idx
return idx, nil
}
// Create new empty index
idx := &PackageIndex{
packages: make(map[string]*PackageEntry),
binaries: make(map[string]string),
lastUpdate: time.Time{},
version: "1.0",
}
c.indices[key] = idx
return idx, nil
}
// LookupPackage finds a package by name in the index (O(1) lookup)
func (idx *PackageIndex) LookupPackage(name string) (*PackageEntry, bool) {
idx.mu.RLock()
defer idx.mu.RUnlock()
entry, exists := idx.packages[name]
return entry, exists
}
// LookupBinary finds a binary package and returns its source package
func (idx *PackageIndex) LookupBinary(binaryName string) (string, bool) {
idx.mu.RLock()
defer idx.mu.RUnlock()
source, exists := idx.binaries[binaryName]
return source, exists
}
// AddPackage adds a package to the index
func (idx *PackageIndex) AddPackage(entry *PackageEntry) {
idx.mu.Lock()
defer idx.mu.Unlock()
idx.packages[entry.Package] = entry
// Mark index as updated so IsExpired() works correctly and
// we don't re-parse Sources.xz on every program run.
idx.lastUpdate = time.Now()
// Index binaries for fast lookup
for _, binary := range entry.Binaries {
if binary != "" && binary != entry.Package {
idx.binaries[binary] = entry.Package
}
}
}
// Save persists the index to disk
func (idx *PackageIndex) Save(cacheFile string) error {
idx.mu.RLock()
defer idx.mu.RUnlock()
// Ensure cache directory exists
if err := os.MkdirAll(filepath.Dir(cacheFile), 0755); err != nil {
return err
}
// Create temporary file
tempFile := cacheFile + ".tmp"
f, err := os.Create(tempFile)
if err != nil {
return err
}
defer f.Close()
// Write JSON
encoder := json.NewEncoder(f)
encoder.SetIndent("", " ")
data := struct {
Packages map[string]*PackageEntry
Binaries map[string]string
LastUpdate time.Time
Version string
}{
Packages: idx.packages,
Binaries: idx.binaries,
LastUpdate: time.Now(),
Version: idx.version,
}
if err := encoder.Encode(data); err != nil {
os.Remove(tempFile)
return err
}
// Atomic rename
if err := os.Rename(tempFile, cacheFile); err != nil {
os.Remove(tempFile)
return err
}
return nil
}
// IsExpired checks if the cache is older than maxAge
func (idx *PackageIndex) IsExpired(maxAge time.Duration) bool {
idx.mu.RLock()
defer idx.mu.RUnlock()
return time.Since(idx.lastUpdate) > maxAge
}
// GetStats returns index statistics
func (idx *PackageIndex) GetStats() (packageCount int, binaryCount int) {
idx.mu.RLock()
defer idx.mu.RUnlock()
return len(idx.packages), len(idx.binaries)
}
// loadIndexFromFile loads an index from disk
func (c *IndexCache) loadIndexFromFile(cacheFile string) (*PackageIndex, error) {
f, err := os.Open(cacheFile)
if err != nil {
return nil, err
}
defer f.Close()
var data struct {
Packages map[string]*PackageEntry
Binaries map[string]string
LastUpdate time.Time
Version string
}
if err := json.NewDecoder(f).Decode(&data); err != nil {
return nil, err
}
idx := &PackageIndex{
packages: data.Packages,
binaries: data.Binaries,
lastUpdate: data.LastUpdate,
version: data.Version,
}
// Validate integrity
if idx.packages == nil || idx.binaries == nil {
return nil, fmt.Errorf("invalid index file: missing maps")
}
return idx, nil
}
// CacheFilePath returns the cache file path for given parameters (public method)
func (c *IndexCache) CacheFilePath(mirror, suite, component string) string {
return c.cacheFilePath(mirror, suite, component)
}
// cacheFilePath returns the cache file path for given parameters
func (c *IndexCache) cacheFilePath(mirror, suite, component string) string {
// Create safe filename from mirror URL
mirrorHash := sha256.Sum256([]byte(mirror))
mirrorShort := fmt.Sprintf("%x", mirrorHash)[:8]
filename := fmt.Sprintf("package_index_%s_%s_%s.json", mirrorShort, suite, component)
return filepath.Join(c.cacheDir, filename)
}
// defaultCacheDir returns the default cache directory
func defaultCacheDir() string {
if dir := os.Getenv("ZSVO_CACHE"); dir != "" {
return dir
}
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".cache", "zsvo")
}
return "/var/cache/zsvo"
}
// Clear removes all cached indices
func (c *IndexCache) Clear() error {
c.mu.Lock()
defer c.mu.Unlock()
c.indices = make(map[string]*PackageIndex)
return os.RemoveAll(c.cacheDir)
}
// CleanupExpired removes expired cache files
func (c *IndexCache) CleanupExpired(maxAge time.Duration) error {
entries, err := os.ReadDir(c.cacheDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
cutoff := time.Now().Add(-maxAge)
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
os.Remove(filepath.Join(c.cacheDir, entry.Name()))
}
}
return nil
}