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