sorcery-go/pkg/tomb/storage.go

303 lines
11 KiB
Go
Executable File

// Package tomb is the content-addressable storage (CAS) layer of the Coven.
//
// "In the Coven, we forge the living. In the Tomb, we preserve the eternal."
//
// A Sarcophagus is the on-disk record of one Essence — the binaries,
// libraries and metadata produced by one cast. The Epitaph is the metadata
// sidecar (JSON): name, version, y/n flags, toolchain id, Merkle root.
// Reanimation is the act of binding an Essence into a Sanctum.
// Technically this means creating reflinks (preferred — zero-copy on
// btrfs/xfs) or hardlinks from the Tomb's blob store into the Sanctum's
// rootfs. If neither is possible (cross-filesystem), we fall back to a
// plain copy.
//
// Works identically across all runtimes: LXC, Podman, Firecracker, baremetal.
//
// Files are stored by their SHA-256 hash under
// /var/lib/sorcery-go/tomb/blobs/<ab>/<hash> (sharded by first two hex
// chars) so a single directory never grows past ~65k entries — keeping
// `ls` fast on cold disks.
package tomb
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"sort"
"strings"
)
// Sarcophagus is the on-disk record of one Essence variant.
type Sarcophagus struct {
EssenceID string `json:"essence_id"` // Merkle root
SpellName string `json:"spell_name"`
Version string `json:"version"`
VariantHash string `json:"variant_hash"` // sha256(version + flags + arch + toolchain)
Arch string `json:"arch"`
Linkage string `json:"linkage"` // "dynamic" | "static" | "hermetic"
Config map[string]bool `json:"config"` // y/n answers
Files map[string]string `json:"files"` // path -> sha256
CreatedAt string `json:"created_at"`
Toolchain string `json:"toolchain"`
License string `json:"license"`
SignedBy string `json:"signed_by,omitempty"` // PGP key fingerprint
}
// Tomb is the storage manager.
type Tomb struct {
Root string // /var/lib/sorcery-go/tomb
}
// New returns a Tomb rooted at `root`.
func New(root string) *Tomb {
return &Tomb{Root: root}
}
// IngestBlob copies a file from `srcPath` into the Tomb's blob store,
// returning the SHA-256 hash. Content-addressed storage means duplicate
// blobs are harmlessly re-written with identical content.
func (t *Tomb) IngestBlob(srcPath string) (string, error) {
hash, err := HashFile(srcPath)
if err != nil {
return "", err
}
dst := t.blobPath(hash)
if err := os.MkdirAll(filepath.Dir(dst), 0700); err != nil {
return "", err
}
in, err := os.Open(srcPath)
if err != nil {
return "", err
}
defer in.Close()
// Use O_CREATE|O_TRUNC directly — no TOCTOU race from a prior Stat.
// Content-addressed means re-writing the same content is safe.
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return "", err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return "", err
}
return hash, nil
}
// Store writes a Sarcophagus's epitaph to disk. Callers must have already
// ingested every blob via IngestBlob.
func (t *Tomb) Store(sarc *Sarcophagus) error {
if err := os.MkdirAll(filepath.Join(t.Root, "epitaphs"), 0700); err != nil {
return err
}
// Recompute the Merkle root from the file map — this is the canonical
// EssenceID. If the caller pre-set EssenceID we verify it matches.
computed := ComputeMerkleRoot(sarc.Files)
if sarc.EssenceID == "" {
sarc.EssenceID = computed
} else if sarc.EssenceID != computed {
return fmt.Errorf("tomb: essence_id mismatch (stored=%s, computed=%s) — refusing to write taint",
sarc.EssenceID, computed)
}
path := filepath.Join(t.Root, "epitaphs", sarc.EssenceID+".json")
data, err := json.MarshalIndent(sarc, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
// GetSarcophagus loads the epitaph for an Essence ID.
func (t *Tomb) GetSarcophagus(essenceID string) (*Sarcophagus, error) {
path := filepath.Join(t.Root, "epitaphs", essenceID+".json")
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("tomb: sarcophagus %s not found: %w", essenceID, err)
}
var s Sarcophagus
if err := json.Unmarshal(data, &s); err != nil {
return nil, err
}
return &s, nil
}
// VerifyRoot recomputes the Merkle root of a Sarcophagus from its file
// hashes and compares it against the stored EssenceID. Any mismatch
// means "Taint" — the Warding must reject Reanimation.
//
// Note: this only verifies the metadata. To verify the blobs themselves
// are not bit-rotted, use VerifyBlobs which re-hashes every blob on disk.
func (t *Tomb) VerifyRoot(essenceID string) error {
s, err := t.GetSarcophagus(essenceID)
if err != nil {
return err
}
computed := ComputeMerkleRoot(s.Files)
if computed != essenceID {
return fmt.Errorf("tomb: merkle mismatch for %s (expected %s, recomputed %s)",
s.SpellName, essenceID, computed)
}
return nil
}
// VerifyBlobs re-hashes every blob referenced by the Sarcophagus and
// confirms the bytes on disk still match. This is the slow bit-rot check
// run by `sorcery tomb verify --all`.
func (t *Tomb) VerifyBlobs(essenceID string) error {
s, err := t.GetSarcophagus(essenceID)
if err != nil {
return err
}
for path, expectedHash := range s.Files {
blobPath := t.blobPath(expectedHash)
actualHash, err := HashFile(blobPath)
if err != nil {
return fmt.Errorf("tomb: blob %s missing for %s: %w", expectedHash, path, err)
}
if actualHash != expectedHash {
return fmt.Errorf("tomb: blob %s bit-rotted (path %s)", expectedHash, path)
}
}
return nil
}
// List returns every Essence currently resting in the Tomb.
func (t *Tomb) List() ([]*Sarcophagus, error) {
dir := filepath.Join(t.Root, "epitaphs")
var out []*Sarcophagus
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
if !strings.HasSuffix(path, ".json") {
return nil
}
data, e := os.ReadFile(path)
if e != nil {
log.Printf("tomb: skipping unreadable file %s: %v", path, e)
return nil
}
var s Sarcophagus
if e := json.Unmarshal(data, &s); e == nil {
out = append(out, &s)
}
return nil
})
return out, err
}
// FindByVariant returns the EssenceID of the Sarcophagus whose VariantHash
// matches, or "" when not found. This is the cache-hit check in the Cast
// pipeline.
func (t *Tomb) FindByVariant(variantHash string) (string, error) {
all, err := t.List()
if err != nil {
return "", err
}
for _, s := range all {
if s.VariantHash == variantHash {
return s.EssenceID, nil
}
}
return "", nil
}
// FindBySpell returns the most recently created Sarcophagus for a spell
// (any variant). Used by `cauldron` to pick the latest essence for an ISO.
func (t *Tomb) FindBySpell(spell, arch string) (*Sarcophagus, error) {
all, err := t.List()
if err != nil {
return nil, err
}
var best *Sarcophagus
for _, s := range all {
if s.SpellName != spell {
continue
}
if arch != "" && s.Arch != arch {
continue
}
if best == nil || s.CreatedAt > best.CreatedAt {
best = s
}
}
if best == nil {
return nil, fmt.Errorf("tomb: no essence for %s on %s", spell, arch)
}
return best, nil
}
// Prune (Garbage Collection) walks the blob store and removes any hash
// that is not referenced by an active epitaph. Returns the bytes reclaimed.
func (t *Tomb) Prune() (int64, error) {
active := make(map[string]bool)
epitaphs, err := t.List()
if err != nil {
return 0, fmt.Errorf("tomb: prune: list epitaphs: %w", err)
}
for _, s := range epitaphs {
for _, h := range s.Files {
active[h] = true
}
}
var reclaimed int64
blobDir := filepath.Join(t.Root, "blobs")
err := filepath.Walk(blobDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
name := info.Name()
if !active[name] {
reclaimed += info.Size()
_ = os.Remove(path)
}
return nil
})
return reclaimed, err
}
// blobPath returns /var/lib/sorcery-go/tomb/blobs/<ab>/<hash>.
func (t *Tomb) blobPath(hash string) string {
if len(hash) < 2 {
return filepath.Join(t.Root, "blobs", hash)
}
return filepath.Join(t.Root, "blobs", hash[:2], hash)
}
// HashFile computes the sha256 of a file on disk — used by the Committer
// and the Cast pipeline before storing a Sarcophagus.
func HashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// ComputeMerkleRoot concatenates every file path + hash (sorted by path)
// and hashes the result. This catches a single-bit flip anywhere in the
// file set. A real binary Merkle tree would let us localise the failure;
// for now we trade precision for simplicity and speed.
func ComputeMerkleRoot(files map[string]string) string {
keys := make([]string, 0, len(files))
for k := range files {
keys = append(keys, k)
}
sort.Strings(keys)
h := sha256.New()
for _, k := range keys {
h.Write([]byte(k))
h.Write([]byte(files[k]))
}
return hex.EncodeToString(h.Sum(nil))
}