303 lines
11 KiB
Go
Executable File
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))
|
|
}
|