package crowdsec import ( "context" "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "regexp" "strings" "time" "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/util" ) var ( ErrCacheMiss = errors.New("cache miss") ErrCacheExpired = errors.New("cache expired") ) // CachedPreset captures metadata about a pulled preset bundle. type CachedPreset struct { Slug string `json:"slug"` CacheKey string `json:"cache_key"` Etag string `json:"etag"` Source string `json:"source"` RetrievedAt time.Time `json:"retrieved_at"` PreviewPath string `json:"preview_path"` ArchivePath string `json:"archive_path"` SizeBytes int64 `json:"size_bytes"` } // HubCache persists pulled bundles on disk with TTL-based eviction. type HubCache struct { baseDir string ttl time.Duration nowFn func() time.Time } var slugPattern = regexp.MustCompile(`^[A-Za-z0-9./_-]+$`) // NewHubCache constructs a cache rooted at baseDir with the provided TTL. func NewHubCache(baseDir string, ttl time.Duration) (*HubCache, error) { if baseDir == "" { return nil, fmt.Errorf("baseDir required") } if err := os.MkdirAll(baseDir, 0o755); err != nil { return nil, fmt.Errorf("create cache dir: %w", err) } return &HubCache{baseDir: baseDir, ttl: ttl, nowFn: time.Now}, nil } // TTL returns the configured time-to-live for cached entries. func (c *HubCache) TTL() time.Duration { return c.ttl } // Store writes the bundle archive and preview to disk and returns the cache metadata. func (c *HubCache) Store(ctx context.Context, slug, etag, source, preview string, archive []byte) (CachedPreset, error) { if err := ctx.Err(); err != nil { return CachedPreset{}, err } cleanSlug := sanitizeSlug(slug) if cleanSlug == "" { return CachedPreset{}, fmt.Errorf("invalid slug") } dir := filepath.Join(c.baseDir, cleanSlug) logger.Log().WithField("slug", util.SanitizeForLog(cleanSlug)).WithField("cache_dir", util.SanitizeForLog(dir)).WithField("archive_size", len(archive)).Debug("storing preset in cache") if err := os.MkdirAll(dir, 0o755); err != nil { logger.Log().WithError(err).WithField("dir", util.SanitizeForLog(dir)).Error("failed to create cache directory") return CachedPreset{}, fmt.Errorf("create slug dir: %w", err) } ts := c.nowFn().UTC() cacheKey := fmt.Sprintf("%s-%d", cleanSlug, ts.Unix()) archivePath := filepath.Join(dir, "bundle.tgz") if err := os.WriteFile(archivePath, archive, 0o640); err != nil { return CachedPreset{}, fmt.Errorf("write archive: %w", err) } previewPath := filepath.Join(dir, "preview.yaml") if err := os.WriteFile(previewPath, []byte(preview), 0o640); err != nil { return CachedPreset{}, fmt.Errorf("write preview: %w", err) } meta := CachedPreset{ Slug: cleanSlug, CacheKey: cacheKey, Etag: etag, Source: source, RetrievedAt: ts, PreviewPath: previewPath, ArchivePath: archivePath, SizeBytes: int64(len(archive)), } metaPath := filepath.Join(dir, "metadata.json") raw, err := json.Marshal(meta) if err != nil { return CachedPreset{}, fmt.Errorf("marshal metadata: %w", err) } if err := os.WriteFile(metaPath, raw, 0o640); err != nil { logger.Log().WithError(err).WithField("meta_path", util.SanitizeForLog(metaPath)).Error("failed to write metadata file") return CachedPreset{}, fmt.Errorf("write metadata: %w", err) } logger.Log().WithField("slug", util.SanitizeForLog(cleanSlug)).WithField("cache_key", cacheKey).WithField("archive_path", util.SanitizeForLog(archivePath)).WithField("preview_path", util.SanitizeForLog(previewPath)).WithField("meta_path", util.SanitizeForLog(metaPath)).Info("preset successfully stored in cache") return meta, nil } // Load returns cached preset metadata, enforcing TTL. func (c *HubCache) Load(ctx context.Context, slug string) (CachedPreset, error) { if err := ctx.Err(); err != nil { return CachedPreset{}, err } cleanSlug := sanitizeSlug(slug) if cleanSlug == "" { return CachedPreset{}, fmt.Errorf("invalid slug") } metaPath := filepath.Join(c.baseDir, cleanSlug, "metadata.json") logger.Log().WithField("slug", util.SanitizeForLog(cleanSlug)).WithField("meta_path", util.SanitizeForLog(metaPath)).Debug("attempting to load cached preset") data, err := os.ReadFile(metaPath) if err != nil { if errors.Is(err, os.ErrNotExist) { logger.Log().WithField("slug", util.SanitizeForLog(cleanSlug)).WithField("meta_path", util.SanitizeForLog(metaPath)).Debug("preset not found in cache (cache miss)") return CachedPreset{}, ErrCacheMiss } logger.Log().WithError(err).WithField("slug", util.SanitizeForLog(cleanSlug)).WithField("meta_path", util.SanitizeForLog(metaPath)).Error("failed to read cached preset metadata") return CachedPreset{}, err } var meta CachedPreset if err := json.Unmarshal(data, &meta); err != nil { logger.Log().WithError(err).WithField("slug", util.SanitizeForLog(cleanSlug)).Error("failed to unmarshal cached preset metadata") return CachedPreset{}, fmt.Errorf("unmarshal metadata: %w", err) } if c.ttl > 0 && c.nowFn().After(meta.RetrievedAt.Add(c.ttl)) { logger.Log().WithField("slug", util.SanitizeForLog(cleanSlug)).WithField("retrieved_at", meta.RetrievedAt).WithField("ttl", c.ttl).Debug("cached preset expired") return CachedPreset{}, ErrCacheExpired } logger.Log().WithField("slug", util.SanitizeForLog(meta.Slug)).WithField("cache_key", meta.CacheKey).WithField("archive_path", util.SanitizeForLog(meta.ArchivePath)).Debug("successfully loaded cached preset") return meta, nil } // LoadPreview returns the preview contents for a cached preset. func (c *HubCache) LoadPreview(ctx context.Context, slug string) (string, error) { meta, err := c.Load(ctx, slug) if err != nil { return "", err } data, err := os.ReadFile(meta.PreviewPath) if err != nil { return "", err } return string(data), nil } // List returns cached presets that have not expired. func (c *HubCache) List(ctx context.Context) ([]CachedPreset, error) { if err := ctx.Err(); err != nil { return nil, err } results := make([]CachedPreset, 0) err := filepath.WalkDir(c.baseDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil } if d.IsDir() || d.Name() != "metadata.json" { return nil } rel, err := filepath.Rel(c.baseDir, path) if err != nil { return nil } slug := filepath.Dir(rel) meta, err := c.Load(ctx, slug) if err != nil { return nil } results = append(results, meta) return nil }) if err != nil { return nil, err } return results, nil } // Evict removes cached data for the given slug. func (c *HubCache) Evict(ctx context.Context, slug string) error { if err := ctx.Err(); err != nil { return err } cleanSlug := sanitizeSlug(slug) if cleanSlug == "" { return fmt.Errorf("invalid slug") } return os.RemoveAll(filepath.Join(c.baseDir, cleanSlug)) } // sanitizeSlug guards against traversal and unsupported characters. func sanitizeSlug(slug string) string { trimmed := strings.TrimSpace(slug) if trimmed == "" { return "" } cleaned := filepath.Clean(trimmed) cleaned = strings.ReplaceAll(cleaned, "\\", "/") if strings.HasPrefix(cleaned, "..") || strings.Contains(cleaned, string(os.PathSeparator)+"..") || strings.HasPrefix(cleaned, string(os.PathSeparator)) { return "" } if !slugPattern.MatchString(cleaned) { return "" } return cleaned } // Exists returns true when a non-expired cache entry is present. func (c *HubCache) Exists(ctx context.Context, slug string) bool { if _, err := c.Load(ctx, slug); err == nil { return true } return false } // Touch updates the timestamp to extend TTL; noop when missing. func (c *HubCache) Touch(ctx context.Context, slug string) error { meta, err := c.Load(ctx, slug) if err != nil { return err } meta.RetrievedAt = c.nowFn().UTC() raw, err := json.Marshal(meta) if err != nil { return err } metaPath := filepath.Join(c.baseDir, meta.Slug, "metadata.json") return os.WriteFile(metaPath, raw, 0o640) } // Size returns aggregated size of cached archives (best effort). func (c *HubCache) Size(ctx context.Context) int64 { var total int64 _ = filepath.WalkDir(c.baseDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil } if d.IsDir() { return nil } info, err := d.Info() if err != nil { return nil } total += info.Size() return nil }) return total }