mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* feat: add sandbox DX commands * fix: Linter errors * fix: Sandbox deny log parsing * fix: Sandbox docs * refactor: Maintain SSOT across pkg dependencies * fix: Linter errors
243 lines
5.5 KiB
Go
243 lines
5.5 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ViolationCacheSchemaVersion is bumped when the on-disk JSON layout changes incompatibly.
|
|
const ViolationCacheSchemaVersion = 1
|
|
|
|
// DefaultViolationCacheRetention is the default number of reports retained in the cache.
|
|
const DefaultViolationCacheRetention = 10
|
|
|
|
const (
|
|
violationCacheFilePrefix = "violation-"
|
|
violationCacheFileSuffix = ".json"
|
|
)
|
|
|
|
// ViolationCacheRecord is the on-disk representation of a ViolationReport.
|
|
type ViolationCacheRecord struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
RecordedAt time.Time `json:"recorded_at"`
|
|
Report *ViolationReport `json:"report"`
|
|
}
|
|
|
|
// ViolationCacheEntry is a cache entry returned by readers.
|
|
type ViolationCacheEntry struct {
|
|
Path string
|
|
Record ViolationCacheRecord
|
|
}
|
|
|
|
// ViolationCache writes and reads violation reports under dir.
|
|
type ViolationCache struct {
|
|
dir string
|
|
retention int
|
|
now func() time.Time
|
|
}
|
|
|
|
// ViolationCacheOption customizes a ViolationCache.
|
|
type ViolationCacheOption func(*ViolationCache)
|
|
|
|
// WithRetention overrides the default retention.
|
|
func WithRetention(n int) ViolationCacheOption {
|
|
return func(c *ViolationCache) {
|
|
if n > 0 {
|
|
c.retention = n
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithClock injects a clock for tests.
|
|
func WithClock(now func() time.Time) ViolationCacheOption {
|
|
return func(c *ViolationCache) {
|
|
if now != nil {
|
|
c.now = now
|
|
}
|
|
}
|
|
}
|
|
|
|
// NewViolationCache returns a ViolationCache rooted at dir. The directory is
|
|
// created lazily on Write.
|
|
func NewViolationCache(dir string, opts ...ViolationCacheOption) *ViolationCache {
|
|
c := &ViolationCache{
|
|
dir: dir,
|
|
retention: DefaultViolationCacheRetention,
|
|
now: time.Now,
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(c)
|
|
}
|
|
|
|
return c
|
|
}
|
|
|
|
// Write persists report to the cache and prunes older entries beyond
|
|
// retention. Returns the path of the written file.
|
|
func (c *ViolationCache) Write(report *ViolationReport) (string, error) {
|
|
if report == nil {
|
|
return "", errors.New("violationcache: nil report")
|
|
}
|
|
|
|
if c.dir == "" {
|
|
return "", errors.New("violationcache: empty cache directory")
|
|
}
|
|
|
|
if err := os.MkdirAll(c.dir, 0o755); err != nil {
|
|
return "", fmt.Errorf("violationcache: create dir: %w", err)
|
|
}
|
|
|
|
ts := c.now().UTC()
|
|
id, err := violationCacheShortID()
|
|
if err != nil {
|
|
return "", fmt.Errorf("violationcache: generate id: %w", err)
|
|
}
|
|
|
|
name := fmt.Sprintf("%s%s-%s%s", violationCacheFilePrefix, ts.Format("20060102T150405.000000000Z"), id, violationCacheFileSuffix)
|
|
path := filepath.Join(c.dir, name)
|
|
|
|
rec := ViolationCacheRecord{
|
|
SchemaVersion: ViolationCacheSchemaVersion,
|
|
RecordedAt: ts,
|
|
Report: report,
|
|
}
|
|
|
|
data, err := json.MarshalIndent(rec, "", " ")
|
|
if err != nil {
|
|
return "", fmt.Errorf("violationcache: marshal: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
|
return "", fmt.Errorf("violationcache: write: %w", err)
|
|
}
|
|
|
|
if err := c.prune(); err != nil {
|
|
return path, fmt.Errorf("violationcache: prune: %w", err)
|
|
}
|
|
|
|
return path, nil
|
|
}
|
|
|
|
// List returns entries newest first. Corrupt or unreadable files are skipped.
|
|
func (c *ViolationCache) List() ([]ViolationCacheEntry, error) {
|
|
if c.dir == "" {
|
|
return nil, errors.New("violationcache: empty cache directory")
|
|
}
|
|
|
|
dirents, err := os.ReadDir(c.dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("violationcache: read dir: %w", err)
|
|
}
|
|
|
|
files := violationCacheMatchingFiles(dirents)
|
|
violationCacheSortFilesNewestFirst(files)
|
|
|
|
entries := make([]ViolationCacheEntry, 0, len(files))
|
|
for _, name := range files {
|
|
path := filepath.Join(c.dir, name)
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
var rec ViolationCacheRecord
|
|
if err := json.Unmarshal(data, &rec); err != nil {
|
|
continue
|
|
}
|
|
if rec.SchemaVersion != ViolationCacheSchemaVersion {
|
|
continue
|
|
}
|
|
|
|
entries = append(entries, ViolationCacheEntry{Path: path, Record: rec})
|
|
}
|
|
|
|
return entries, nil
|
|
}
|
|
|
|
// Latest returns the most recent entry, or nil if the cache is empty.
|
|
func (c *ViolationCache) Latest() (*ViolationCacheEntry, error) {
|
|
entries, err := c.List()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(entries) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
e := entries[0]
|
|
return &e, nil
|
|
}
|
|
|
|
func (c *ViolationCache) prune() error {
|
|
dirents, err := os.ReadDir(c.dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
files := violationCacheMatchingFiles(dirents)
|
|
if len(files) <= c.retention {
|
|
return nil
|
|
}
|
|
|
|
violationCacheSortFilesNewestFirst(files)
|
|
|
|
var firstErr error
|
|
for _, name := range files[c.retention:] {
|
|
if err := os.Remove(filepath.Join(c.dir, name)); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
|
|
return firstErr
|
|
}
|
|
|
|
func violationCacheMatchingFiles(dirents []os.DirEntry) []string {
|
|
files := make([]string, 0, len(dirents))
|
|
for _, d := range dirents {
|
|
if d.IsDir() {
|
|
continue
|
|
}
|
|
|
|
name := d.Name()
|
|
if len(name) <= len(violationCacheFilePrefix)+len(violationCacheFileSuffix) {
|
|
continue
|
|
}
|
|
|
|
if !strings.HasPrefix(name, violationCacheFilePrefix) || !strings.HasSuffix(name, violationCacheFileSuffix) {
|
|
continue
|
|
}
|
|
|
|
files = append(files, name)
|
|
}
|
|
|
|
return files
|
|
}
|
|
|
|
func violationCacheSortFilesNewestFirst(files []string) {
|
|
sort.Slice(files, func(i, j int) bool { return files[i] > files[j] })
|
|
}
|
|
|
|
func violationCacheShortID() (string, error) {
|
|
var buf [4]byte
|
|
if _, err := rand.Read(buf[:]); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return hex.EncodeToString(buf[:]), nil
|
|
}
|