package models
import (
"fmt"
"regexp"
"runtime"
"strings"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/google/uuid"
"gopkg.in/yaml.v3"
)
type CacheEntry struct {
Key string `yaml:"key"`
Hash []string `yaml:"hash"`
Paths []string `yaml:"paths"`
Fallback bool `yaml:"fallback"`
// 1 is fastest, 19 is smallest, 0 is the zstd default
CompressionLevel int `yaml:"compression-level"`
// on-success is the default, always also saves on failed and timed-out runs
When string `yaml:"when"`
}
type CacheBinding struct {
EntryIndex int
Paths []string
Key string
Hash string
Checksum string
SizeBytes int64
SaveID string
SaveKey string
RestoreID string
RestoreKey string
RestoreName string
CompressionLevel int
When string
}
func (b CacheBinding) SaveOn(failed bool) bool {
return b.When == "always" || !failed
}
func CacheNamespace() string {
return fmt.Sprintf("tar-zstd-v1/%s/%s", runtime.GOOS, runtime.GOARCH)
}
func CacheObjectKey(repoDid, id string) (string, error) {
did, err := syntax.ParseDID(repoDid)
if err != nil {
return "", fmt.Errorf("cache: invalid repository did %q: %w", repoDid, err)
}
parsedID, err := uuid.Parse(id)
if err != nil {
return "", fmt.Errorf("cache: invalid object id %q: %w", id, err)
}
return fmt.Sprintf("objects/%s/%s", did, parsedID), nil
}
// keys become storage paths, so no slashes
var cacheKeyRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
func (c CacheEntry) Validate() error {
if !cacheKeyRe.MatchString(c.Key) {
return fmt.Errorf("cache: invalid key %q (allowed: letters, digits, '.', '_', '-')", c.Key)
}
for _, f := range c.Hash {
// rev-parse needs plain repo-relative paths, not pathspecs
if f == "" || strings.HasPrefix(f, "/") || strings.HasPrefix(f, "..") {
return fmt.Errorf("cache %q: hash path %q is not repo-relative", c.Key, f)
}
if strings.ContainsAny(f, ": \t\n\"'`$\\*?[") || strings.Contains(f, "/../") || strings.HasSuffix(f, "/..") {
return fmt.Errorf("cache %q: hash path %q contains unsupported characters", c.Key, f)
}
}
if len(c.Paths) == 0 {
return fmt.Errorf("cache %q: no paths", c.Key)
}
if c.CompressionLevel < 0 || c.CompressionLevel > 19 {
return fmt.Errorf("cache %q: compression-level %d out of range (1-19)", c.Key, c.CompressionLevel)
}
switch c.When {
case "", "on-success", "always":
default:
return fmt.Errorf("cache %q: when %q is not one of on-success, always", c.Key, c.When)
}
seen := make(map[string]bool, len(c.Paths))
for _, p := range c.Paths {
if p == "" {
return fmt.Errorf("cache %q: empty path", c.Key)
}
if strings.ContainsAny(p, " \t\n\"'`$\\;&|<>(){}") {
return fmt.Errorf("cache %q: path %q contains unsupported characters", c.Key, p)
}
if strings.Contains(p, "..") {
return fmt.Errorf("cache %q: path %q must not contain '..'", c.Key, p)
}
if seen[p] {
return fmt.Errorf("cache %q: duplicate path %q", c.Key, p)
}
seen[p] = true
}
return nil
}
func ParseCacheEntries(raw string) ([]CacheEntry, error) {
var workflow struct {
Cache []CacheEntry `yaml:"cache"`
}
if err := yaml.Unmarshal([]byte(raw), &workflow); err != nil {
return nil, err
}
if err := ValidateCacheEntries(workflow.Cache); err != nil {
return nil, err
}
return workflow.Cache, nil
}
func ValidateCacheEntries(entries []CacheEntry) error {
seen := make(map[string]struct{}, len(entries))
for _, entry := range entries {
if err := entry.Validate(); err != nil {
return err
}
if _, ok := seen[entry.Key]; ok {
return fmt.Errorf("cache: duplicate key %q", entry.Key)
}
seen[entry.Key] = struct{}{}
}
return nil
}