From 17b68161f8e71c8f07d43c0bb8d1f17902b212fd Mon Sep 17 00:00:00 2001 From: mdheller Date: Mon, 3 Aug 2026 05:56:50 -0400 Subject: [PATCH 1/5] feat(inception-mount): owned FUSE-free FS seam + Linux btrfs versioning substrate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inception-mount lives in SourceOS because it is an OS + managed-network concern, not a platform concern: the privileged snapshot/replication layer is OS-resident. One owned go-billy FileSystem seam (fs.InceptionFS) with fail-closed capability leases + hash-chained receipts, served two ways, macFUSE-free: - agent/pod face: in-process VFS — no mount() syscall, no CAP_SYS_ADMIN, legal inside a restricted-PSA pod. - human/userland face: userspace NFSv3 on loopback (willscott/go-nfs), mounted by the OS-native client; FSKit (macOS 26+) is the native successor. Versioning substrate (backend.Snapshotter): a governed Commit freezes an immutable, receipt-pinned Version. - BtrfsSnapshotter (linux): `btrfs subvolume snapshot -r`, id = UUID:generation; the privileged op belongs in the OS mounter daemon. Cross-compiles amd64+arm64. - DevSnapshotter (portable): content-hash + read-only tree copy, same contract, runs on macOS/CI. Validated (ADR-0001 seam, ADR-0002 btrfs): both faces + fail-closed governance + receipt chain + distinct immutable versions green on macOS; btrfs path cross- compiles for Linux; full NFS-client wire round-trip is root-gated. Nested Go module under src/, isolated from the OS image build. --- src/inception-mount/.gitignore | 2 + src/inception-mount/README.md | 43 +++++ src/inception-mount/backend/btrfs_linux.go | 74 ++++++++ src/inception-mount/backend/btrfs_other.go | 22 +++ src/inception-mount/backend/dev.go | 115 ++++++++++++ src/inception-mount/backend/snapshot.go | 26 +++ .../cmd/inception-mount/main.go | 44 +++++ .../docs/ADR-0001-inception-mount.md | 69 ++++++++ .../docs/ADR-0002-linux-btrfs-substrate.md | 62 +++++++ src/inception-mount/fs/commit_test.go | 82 +++++++++ src/inception-mount/fs/governance.go | 141 +++++++++++++++ src/inception-mount/fs/inceptionfs.go | 167 ++++++++++++++++++ src/inception-mount/fs/inceptionfs_test.go | 93 ++++++++++ src/inception-mount/fs/nfsface_test.go | 96 ++++++++++ src/inception-mount/go.mod | 17 ++ src/inception-mount/go.sum | 42 +++++ 16 files changed, 1095 insertions(+) create mode 100644 src/inception-mount/.gitignore create mode 100644 src/inception-mount/README.md create mode 100644 src/inception-mount/backend/btrfs_linux.go create mode 100644 src/inception-mount/backend/btrfs_other.go create mode 100644 src/inception-mount/backend/dev.go create mode 100644 src/inception-mount/backend/snapshot.go create mode 100644 src/inception-mount/cmd/inception-mount/main.go create mode 100644 src/inception-mount/docs/ADR-0001-inception-mount.md create mode 100644 src/inception-mount/docs/ADR-0002-linux-btrfs-substrate.md create mode 100644 src/inception-mount/fs/commit_test.go create mode 100644 src/inception-mount/fs/governance.go create mode 100644 src/inception-mount/fs/inceptionfs.go create mode 100644 src/inception-mount/fs/inceptionfs_test.go create mode 100644 src/inception-mount/fs/nfsface_test.go create mode 100644 src/inception-mount/go.mod create mode 100644 src/inception-mount/go.sum diff --git a/src/inception-mount/.gitignore b/src/inception-mount/.gitignore new file mode 100644 index 0000000..68b7ee5 --- /dev/null +++ b/src/inception-mount/.gitignore @@ -0,0 +1,2 @@ +*.test +/inception-mount diff --git a/src/inception-mount/README.md b/src/inception-mount/README.md new file mode 100644 index 0000000..5f04276 --- /dev/null +++ b/src/inception-mount/README.md @@ -0,0 +1,43 @@ +# inception-mount + +An **owned, FUSE-free** FileSystem seam for mounting inception spaces from user +land — the way a Docker volume driver abstracts backing storage, but +proof-carrying (capability lease + hash-chained receipts + warrant-typed +content). See [docs/ADR-0001](docs/ADR-0001-inception-mount.md). + +Not macFUSE. Not any kernel-mount dependency inside pods. One `go-billy` +FileSystem seam (`fs.InceptionFS`), served two ways: + +- **Agent / pod face** — link the VFS in-process. No `mount()` syscall ⇒ no + privilege ⇒ runs inside a restricted-PSA `sovereign-runtime` pod. +- **Human / userland face** — served as userspace **NFSv3 over loopback**; the + OS-native NFS client mounts it. FSKit (macOS 26+) is the native successor. + +Governance lives in the seam, so it is identical on both faces: every op is gated +by a capability lease (**fail-closed**) and leaves a **hash-chained receipt**; +`unmount ≡ revocation`. + +## Layout + +- `fs/governance.go` — `Lease`, fail-closed `Membrane`, hash-chained `ReceiptLog`. +- `fs/inceptionfs.go` — `InceptionFS`: decorates any `billy.Filesystem` backend. +- `cmd/inception-mount` — serve a space as userspace NFSv3 on loopback. + +## Run + +```bash +go test ./... # both faces + governance (wire round-trip skips unless root) + +# serve a local dir as a governed, read-only inception space over loopback NFS: +go run ./cmd/inception-mount -dir /path/to/space -space demo-space +# then, on the human's own machine (their sudo grants the mount — no kext, no FUSE): +# sudo mount -o vers=3,tcp,port=22049,mountport=22049,noowners,rw -t nfs 127.0.0.1:/ /path/to/mnt +``` + +## Status + +Spike. Proven: privilege-free in-process VFS with fail-closed governance + +verified receipt chain; governed NFSv3 server stands up unprivileged on loopback; +full NFS-client wire read + denied write (root-gated). Next: real backends +(trit-pack / HellGraph / zot), FSKit module, write-back consistency across shared +replicas. diff --git a/src/inception-mount/backend/btrfs_linux.go b/src/inception-mount/backend/btrfs_linux.go new file mode 100644 index 0000000..1e70e60 --- /dev/null +++ b/src/inception-mount/backend/btrfs_linux.go @@ -0,0 +1,74 @@ +//go:build linux + +package backend + +import ( + "fmt" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// BtrfsSnapshotter is the production Linux Snapshotter. A commit becomes a +// read-only btrfs snapshot of the space's subvolume — an O(1), COW-cheap, +// immutable version. This is a PRIVILEGED operation (subvolume ops need +// CAP_SYS_ADMIN unless mounted user_subvol_rm_allowed), so it is meant to run in +// the owned OS-level mounter daemon, never in an unprivileged agent pod — which +// is exactly why the versioning/replication layer is an OS concern. +// +// It shells the distro `btrfs` binary today; the owned path is dennwc/btrfs +// (pure-Go btrfs ioctls, Apache-2.0) so the daemon carries no external runtime +// dependency. Cross-node replication (not shown) is `btrfs send -p | +// btrfs receive` — the managed-network face. +type BtrfsSnapshotter struct { + subvol string // the space's read-write subvolume (working tree) + snapDir string // directory holding read-only snapshots +} + +func NewBtrfsSnapshotter(subvol, snapDir string) *BtrfsSnapshotter { + return &BtrfsSnapshotter{subvol: subvol, snapDir: snapDir} +} + +func (b *BtrfsSnapshotter) Kind() string { return "btrfs" } + +func (b *BtrfsSnapshotter) Snapshot(purpose string) (Version, error) { + dest := filepath.Join(b.snapDir, fmt.Sprintf("v-%d", time.Now().UTC().UnixNano())) + if out, err := exec.Command("btrfs", "subvolume", "snapshot", "-r", b.subvol, dest).CombinedOutput(); err != nil { + return Version{}, fmt.Errorf("btrfs snapshot: %v: %s", err, strings.TrimSpace(string(out))) + } + id, err := subvolID(dest) + if err != nil { + return Version{Ref: dest, Kind: "btrfs"}, fmt.Errorf("read snapshot id: %w", err) + } + return Version{ID: id, Ref: dest, Kind: "btrfs"}, nil +} + +// subvolID returns ":" for the snapshot — a stable identity the +// receipt chain binds (bind-at-capture). +func subvolID(path string) (string, error) { + out, err := exec.Command("btrfs", "subvolume", "show", path).CombinedOutput() + if err != nil { + return "", fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out))) + } + var uuid, gen string + for _, line := range strings.Split(string(out), "\n") { + f := strings.SplitN(strings.TrimSpace(line), ":", 2) + if len(f) != 2 { + continue + } + k, v := strings.TrimSpace(f[0]), strings.TrimSpace(f[1]) + switch k { + case "UUID": + uuid = v + case "Generation", "Gen at creation": + if gen == "" { + gen = v + } + } + } + if uuid == "" { + return "", fmt.Errorf("no UUID in `btrfs subvolume show %s`", path) + } + return uuid + ":" + gen, nil +} diff --git a/src/inception-mount/backend/btrfs_other.go b/src/inception-mount/backend/btrfs_other.go new file mode 100644 index 0000000..d3687e4 --- /dev/null +++ b/src/inception-mount/backend/btrfs_other.go @@ -0,0 +1,22 @@ +//go:build !linux + +package backend + +import "fmt" + +// BtrfsSnapshotter is Linux-only; this stub keeps the package building on other +// OSes (dev on macOS uses DevSnapshotter). Constructing it is fine; Snapshot fails. +type BtrfsSnapshotter struct { + subvol string + snapDir string +} + +func NewBtrfsSnapshotter(subvol, snapDir string) *BtrfsSnapshotter { + return &BtrfsSnapshotter{subvol: subvol, snapDir: snapDir} +} + +func (b *BtrfsSnapshotter) Kind() string { return "btrfs" } + +func (b *BtrfsSnapshotter) Snapshot(purpose string) (Version, error) { + return Version{}, fmt.Errorf("btrfs snapshotter requires linux (GOOS=%s); use DevSnapshotter off-Linux", "!linux") +} diff --git a/src/inception-mount/backend/dev.go b/src/inception-mount/backend/dev.go new file mode 100644 index 0000000..6fecf4f --- /dev/null +++ b/src/inception-mount/backend/dev.go @@ -0,0 +1,115 @@ +package backend + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "sort" +) + +// DevSnapshotter is the portable, btrfs-free Snapshotter: it content-hashes the +// working tree (a Merkle-ish digest over sorted path+mode+content) and copies it +// read-only into a version store. It gives the same immutable-Version contract as +// btrfs so the seam, receipts, and tests run on any OS; on Linux it is swapped +// for BtrfsSnapshotter with no change above this interface. +type DevSnapshotter struct { + root string // the space's working tree + store string // where immutable versions are copied (MUST be outside root) +} + +// NewDevSnapshotter snapshots root into store. store must not be inside root. +func NewDevSnapshotter(root, store string) *DevSnapshotter { + return &DevSnapshotter{root: root, store: store} +} + +func (d *DevSnapshotter) Kind() string { return "dev" } + +func (d *DevSnapshotter) Snapshot(purpose string) (Version, error) { + h, err := hashTree(d.root) + if err != nil { + return Version{}, fmt.Errorf("hash tree: %w", err) + } + dest := filepath.Join(d.store, h) + if _, err := os.Stat(dest); err == nil { + return Version{ID: h, Ref: dest, Kind: "dev"}, nil // content-identical version already frozen + } + if err := copyTreeReadOnly(d.root, dest); err != nil { + return Version{}, fmt.Errorf("freeze: %w", err) + } + return Version{ID: h, Ref: dest, Kind: "dev"}, nil +} + +// hashTree computes a deterministic digest over the tree: for each regular file +// (sorted by relative path) it folds in the path, mode, and content hash. +func hashTree(root string) (string, error) { + type ent struct { + rel string + mode os.FileMode + sum [32]byte + } + var ents []ent + err := filepath.Walk(root, func(p string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + if fi.IsDir() { + return nil + } + rel, _ := filepath.Rel(root, p) + f, err := os.Open(p) + if err != nil { + return err + } + defer f.Close() + hsh := sha256.New() + if _, err := io.Copy(hsh, f); err != nil { + return err + } + var s [32]byte + copy(s[:], hsh.Sum(nil)) + ents = append(ents, ent{rel: rel, mode: fi.Mode(), sum: s}) + return nil + }) + if err != nil { + return "", err + } + sort.Slice(ents, func(i, j int) bool { return ents[i].rel < ents[j].rel }) + top := sha256.New() + for _, e := range ents { + fmt.Fprintf(top, "%s|%o|%s\n", e.rel, e.mode, hex.EncodeToString(e.sum[:])) + } + return hex.EncodeToString(top.Sum(nil)), nil +} + +func copyTreeReadOnly(src, dst string) error { + return filepath.Walk(src, func(p string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(src, p) + target := filepath.Join(dst, rel) + if fi.IsDir() { + return os.MkdirAll(target, 0o755) + } + in, err := os.Open(p) + if err != nil { + return err + } + defer in.Close() + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o444) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + return out.Close() + }) +} diff --git a/src/inception-mount/backend/snapshot.go b/src/inception-mount/backend/snapshot.go new file mode 100644 index 0000000..58c145b --- /dev/null +++ b/src/inception-mount/backend/snapshot.go @@ -0,0 +1,26 @@ +// Package backend holds swappable substrates behind the InceptionFS seam and, +// crucially, the Snapshotter that turns a space's working state into an +// immutable, receipt-pinnable Version. +// +// On Linux the production Snapshotter is btrfs: a governed commit becomes +// `btrfs subvolume snapshot -r` (a privileged op that lives in the owned +// OS-level mounter daemon, NOT the agent pod), and cross-node replication is +// `btrfs send -p | btrfs receive` — the managed-network face. The +// portable DevSnapshotter mirrors the contract without btrfs for dev/CI on any +// OS. Both return a Version whose ID the receipt chain binds (bind-at-capture). +package backend + +// Version is an immutable snapshot of an inception space. +type Version struct { + ID string // stable identity: btrfs subvol UUID:generation, or content hash (dev) + Ref string // handle to the read-only snapshot (path today; could be a send-stream ref) + Kind string // "btrfs" | "dev" +} + +// Snapshotter captures the current state of a space as an immutable Version. +type Snapshotter interface { + // Snapshot freezes the space's current working tree read-only and returns + // its Version. purpose is recorded for provenance. + Snapshot(purpose string) (Version, error) + Kind() string +} diff --git a/src/inception-mount/cmd/inception-mount/main.go b/src/inception-mount/cmd/inception-mount/main.go new file mode 100644 index 0000000..604015e --- /dev/null +++ b/src/inception-mount/cmd/inception-mount/main.go @@ -0,0 +1,44 @@ +// Command inception-mount serves an inception space as userspace NFSv3 over +// loopback, so the host's NATIVE NFS client can mount it — no FUSE, no macFUSE +// kext, no /dev/fuse. The same fs.InceptionFS is what an agent links in-process +// (the pod face); here it wears the userland face. +package main + +import ( + "flag" + "log" + "net" + + ifs "github.com/SociOS-Linux/SourceOS/src/inception-mount/fs" + "github.com/go-git/go-billy/v5/osfs" + nfs "github.com/willscott/go-nfs" + nfshelper "github.com/willscott/go-nfs/helpers" +) + +func main() { + addr := flag.String("addr", "127.0.0.1:22049", "loopback listen address") + dir := flag.String("dir", ".", "backend dir standing in for the inception space content store") + subject := flag.String("subject", "human", "lease subject") + space := flag.String("space", "demo-space", "inception space id") + write := flag.Bool("write", false, "grant write capability (default read-only)") + flag.Parse() + + lease := ifs.ReadOnlyLease(*subject, *space, "userland-mount") + if *write { + lease = ifs.ReadWriteLease(*subject, *space, "userland-mount") + } + fsys := ifs.New(osfs.New(*dir), *space, lease) + handler := nfshelper.NewCachingHandler(nfshelper.NewNullAuthHandler(fsys), 1024) + + lis, err := net.Listen("tcp", *addr) + if err != nil { + log.Fatalf("listen %s: %v", *addr, err) + } + host, port, _ := net.SplitHostPort(lis.Addr().String()) + log.Printf("inception-mount: serving space %q from %q on %s (NFSv3, FUSE-free, lease write=%v)", + *space, *dir, lis.Addr(), *write) + log.Printf("mount (macOS): sudo mount -o vers=3,tcp,port=%s,mountport=%s,noowners,rw -t nfs %s:/ /path/to/mnt", port, port, host) + if err := nfs.Serve(lis, handler); err != nil { + log.Fatalf("serve: %v", err) + } +} diff --git a/src/inception-mount/docs/ADR-0001-inception-mount.md b/src/inception-mount/docs/ADR-0001-inception-mount.md new file mode 100644 index 0000000..8024dbf --- /dev/null +++ b/src/inception-mount/docs/ADR-0001-inception-mount.md @@ -0,0 +1,69 @@ +# ADR-0001 — Inception Mount: an owned, FUSE-free FileSystem seam + +**Status:** Accepted (spike proven) · **Date:** 2026-08-03 + +## Context + +Inception spaces (the trit/provenance content plane) must be *mountable from +user land* — attachable to agents and to the human's own machine — the way a +Docker volume driver abstracts backing storage from the application. The obvious +mechanism is FUSE. We reject it: + +1. On macOS, "FUSE" is **macFUSE**, whose current kext ships under a **proprietary + license** — a licensing trap, not just a preference. +2. More fundamentally, the privilege wall is **not FUSE-specific**. *Any* kernel + mount syscall (`mount -t nfs`, `mount_webdavfs`, `/dev/fuse`) requires + `CAP_SYS_ADMIN`. A restricted-PSA `sovereign-runtime` pod forbids all of them + equally, so "swap FUSE for another mount protocol" buys no privilege relief. + +## Decision + +One **owned FileSystem seam** (`fs.InceptionFS`, a `go-billy` `Filesystem`), with +the backing substrate swappable behind it (memfs/osfs today; trit-pack, HellGraph +content, sovereign-zot tomorrow) exactly as a volume driver swaps NFS↔S3. The +seam carries the governance a plain file server lacks: a **capability lease** +(fail-closed), a **hash-chained receipt** per operation, warrant-typed content, +and `unmount ≡ revocation`. It is served through **two faces, macFUSE-free**: + +| Face | Transport | Privilege | +|------|-----------|-----------| +| **Agent / pod** (restricted-PSA) | link the VFS **in-process** (or localhost gRPC/9p) — no kernel mount | **none** — no `mount()` syscall, legal inside the isolation contract | +| **Human / userland** (the Mac) | serve **userspace NFSv3 on loopback**, mount with the OS-native NFS client; **FSKit** (macOS 26+) as the native successor | serving is unprivileged; the human's own `sudo mount_nfs` grants the mount on their machine | + +The agent-face row is the answer to "can we mount unprivileged inside our own +isolation contract?": **you don't mount — you serve the VFS in-process.** + +## Owned foundations (enhance, don't wrap · MIT/Apache gate) + +- **go-git/go-billy** (Apache-2.0) — the `Filesystem` interface; the seam we own. +- **willscott/go-nfs** (userspace NFSv3, billy-native) — the loopback NFS face. +- **rclone** (MIT) — *pattern reference only* (it adopted loopback-NFS to dodge + macFUSE); cherry-pick its VFS write-back/cache if needed, do not vendor whole + (its ~70 backends violate the no-bloat rule). +- **FSKit** (macOS 26+) — track as the native mount successor via our own Swift + module; never macFUSE. + +Licenses are re-verified at adoption, not assumed. + +## Proven by this spike + +- **Agent/pod face** (`TestAgentFace_ReadShared_WriteFenced`, `TestNoLease…`, + `TestWrongSpace…`): in-process VFS, no privilege — read allowed under a + read-only lease, write **fail-closed denied**, no-lease and cross-space denied, + receipt chain verified. ✅ +- **Userland face** (`TestUserlandFace_ServesOnLoopback`): the governed FS stands + up as userspace NFSv3 on an unprivileged loopback port and accepts RPC. ✅ +- **Full wire round-trip** (`TestUserlandFace_NFSClientRoundTrip`): real NFS + client read + fail-closed write over NFSv3. Root-gated (go-nfs-client dials + portmap `:111`); the kernel `mount_nfs -o port=,mountport=` path needs no + privileged server. Skipped when not root. + +## Consequences / open items + +- **Write-back consistency** across shared replicas → single-writer or + receipt-ordered writes (ties to the measurement/resource contract). +- **Revocation latency** — unmount must fence in-flight writes immediately. +- **Backends** — implement `billy.Filesystem` over trit-pack / HellGraph / zot. +- **FSKit module** — Swift, owned, for the native macOS 26+ mount. +- **go.mod toolchain** floated to go 1.25 via `go get`; pin deliberately before + first release. diff --git a/src/inception-mount/docs/ADR-0002-linux-btrfs-substrate.md b/src/inception-mount/docs/ADR-0002-linux-btrfs-substrate.md new file mode 100644 index 0000000..f2f8eb3 --- /dev/null +++ b/src/inception-mount/docs/ADR-0002-linux-btrfs-substrate.md @@ -0,0 +1,62 @@ +# ADR-0002 — Btrfs as the Linux substrate for inception spaces + +**Status:** Validated (design + cross-compile; runtime proof runs on a Linux/btrfs node) · **Date:** 2026-08-03 + +## Question + +How do we use **btrfs** on Linux in the same pattern as the macOS/NFS seam +(ADR-0001) — and why does that make inception-mount an **OS + managed-network** +concern (SourceOS), not a platform concern? + +## Validation + +**1. The btrfs privilege model maps exactly onto ADR-0001's two faces.** +Creating/snapshotting a subvolume needs `CAP_SYS_ADMIN` (or a mount with +`user_subvol_rm_allowed`); *reading* a subvolume tree is unprivileged +(`BTRFS_IOC_INO_LOOKUP_USER`, kernel ≥4.18). Therefore: +- **Agent/pod face** — the pod reads/writes files in an already-mounted + subvolume **in-process, unprivileged** (no ioctl, no `mount()`), legal in a + restricted-PSA pod. Same as the macOS in-process VFS face. +- **OS-daemon face** — the **privileged** ops (`subvolume snapshot -r`, and + `send`/`receive`) run in the **owned OS-level mounter daemon** with the one + narrow capability. That the versioning + replication layer is privileged and + OS-resident is *why this belongs in SourceOS, not the platform.* + +**2. Snapshots are provenance-native versioning.** A governed `Commit` becomes a +read-only btrfs snapshot — O(1), COW-cheap, immutable — whose `UUID:generation` +the receipt chain binds (bind-at-capture; reversibility-distance ε ties +`project_epoch_e13_reference_gated_stack`). Reflink/COW gives *mount-don't-ingest* +efficiency: attach large userland trees, copy nothing until write. + +**3. Managed network = `btrfs send -p | ssh nodeN btrfs receive`.** +Incremental, verifiable replication of an inception space across nodes — the +Docker "share data among machines" picture, native, with the deltas being exactly +the snapshot increments. This is the managed-network face; it is an OS/fabric +capability, not an app feature. + +**4. Owned Go tooling exists, license-clean.** `dennwc/btrfs` (pure-Go btrfs +ioctls) is **Apache-2.0** (dep `dennwc/ioctl` MIT) — passes the MIT/Apache gate +and is the enhance-our-own base so the daemon carries no external runtime +dependency. `libbtrfsutil` is the C reference. The spike shells the `btrfs` +binary as a stand-in; the owned path swaps in dennwc/btrfs. + +## Shape in this repo + +`backend.Snapshotter` is the seam: `Snapshot(purpose) → Version{ID,Ref,Kind}`. +- `BtrfsSnapshotter` (`//go:build linux`) — `btrfs subvolume snapshot -r`, id = + `UUID:generation`. **Cross-compiles for linux/amd64 + linux/arm64** (validated). +- `DevSnapshotter` (portable) — content-hash + read-only tree copy; identical + contract, so seam/receipts/tests run on macOS/CI. `TestCommit_Versioned_*` + proves distinct immutable versions + receipt pinning + read-only-lease denial. + +`InceptionFS.Commit(purpose)` gates on write capability, snapshots, and appends a +single `commit` receipt pinning `btrfs://` (or `dev://`). + +## Consequences + +- The **OS mounter daemon** owns snapshot + send/receive (privileged); pods only + ever see an unprivileged in-process VFS over a mounted subvolume. +- Space = subvolume; version = read-only snapshot; replication = send/receive. +- Retention/GC of snapshots is a daemon policy (bounded, fail-closed loop). +- Next: swap the `btrfs` shell-out for dennwc/btrfs; wire send/receive replication + + a snapshot retention policy; run the runtime proof on a Linux/btrfs node. diff --git a/src/inception-mount/fs/commit_test.go b/src/inception-mount/fs/commit_test.go new file mode 100644 index 0000000..57fa746 --- /dev/null +++ b/src/inception-mount/fs/commit_test.go @@ -0,0 +1,82 @@ +package fs_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/SociOS-Linux/SourceOS/src/inception-mount/backend" + ifs "github.com/SociOS-Linux/SourceOS/src/inception-mount/fs" + "github.com/go-git/go-billy/v5/osfs" +) + +// TestCommit_Versioned_ReceiptPinned proves the versioning face with the portable +// DevSnapshotter (btrfs stands in for it on Linux, same interface): a governed +// Commit freezes an immutable Version whose ID is bound into the receipt chain, +// a mutation yields a DISTINCT version, and a read-only lease cannot Commit. +func TestCommit_Versioned_ReceiptPinned(t *testing.T) { + dir := t.TempDir() + store := t.TempDir() // version store lives OUTSIDE the working tree + if err := os.WriteFile(filepath.Join(dir, "twin.ttl"), + []byte(" a hdt:FHIRResource .\n"), 0o644); err != nil { + t.Fatal(err) + } + snap := backend.NewDevSnapshotter(dir, store) + fsys := ifs.New(osfs.New(dir), "demo-space", + ifs.ReadWriteLease("agent-1", "demo-space", "reconcile")).WithSnapshotter(snap) + + v1, err := fsys.Commit("baseline") + if err != nil { + t.Fatalf("commit v1: %v", err) + } + if v1.ID == "" || v1.Kind != "dev" { + t.Fatalf("bad version: %+v", v1) + } + + // mutate through the governed seam, then commit again → distinct version + f, err := fsys.Create("finding.ttl") + if err != nil { + t.Fatalf("write: %v", err) + } + f.Write([]byte(" a hdt:Observation .\n")) + f.Close() + v2, err := fsys.Commit("after-finding") + if err != nil { + t.Fatalf("commit v2: %v", err) + } + if v1.ID == v2.ID { + t.Fatalf("expected distinct versions, both %s", v1.ID) + } + + // the frozen v1 still contains only the baseline (immutability) + if _, err := os.Stat(filepath.Join(v1.Ref, "finding.ttl")); !os.IsNotExist(err) { + t.Fatal("v1 snapshot must not contain the later write") + } + + // receipts pin both version ids, chain intact + var commits []string + for _, r := range fsys.Receipts().Entries() { + if r.Op == ifs.OpCommit && strings.HasPrefix(r.Verdict, "allow") { + commits = append(commits, r.Path) + } + } + if len(commits) != 2 || !strings.Contains(commits[0], v1.ID) || !strings.Contains(commits[1], v2.ID) { + t.Fatalf("commit receipts did not pin versions: %v", commits) + } + if err := fsys.Receipts().Verify(); err != nil { + t.Fatalf("receipt chain: %v", err) + } +} + +// TestCommit_ReadOnlyLease_Denied proves committing a version needs write cap. +func TestCommit_ReadOnlyLease_Denied(t *testing.T) { + dir := t.TempDir() + store := t.TempDir() + fsys := ifs.New(osfs.New(dir), "demo-space", + ifs.ReadOnlyLease("viewer", "demo-space", "browse")). + WithSnapshotter(backend.NewDevSnapshotter(dir, store)) + if _, err := fsys.Commit("x"); err == nil { + t.Fatal("expected commit under read-only lease to be DENIED") + } +} diff --git a/src/inception-mount/fs/governance.go b/src/inception-mount/fs/governance.go new file mode 100644 index 0000000..a4a4b60 --- /dev/null +++ b/src/inception-mount/fs/governance.go @@ -0,0 +1,141 @@ +// Package fs — the owned inception-mount FileSystem seam. +// +// governance.go is the part a plain file server (Docker volume driver, NFS +// export, macFUSE mount) does NOT have: every operation crossing the mount is +// gated by a capability lease (fail-closed) and leaves a hash-chained receipt. +// This is where the capability membrane and the trit/provenance spine attach. +package fs + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sync" + "time" +) + +// Op is the class of access crossing the mount seam. +type Op string + +const ( + OpRead Op = "read" + OpWrite Op = "write" + OpList Op = "list" + OpStat Op = "stat" + OpCommit Op = "commit" // freeze an immutable version (durable history mutation) +) + +func (o Op) isWrite() bool { return o == OpWrite || o == OpCommit } + +// Lease is the capability a subject holds over one inception space: the +// role×space×purpose×caps grant that a mount materializes. No lease ⇒ no access. +type Lease struct { + Subject string // who (agent id, human, replica) + Space string // which inception space this lease is scoped to + Purpose string // declared purpose (purpose-bound consent) + CanRead bool // read/list/stat allowed + CanWrite bool // create/write/rename/remove allowed + Expiry time.Time // hard expiry; zero = no expiry (dev only) +} + +// ReadOnlyLease is the common case: attach a space to read, never mutate. +func ReadOnlyLease(subject, space, purpose string) *Lease { + return &Lease{Subject: subject, Space: space, Purpose: purpose, CanRead: true} +} + +// ReadWriteLease grants mutation; writes still emit receipts. +func ReadWriteLease(subject, space, purpose string) *Lease { + return &Lease{Subject: subject, Space: space, Purpose: purpose, CanRead: true, CanWrite: true} +} + +// Membrane decides allow/deny for an op on a path under a lease. It is +// FAIL-CLOSED: any missing/expired/out-of-scope condition denies. It never +// degrades an over-scoped request into a lesser-scoped success — it refuses. +type Membrane struct{} + +// Check returns nil to allow, or a non-nil error (the denial reason) to deny. +func (m *Membrane) Check(l *Lease, space string, op Op, path string) error { + if l == nil { + return fmt.Errorf("denied: no lease presented for %s on %q", op, path) + } + if l.Space != space { + return fmt.Errorf("denied: lease scoped to space %q, mount is space %q", l.Space, space) + } + if !l.Expiry.IsZero() && time.Now().After(l.Expiry) { + return fmt.Errorf("denied: lease for %q expired at %s", l.Subject, l.Expiry.Format(time.RFC3339)) + } + if op.isWrite() { + if !l.CanWrite { + return fmt.Errorf("denied: lease lacks write capability (%s on %q)", op, path) + } + } else if !l.CanRead { + return fmt.Errorf("denied: lease lacks read capability (%s on %q)", op, path) + } + return nil +} + +// Receipt is one tamper-evident record of an access decision at the seam. +// Receipts chain by hash so the mount's whole access history is verifiable. +type Receipt struct { + Seq uint64 `json:"seq"` + Prev string `json:"prev"` // hash of the previous receipt + TS time.Time `json:"ts"` + Subject string `json:"subject"` + Space string `json:"space"` + Op Op `json:"op"` + Path string `json:"path"` + Verdict string `json:"verdict"` // "allow" or "deny: " + Hash string `json:"hash"` // sha256 over the fields above + Prev +} + +// ReceiptLog is an append-only hash-chained ledger of seam decisions. +type ReceiptLog struct { + mu sync.Mutex + last string + all []Receipt +} + +func (rl *ReceiptLog) append(subject, space string, op Op, path, verdict string) Receipt { + rl.mu.Lock() + defer rl.mu.Unlock() + r := Receipt{ + Seq: uint64(len(rl.all)), Prev: rl.last, TS: time.Now().UTC(), + Subject: subject, Space: space, Op: op, Path: path, Verdict: verdict, + } + payload := fmt.Sprintf("%d|%s|%s|%s|%s|%s|%s|%s", + r.Seq, r.Prev, r.TS.Format(time.RFC3339Nano), r.Subject, r.Space, r.Op, r.Path, r.Verdict) + sum := sha256.Sum256([]byte(payload)) + r.Hash = hex.EncodeToString(sum[:]) + rl.last = r.Hash + rl.all = append(rl.all, r) + return r +} + +// Entries returns a copy of the chain for inspection/verification. +func (rl *ReceiptLog) Entries() []Receipt { + rl.mu.Lock() + defer rl.mu.Unlock() + out := make([]Receipt, len(rl.all)) + copy(out, rl.all) + return out +} + +// Verify walks the chain and confirms every link's hash and Prev pointer. +func (rl *ReceiptLog) Verify() error { + rl.mu.Lock() + defer rl.mu.Unlock() + prev := "" + for i, r := range rl.all { + if r.Prev != prev { + return fmt.Errorf("receipt %d: prev=%q, expected %q (chain broken)", i, r.Prev, prev) + } + payload := fmt.Sprintf("%d|%s|%s|%s|%s|%s|%s|%s", + r.Seq, r.Prev, r.TS.Format(time.RFC3339Nano), r.Subject, r.Space, r.Op, r.Path, r.Verdict) + sum := sha256.Sum256([]byte(payload)) + if got := hex.EncodeToString(sum[:]); got != r.Hash { + return fmt.Errorf("receipt %d: hash=%s recomputed=%s (tampered)", i, r.Hash, got) + } + prev = r.Hash + } + return nil +} diff --git a/src/inception-mount/fs/inceptionfs.go b/src/inception-mount/fs/inceptionfs.go new file mode 100644 index 0000000..d19c8c2 --- /dev/null +++ b/src/inception-mount/fs/inceptionfs.go @@ -0,0 +1,167 @@ +package fs + +import ( + "fmt" + "os" + + "github.com/SociOS-Linux/SourceOS/src/inception-mount/backend" + "github.com/go-git/go-billy/v5" +) + +// InceptionFS is the owned FileSystem seam. It decorates ANY billy.Filesystem +// backend (memfs/osfs today; trit-pack, HellGraph content, or a sovereign-zot +// store tomorrow — the backend is swappable behind this one interface, the way +// a Docker volume driver swaps NFS for S3 without the app changing) and gates +// every operation through the capability Membrane, emitting a Receipt each time. +// +// The SAME value serves both faces of the mount: +// - agent/pod face: call these methods in-process (no kernel mount, so no +// CAP_SYS_ADMIN — legal inside a restricted-PSA sovereign-runtime pod). +// - human/userland face: hand this to willscott/go-nfs and the OS mounts it +// over loopback NFS with its native client (no FUSE, no macFUSE kext). +// +// It implements billy.Filesystem by embedding the backend (promoting the +// non-governed methods) and overriding the ones that cross the trust boundary. +type InceptionFS struct { + billy.Filesystem // backend; promoted methods (Join, MkdirAll, TempFile, symlinks…) + space string + lease *Lease + membrane *Membrane + receipts *ReceiptLog + snap backend.Snapshotter // optional: btrfs on Linux, DevSnapshotter elsewhere +} + +// New wraps a billy backend as an inception space under a lease. +func New(fs billy.Filesystem, space string, lease *Lease) *InceptionFS { + return &InceptionFS{ + Filesystem: fs, + space: space, + lease: lease, + membrane: &Membrane{}, + receipts: &ReceiptLog{}, + } +} + +// WithSnapshotter attaches the version substrate (btrfs / dev) so the space can +// Commit immutable, receipt-pinned versions. Returns the same *InceptionFS. +func (f *InceptionFS) WithSnapshotter(s backend.Snapshotter) *InceptionFS { + f.snap = s + return f +} + +// Commit freezes the space's current state into an immutable Version and binds +// its ID into the receipt chain (bind-at-capture). Requires write capability — +// a committed version is a durable mutation of the space's history. +func (f *InceptionFS) Commit(purpose string) (backend.Version, error) { + if f.snap == nil { + return backend.Version{}, fmt.Errorf("no snapshotter configured for space %q", f.space) + } + subject := "" + if f.lease != nil { + subject = f.lease.Subject + } + // Gate directly (not via gate()) so we emit exactly ONE receipt — the + // version-pinned one on success, or the denial on failure. + if err := f.membrane.Check(f.lease, f.space, OpCommit, "/"); err != nil { + f.receipts.append(subject, f.space, OpCommit, "/", "deny: "+err.Error()) + return backend.Version{}, err + } + v, err := f.snap.Snapshot(purpose) + if err != nil { + f.receipts.append(subject, f.space, OpCommit, "/", "error: "+err.Error()) + return v, err + } + f.receipts.append(subject, f.space, OpCommit, f.snap.Kind()+"://"+v.ID, "allow: snapshot "+purpose) + return v, nil +} + +// Receipts exposes the seam's hash-chained access ledger. +func (f *InceptionFS) Receipts() *ReceiptLog { return f.receipts } + +// gate enforces the membrane and records a receipt (allow OR deny). Returns the +// denial error (already receipted) or nil to proceed. +func (f *InceptionFS) gate(op Op, path string) error { + err := f.membrane.Check(f.lease, f.space, op, path) + subject := "" + if f.lease != nil { + subject = f.lease.Subject + } + if err != nil { + f.receipts.append(subject, f.space, op, path, "deny: "+err.Error()) + return err + } + f.receipts.append(subject, f.space, op, path, "allow") + return nil +} + +func (f *InceptionFS) Open(filename string) (billy.File, error) { + if err := f.gate(OpRead, filename); err != nil { + return nil, err + } + return f.Filesystem.Open(filename) +} + +func (f *InceptionFS) OpenFile(filename string, flag int, perm os.FileMode) (billy.File, error) { + op := OpRead + if flag&(os.O_WRONLY|os.O_RDWR|os.O_CREATE|os.O_APPEND|os.O_TRUNC) != 0 { + op = OpWrite + } + if err := f.gate(op, filename); err != nil { + return nil, err + } + return f.Filesystem.OpenFile(filename, flag, perm) +} + +func (f *InceptionFS) Create(filename string) (billy.File, error) { + if err := f.gate(OpWrite, filename); err != nil { + return nil, err + } + return f.Filesystem.Create(filename) +} + +func (f *InceptionFS) ReadDir(path string) ([]os.FileInfo, error) { + if err := f.gate(OpList, path); err != nil { + return nil, err + } + return f.Filesystem.ReadDir(path) +} + +func (f *InceptionFS) Stat(filename string) (os.FileInfo, error) { + if err := f.gate(OpStat, filename); err != nil { + return nil, err + } + return f.Filesystem.Stat(filename) +} + +func (f *InceptionFS) Lstat(filename string) (os.FileInfo, error) { + if err := f.gate(OpStat, filename); err != nil { + return nil, err + } + return f.Filesystem.Lstat(filename) +} + +func (f *InceptionFS) Rename(oldpath, newpath string) error { + if err := f.gate(OpWrite, oldpath); err != nil { + return err + } + return f.Filesystem.Rename(oldpath, newpath) +} + +func (f *InceptionFS) Remove(filename string) error { + if err := f.gate(OpWrite, filename); err != nil { + return err + } + return f.Filesystem.Remove(filename) +} + +// Chroot re-wraps the sub-tree so governance is not lost when descending. +func (f *InceptionFS) Chroot(path string) (billy.Filesystem, error) { + if err := f.gate(OpList, path); err != nil { + return nil, err + } + sub, err := f.Filesystem.Chroot(path) + if err != nil { + return nil, err + } + return &InceptionFS{Filesystem: sub, space: f.space, lease: f.lease, membrane: f.membrane, receipts: f.receipts}, nil +} diff --git a/src/inception-mount/fs/inceptionfs_test.go b/src/inception-mount/fs/inceptionfs_test.go new file mode 100644 index 0000000..a4a12ec --- /dev/null +++ b/src/inception-mount/fs/inceptionfs_test.go @@ -0,0 +1,93 @@ +package fs + +import ( + "io" + "strings" + "testing" + + "github.com/go-git/go-billy/v5" + "github.com/go-git/go-billy/v5/memfs" +) + +// seedSpace builds a backend that stands in for a trit-pack / HellGraph content +// store: an inception space with one warrant-typed artifact. +func seedSpace(t *testing.T) billy.Filesystem { + t.Helper() + be := memfs.New() + f, err := be.Create("twin.ttl") + if err != nil { + t.Fatalf("seed create: %v", err) + } + if _, err := f.Write([]byte(" a hdt:FHIRResource .\n")); err != nil { + t.Fatalf("seed write: %v", err) + } + f.Close() + return be +} + +// TestAgentFace_ReadShared_WriteFenced proves the pod face: an agent linking the +// VFS in-process (no kernel mount, no privilege) reads under a read-only lease, +// and a write is FAIL-CLOSED denied — with a receipt for both. +func TestAgentFace_ReadShared_WriteFenced(t *testing.T) { + be := seedSpace(t) + ifs := New(be, "demo-space", ReadOnlyLease("agent-1", "demo-space", "reconcile-twin")) + + // read allowed + rf, err := ifs.Open("twin.ttl") + if err != nil { + t.Fatalf("expected read allowed, got %v", err) + } + b, _ := io.ReadAll(rf) + rf.Close() + if !strings.Contains(string(b), "FHIRResource") { + t.Fatalf("unexpected content: %q", b) + } + + // write DENIED (read-only lease) — fail-closed + if _, err := ifs.Create("inject.ttl"); err == nil { + t.Fatal("expected write to be DENIED under read-only lease, but it succeeded") + } else if !strings.Contains(err.Error(), "lacks write capability") { + t.Fatalf("wrong denial reason: %v", err) + } + + // receipts: one allow (read) + one deny (write), chain intact + rs := ifs.Receipts().Entries() + if len(rs) != 2 { + t.Fatalf("expected 2 receipts, got %d", len(rs)) + } + if rs[0].Verdict != "allow" || rs[0].Op != OpRead { + t.Fatalf("receipt[0] = %+v, want allow/read", rs[0]) + } + if !strings.HasPrefix(rs[1].Verdict, "deny:") || rs[1].Op != OpWrite { + t.Fatalf("receipt[1] = %+v, want deny/write", rs[1]) + } + if err := ifs.Receipts().Verify(); err != nil { + t.Fatalf("receipt chain verify: %v", err) + } +} + +// TestNoLease_DeniesEverything proves the fail-closed default: no lease ⇒ no access. +func TestNoLease_DeniesEverything(t *testing.T) { + be := seedSpace(t) + ifs := New(be, "demo-space", nil) + if _, err := ifs.Open("twin.ttl"); err == nil { + t.Fatal("expected no-lease read to be denied") + } + if _, err := ifs.ReadDir("/"); err == nil { + t.Fatal("expected no-lease list to be denied") + } + for _, r := range ifs.Receipts().Entries() { + if !strings.HasPrefix(r.Verdict, "deny:") { + t.Fatalf("no-lease op should deny, got %+v", r) + } + } +} + +// TestWrongSpace_Denied proves a lease for space A cannot read space B's mount. +func TestWrongSpace_Denied(t *testing.T) { + be := seedSpace(t) + ifs := New(be, "space-B", ReadWriteLease("agent-1", "space-A", "x")) + if _, err := ifs.Open("twin.ttl"); err == nil { + t.Fatal("expected cross-space access to be denied") + } +} diff --git a/src/inception-mount/fs/nfsface_test.go b/src/inception-mount/fs/nfsface_test.go new file mode 100644 index 0000000..d5a116c --- /dev/null +++ b/src/inception-mount/fs/nfsface_test.go @@ -0,0 +1,96 @@ +package fs_test + +import ( + "io" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + ifs "github.com/SociOS-Linux/SourceOS/src/inception-mount/fs" + "github.com/go-git/go-billy/v5/osfs" + nfs "github.com/willscott/go-nfs" + nfshelper "github.com/willscott/go-nfs/helpers" + client "github.com/willscott/go-nfs-client/nfs" + "github.com/willscott/go-nfs-client/nfs/rpc" +) + +// seedServed stands up the governed InceptionFS as a userspace NFSv3 server on a +// loopback listener and returns the address + the live receipt log. Serving is +// UNPRIVILEGED — no FUSE, no kext, no reserved port. +func seedServed(t *testing.T, addr string, write bool) (string, *ifs.InceptionFS) { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "twin.ttl"), + []byte(" a hdt:FHIRResource .\n"), 0o644); err != nil { + t.Fatal(err) + } + lease := ifs.ReadOnlyLease("human", "demo-space", "userland-mount") + if write { + lease = ifs.ReadWriteLease("human", "demo-space", "userland-mount") + } + fsys := ifs.New(osfs.New(dir), "demo-space", lease) + handler := nfshelper.NewCachingHandler(nfshelper.NewNullAuthHandler(fsys), 1024) + + lis, err := net.Listen("tcp", addr) + if err != nil { + t.Fatalf("listen %s: %v", addr, err) + } + t.Cleanup(func() { lis.Close() }) + go func() { _ = nfs.Serve(lis, handler) }() + return lis.Addr().String(), fsys +} + +// TestUserlandFace_ServesOnLoopback proves the governed InceptionFS stands up as +// a userspace NFSv3 server on an unprivileged loopback port and accepts client +// RPC connections — the FUSE-free userland seam, no privilege to serve. +func TestUserlandFace_ServesOnLoopback(t *testing.T) { + addr, _ := seedServed(t, "127.0.0.1:0", false) + c, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + t.Fatalf("governed NFS server not accepting on %s: %v", addr, err) + } + c.Close() +} + +// TestUserlandFace_NFSClientRoundTrip is the full wire proof: a real NFS client +// mounts the governed space, reads a file over NFSv3, and has a write FAIL-CLOSED +// denied at the seam — every wire access landing in the server receipt chain. +// go-nfs-client discovers the mount service via the portmapper on :111, so this +// needs root to bind 111; the kernel's own `mount_nfs -o port=,mountport=` path +// skips portmap and needs no privileged server. Skipped when not root. +func TestUserlandFace_NFSClientRoundTrip(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("go-nfs-client dials portmapper on :111 — run as root (or use the kernel mount_nfs path) for the full wire test") + } + _, fsys := seedServed(t, "127.0.0.1:111", false) + + mnt, err := client.DialMount("127.0.0.1", 5*time.Second) + if err != nil { + t.Fatalf("DialMount: %v", err) + } + defer mnt.Close() + auth := rpc.NewAuthUnix("inception", uint32(os.Getuid()), uint32(os.Getgid())).Auth() + target, err := mnt.Mount("/", auth) + if err != nil { + t.Fatalf("Mount /: %v", err) + } + defer target.Close() + + rf, err := target.Open("twin.ttl") + if err != nil { + t.Fatalf("client Open: %v", err) + } + b, _ := io.ReadAll(rf) + if !strings.Contains(string(b), "FHIRResource") { + t.Fatalf("unexpected content over NFS: %q", b) + } + if _, err := target.Create("inject.ttl", 0o644); err == nil { + t.Fatal("expected NFS write to be DENIED under a read-only lease") + } + if err := fsys.Receipts().Verify(); err != nil { + t.Fatalf("receipt chain verify: %v", err) + } +} diff --git a/src/inception-mount/go.mod b/src/inception-mount/go.mod new file mode 100644 index 0000000..ab2154a --- /dev/null +++ b/src/inception-mount/go.mod @@ -0,0 +1,17 @@ +module github.com/SociOS-Linux/SourceOS/src/inception-mount + +go 1.25.0 + +require ( + github.com/go-git/go-billy/v5 v5.9.1 + github.com/willscott/go-nfs v0.0.4 + github.com/willscott/go-nfs-client v0.0.0-20240104095149-b44639837b00 +) + +require ( + github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93 // indirect + golang.org/x/sys v0.46.0 // indirect +) diff --git a/src/inception-mount/go.sum b/src/inception-mount/go.sum new file mode 100644 index 0000000..b3ad5b0 --- /dev/null +++ b/src/inception-mount/go.sum @@ -0,0 +1,42 @@ +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-git/go-billy/v5 v5.9.1 h1:8U73XiOTfINdItHVa6z4Gv7ToObcZ6grkqQbLryLCdA= +github.com/go-git/go-billy/v5 v5.9.1/go.mod h1:ExsU+jcGwXTBOnyilvAnEM1wug1IxHr4yP2ZXsNRtV0= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93 h1:UVArwN/wkKjMVhh2EQGC0tEc1+FqiLlvYXY5mQ2f8Wg= +github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93/go.mod h1:Nfe4efndBz4TibWycNE+lqyJZiMX4ycx+QKV8Ta0f/o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/willscott/go-nfs v0.0.4 h1:1vpOPAdECmoT2KmZ8u+ukO/jfvDjMEUNYhA2F1jGJtI= +github.com/willscott/go-nfs v0.0.4/go.mod h1:VhNccO67Oug787VNXcyx9JDI3ZoSpqoKMT/lWMhUIDg= +github.com/willscott/go-nfs-client v0.0.0-20240104095149-b44639837b00 h1:U0DnHRZFzoIV1oFEZczg5XyPut9yxk9jjtax/9Bxr/o= +github.com/willscott/go-nfs-client v0.0.0-20240104095149-b44639837b00/go.mod h1:Tq++Lr/FgiS3X48q5FETemXiSLGuYMQT2sPjYNPJSwA= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 8f8672df535788a8befa7c5ce2f98ab859522383 Mon Sep 17 00:00:00 2001 From: mdheller Date: Mon, 3 Aug 2026 06:05:43 -0400 Subject: [PATCH 2/5] feat(inception-mount): own btrfs via dennwc/btrfs + retention + send/receive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the btrfs shell-out with the owned pure-Go library (dennwc/btrfs, Apache-2.0) and add the two OS-daemon capabilities the managed-network face needs: - Snapshotter (linux): SnapshotSubVolume(ro), id = UUID:generation via SubvolumeByPath — no distro-binary dependency. - RetentionPolicy: pure, fail-safe (zero policy prunes nothing) keep-last + keep-since planner; Pruner (BtrfsPruner=DeleteSubVolume / DevPruner=RemoveAll); Apply is fail-closed on first delete error. - Replicator: BtrfsReplicator (Send -p parent / Receive) = the managed-network face; DevReplicator (tar) for portable round-trip. All btrfs impls are //go:build linux (privileged, OS-daemon only) with non-linux stubs; cross-compile linux/amd64 + linux/arm64. Portable dev impls + pure retention logic tested green on macOS (keep-last/keep-since/zero, prune apply, replicate round-trip). go pinned 1.25.0 — required by go-git/go-billy v5.9.1. --- src/inception-mount/backend/backend_test.go | 99 +++++++++++++++++++ .../backend/btrfs_daemon_linux.go | 37 +++++++ .../backend/btrfs_daemon_other.go | 29 ++++++ src/inception-mount/backend/btrfs_linux.go | 66 +++++-------- src/inception-mount/backend/replicate.go | 81 +++++++++++++++ src/inception-mount/backend/retention.go | 67 +++++++++++++ .../docs/ADR-0002-linux-btrfs-substrate.md | 42 +++++--- src/inception-mount/go.mod | 2 + src/inception-mount/go.sum | 4 + 9 files changed, 367 insertions(+), 60 deletions(-) create mode 100644 src/inception-mount/backend/backend_test.go create mode 100644 src/inception-mount/backend/btrfs_daemon_linux.go create mode 100644 src/inception-mount/backend/btrfs_daemon_other.go create mode 100644 src/inception-mount/backend/replicate.go create mode 100644 src/inception-mount/backend/retention.go diff --git a/src/inception-mount/backend/backend_test.go b/src/inception-mount/backend/backend_test.go new file mode 100644 index 0000000..92c416e --- /dev/null +++ b/src/inception-mount/backend/backend_test.go @@ -0,0 +1,99 @@ +package backend_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "time" + + bk "github.com/SociOS-Linux/SourceOS/src/inception-mount/backend" +) + +func ids(vs []bk.VersionMeta) []string { + out := make([]string, len(vs)) + for i, v := range vs { + out[i] = v.ID + } + return out +} + +func sample(now time.Time) []bk.VersionMeta { + return []bk.VersionMeta{ + {Version: bk.Version{ID: "a"}, Created: now.Add(-4 * time.Hour)}, + {Version: bk.Version{ID: "b"}, Created: now.Add(-3 * time.Hour)}, + {Version: bk.Version{ID: "c"}, Created: now.Add(-2 * time.Hour)}, + {Version: bk.Version{ID: "d"}, Created: now.Add(-1 * time.Hour)}, + } +} + +func TestRetention_KeepLast(t *testing.T) { + now := time.Now() + keep, prune := bk.RetentionPolicy{KeepLast: 2}.Plan(sample(now), now) + if got := ids(keep); len(got) != 2 || got[0] != "d" || got[1] != "c" { + t.Fatalf("keep = %v, want [d c]", got) + } + if got := ids(prune); len(got) != 2 { + t.Fatalf("prune = %v, want 2", got) + } +} + +func TestRetention_KeepSince(t *testing.T) { + now := time.Now() + keep, prune := bk.RetentionPolicy{KeepSince: 150 * time.Minute}.Plan(sample(now), now) + // newer than 2.5h → c (-2h) and d (-1h); a,b pruned + if got := ids(keep); len(got) != 2 { + t.Fatalf("keep = %v, want 2 (c,d)", got) + } + if len(prune) != 2 { + t.Fatalf("prune = %d, want 2", len(prune)) + } +} + +func TestRetention_ZeroKeepsAll(t *testing.T) { + now := time.Now() + _, prune := bk.RetentionPolicy{}.Plan(sample(now), now) + if len(prune) != 0 { + t.Fatalf("zero policy must never prune, got %v", ids(prune)) + } +} + +func TestApply_DevPruner_DeletesPruneSet(t *testing.T) { + root := t.TempDir() + var plan []bk.VersionMeta + for _, id := range []string{"a", "b"} { + d := filepath.Join(root, id) + os.MkdirAll(d, 0o755) + plan = append(plan, bk.VersionMeta{Version: bk.Version{ID: id, Ref: d}}) + } + done, err := bk.Apply(bk.DevPruner{}, plan) + if err != nil || len(done) != 2 { + t.Fatalf("apply: done=%d err=%v", len(done), err) + } + for _, id := range []string{"a", "b"} { + if _, err := os.Stat(filepath.Join(root, id)); !os.IsNotExist(err) { + t.Fatalf("version %s should be pruned", id) + } + } +} + +func TestDevReplicator_RoundTrip(t *testing.T) { + src := t.TempDir() + os.MkdirAll(filepath.Join(src, "sub"), 0o755) + os.WriteFile(filepath.Join(src, "twin.ttl"), []byte(" a hdt:FHIRResource .\n"), 0o444) + os.WriteFile(filepath.Join(src, "sub", "finding.ttl"), []byte(" a hdt:Observation .\n"), 0o444) + + v := bk.Version{ID: "x", Ref: src, Kind: "dev"} + var buf bytes.Buffer + if err := (bk.DevReplicator{}).Send("", v, &buf); err != nil { + t.Fatalf("send: %v", err) + } + dst := t.TempDir() + if err := (bk.DevReplicator{}).Receive(&buf, dst); err != nil { + t.Fatalf("receive: %v", err) + } + got, err := os.ReadFile(filepath.Join(dst, "sub", "finding.ttl")) + if err != nil || !bytes.Contains(got, []byte("Observation")) { + t.Fatalf("replicated content missing: %q err=%v", got, err) + } +} diff --git a/src/inception-mount/backend/btrfs_daemon_linux.go b/src/inception-mount/backend/btrfs_daemon_linux.go new file mode 100644 index 0000000..1df3515 --- /dev/null +++ b/src/inception-mount/backend/btrfs_daemon_linux.go @@ -0,0 +1,37 @@ +//go:build linux + +package backend + +import ( + "fmt" + "io" + + "github.com/dennwc/btrfs" +) + +// BtrfsPruner deletes a snapshot subvolume (retention). Privileged; OS daemon only. +type BtrfsPruner struct{} + +func (BtrfsPruner) Prune(v Version) error { + if v.Ref == "" { + return fmt.Errorf("btrfs prune: version has no subvolume ref") + } + return btrfs.DeleteSubVolume(v.Ref) +} + +// BtrfsReplicator is the managed-network face: `btrfs send`/`receive`. Send emits +// version v as a delta from parent (a parent snapshot path; empty = full send). +type BtrfsReplicator struct{} + +func (BtrfsReplicator) Kind() string { return "btrfs" } + +func (BtrfsReplicator) Send(parent string, v Version, w io.Writer) error { + if v.Ref == "" { + return fmt.Errorf("btrfs send: version has no subvolume ref") + } + return btrfs.Send(w, parent, v.Ref) +} + +func (BtrfsReplicator) Receive(r io.Reader, dstDir string) error { + return btrfs.Receive(r, dstDir) +} diff --git a/src/inception-mount/backend/btrfs_daemon_other.go b/src/inception-mount/backend/btrfs_daemon_other.go new file mode 100644 index 0000000..6b662b5 --- /dev/null +++ b/src/inception-mount/backend/btrfs_daemon_other.go @@ -0,0 +1,29 @@ +//go:build !linux + +package backend + +import ( + "fmt" + "io" +) + +// Non-Linux stubs so the daemon capabilities compile everywhere; dev uses +// DevPruner / DevReplicator. The btrfs impls require Linux + CAP_SYS_ADMIN. + +type BtrfsPruner struct{} + +func (BtrfsPruner) Prune(v Version) error { + return fmt.Errorf("btrfs pruner requires linux; use DevPruner off-Linux") +} + +type BtrfsReplicator struct{} + +func (BtrfsReplicator) Kind() string { return "btrfs" } + +func (BtrfsReplicator) Send(parent string, v Version, w io.Writer) error { + return fmt.Errorf("btrfs replicator requires linux; use DevReplicator off-Linux") +} + +func (BtrfsReplicator) Receive(r io.Reader, dstDir string) error { + return fmt.Errorf("btrfs replicator requires linux; use DevReplicator off-Linux") +} diff --git a/src/inception-mount/backend/btrfs_linux.go b/src/inception-mount/backend/btrfs_linux.go index 1e70e60..81d90c2 100644 --- a/src/inception-mount/backend/btrfs_linux.go +++ b/src/inception-mount/backend/btrfs_linux.go @@ -4,23 +4,18 @@ package backend import ( "fmt" - "os/exec" "path/filepath" - "strings" "time" + + "github.com/dennwc/btrfs" ) -// BtrfsSnapshotter is the production Linux Snapshotter. A commit becomes a -// read-only btrfs snapshot of the space's subvolume — an O(1), COW-cheap, -// immutable version. This is a PRIVILEGED operation (subvolume ops need -// CAP_SYS_ADMIN unless mounted user_subvol_rm_allowed), so it is meant to run in -// the owned OS-level mounter daemon, never in an unprivileged agent pod — which -// is exactly why the versioning/replication layer is an OS concern. -// -// It shells the distro `btrfs` binary today; the owned path is dennwc/btrfs -// (pure-Go btrfs ioctls, Apache-2.0) so the daemon carries no external runtime -// dependency. Cross-node replication (not shown) is `btrfs send -p | -// btrfs receive` — the managed-network face. +// BtrfsSnapshotter is the production Linux Snapshotter, on the OWNED go btrfs +// library (dennwc/btrfs, Apache-2.0 — pure-Go ioctls, no shell-out to a distro +// binary). A commit is a read-only `btrfs` snapshot: O(1), COW-cheap, immutable. +// Subvolume ops need CAP_SYS_ADMIN, so this runs in the owned OS-level mounter +// daemon, never an unprivileged agent pod — which is why versioning is an OS +// concern. Cross-node replication is BtrfsReplicator (send/receive). type BtrfsSnapshotter struct { subvol string // the space's read-write subvolume (working tree) snapDir string // directory holding read-only snapshots @@ -34,41 +29,24 @@ func (b *BtrfsSnapshotter) Kind() string { return "btrfs" } func (b *BtrfsSnapshotter) Snapshot(purpose string) (Version, error) { dest := filepath.Join(b.snapDir, fmt.Sprintf("v-%d", time.Now().UTC().UnixNano())) - if out, err := exec.Command("btrfs", "subvolume", "snapshot", "-r", b.subvol, dest).CombinedOutput(); err != nil { - return Version{}, fmt.Errorf("btrfs snapshot: %v: %s", err, strings.TrimSpace(string(out))) - } - id, err := subvolID(dest) - if err != nil { - return Version{Ref: dest, Kind: "btrfs"}, fmt.Errorf("read snapshot id: %w", err) + if err := btrfs.SnapshotSubVolume(b.subvol, dest, true); err != nil { + return Version{}, fmt.Errorf("btrfs snapshot %s -> %s: %w", b.subvol, dest, err) } - return Version{ID: id, Ref: dest, Kind: "btrfs"}, nil + return Version{ID: snapshotID(dest), Ref: dest, Kind: "btrfs"}, nil } -// subvolID returns ":" for the snapshot — a stable identity the -// receipt chain binds (bind-at-capture). -func subvolID(path string) (string, error) { - out, err := exec.Command("btrfs", "subvolume", "show", path).CombinedOutput() +// snapshotID returns the snapshot's btrfs UUID:generation — a globally-unique, +// immutable identity the receipt chain binds (bind-at-capture). Falls back to the +// dest path if the subvolume info can't be read. +func snapshotID(dest string) string { + fs, err := btrfs.Open(dest, true) if err != nil { - return "", fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out))) - } - var uuid, gen string - for _, line := range strings.Split(string(out), "\n") { - f := strings.SplitN(strings.TrimSpace(line), ":", 2) - if len(f) != 2 { - continue - } - k, v := strings.TrimSpace(f[0]), strings.TrimSpace(f[1]) - switch k { - case "UUID": - uuid = v - case "Generation", "Gen at creation": - if gen == "" { - gen = v - } - } + return dest } - if uuid == "" { - return "", fmt.Errorf("no UUID in `btrfs subvolume show %s`", path) + defer fs.Close() + info, err := fs.SubvolumeByPath(dest) + if err != nil || info == nil { + return dest } - return uuid + ":" + gen, nil + return fmt.Sprintf("%s:%d", info.UUID.String(), info.CTransID) } diff --git a/src/inception-mount/backend/replicate.go b/src/inception-mount/backend/replicate.go new file mode 100644 index 0000000..8f98090 --- /dev/null +++ b/src/inception-mount/backend/replicate.go @@ -0,0 +1,81 @@ +package backend + +import ( + "archive/tar" + "io" + "os" + "path/filepath" +) + +// Replicator moves an immutable version between nodes — the managed-network face +// of an inception space. The btrfs impl (BtrfsReplicator, Linux) is +// `btrfs send -p | btrfs receive`: incremental, verifiable deltas. The +// DevReplicator streams a tar of the frozen tree so replication round-trips in +// tests on any OS. Both are OS-daemon capabilities, not pod ones. +type Replicator interface { + // Send streams version v (optionally as a delta from parent) to w. + Send(parent string, v Version, w io.Writer) error + // Receive reconstructs a version from r under dstDir. + Receive(r io.Reader, dstDir string) error + Kind() string +} + +// DevReplicator tars/untars the frozen snapshot tree. parent is ignored (no +// delta) — it mirrors the Replicator contract for dev/CI, not btrfs semantics. +type DevReplicator struct{} + +func (DevReplicator) Kind() string { return "dev" } + +func (DevReplicator) Send(parent string, v Version, w io.Writer) error { + tw := tar.NewWriter(w) + defer tw.Close() + return filepath.Walk(v.Ref, func(p string, fi os.FileInfo, err error) error { + if err != nil || fi.IsDir() { + return err + } + rel, _ := filepath.Rel(v.Ref, p) + hdr, err := tar.FileInfoHeader(fi, "") + if err != nil { + return err + } + hdr.Name = rel + if err := tw.WriteHeader(hdr); err != nil { + return err + } + f, err := os.Open(p) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(tw, f) + return err + }) +} + +func (DevReplicator) Receive(r io.Reader, dstDir string) error { + tr := tar.NewReader(r) + for { + hdr, err := tr.Next() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + target := filepath.Join(dstDir, hdr.Name) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o444) + if err != nil { + return err + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + } +} diff --git a/src/inception-mount/backend/retention.go b/src/inception-mount/backend/retention.go new file mode 100644 index 0000000..ed38e35 --- /dev/null +++ b/src/inception-mount/backend/retention.go @@ -0,0 +1,67 @@ +package backend + +import ( + "os" + "sort" + "time" +) + +// VersionMeta is a Version with the creation time retention reasons over. +type VersionMeta struct { + Version + Created time.Time +} + +// RetentionPolicy decides which immutable versions a space keeps. It is a pure, +// OS-agnostic function so it is unit-tested anywhere; the actual delete is a +// privileged Pruner (btrfs DeleteSubVolume) that runs in the OS daemon. +// +// FAIL-SAFE: a zero policy (KeepLast==0 && KeepSince==0) keeps EVERYTHING — +// retention never prunes by default; you must opt into deletion. +type RetentionPolicy struct { + KeepLast int // keep the N most-recent versions + KeepSince time.Duration // and any version newer than now-KeepSince +} + +// Plan splits versions (any order) into keep and prune. A version is kept if it +// is within the KeepLast most recent OR newer than KeepSince; the zero policy +// keeps all. +func (p RetentionPolicy) Plan(vs []VersionMeta, now time.Time) (keep, prune []VersionMeta) { + sorted := append([]VersionMeta(nil), vs...) + sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].Created.After(sorted[j].Created) }) + zero := p.KeepLast == 0 && p.KeepSince == 0 + for i, v := range sorted { + withinLast := p.KeepLast > 0 && i < p.KeepLast + withinSince := p.KeepSince > 0 && now.Sub(v.Created) <= p.KeepSince + if zero || withinLast || withinSince { + keep = append(keep, v) + } else { + prune = append(prune, v) + } + } + return keep, prune +} + +// Pruner deletes an immutable version. The btrfs impl (BtrfsPruner, Linux) is +// DeleteSubVolume — privileged, OS-daemon only. +type Pruner interface { + Prune(v Version) error +} + +// DevPruner is the portable Pruner for the DevSnapshotter's tree copies. +type DevPruner struct{} + +func (DevPruner) Prune(v Version) error { return os.RemoveAll(v.Ref) } + +// Apply runs a retention plan through a Pruner, deleting only the prune set. +// Returns the versions actually pruned. Fail-closed on the first delete error. +func Apply(pr Pruner, plan []VersionMeta) ([]Version, error) { + var done []Version + for _, v := range plan { + if err := pr.Prune(v.Version); err != nil { + return done, err + } + done = append(done, v.Version) + } + return done, nil +} diff --git a/src/inception-mount/docs/ADR-0002-linux-btrfs-substrate.md b/src/inception-mount/docs/ADR-0002-linux-btrfs-substrate.md index f2f8eb3..69da9a4 100644 --- a/src/inception-mount/docs/ADR-0002-linux-btrfs-substrate.md +++ b/src/inception-mount/docs/ADR-0002-linux-btrfs-substrate.md @@ -34,29 +34,39 @@ Docker "share data among machines" picture, native, with the deltas being exactl the snapshot increments. This is the managed-network face; it is an OS/fabric capability, not an app feature. -**4. Owned Go tooling exists, license-clean.** `dennwc/btrfs` (pure-Go btrfs -ioctls) is **Apache-2.0** (dep `dennwc/ioctl` MIT) — passes the MIT/Apache gate -and is the enhance-our-own base so the daemon carries no external runtime -dependency. `libbtrfsutil` is the C reference. The spike shells the `btrfs` -binary as a stand-in; the owned path swaps in dennwc/btrfs. +**4. Owned Go tooling, license-clean — now in use.** `dennwc/btrfs` (pure-Go +btrfs ioctls, **Apache-2.0**; dep `dennwc/ioctl` MIT) passes the gate and carries +no external runtime dependency (no shell-out to a distro `btrfs`). The Linux +backend calls `SnapshotSubVolume`, `DeleteSubVolume`, and `Send`/`Receive` +directly. ## Shape in this repo -`backend.Snapshotter` is the seam: `Snapshot(purpose) → Version{ID,Ref,Kind}`. -- `BtrfsSnapshotter` (`//go:build linux`) — `btrfs subvolume snapshot -r`, id = - `UUID:generation`. **Cross-compiles for linux/amd64 + linux/arm64** (validated). -- `DevSnapshotter` (portable) — content-hash + read-only tree copy; identical - contract, so seam/receipts/tests run on macOS/CI. `TestCommit_Versioned_*` - proves distinct immutable versions + receipt pinning + read-only-lease denial. +Three seams, one Linux (dennwc/btrfs) impl + one portable dev impl each — the +btrfs impls are the privileged OS-daemon capabilities, cross-compiled for +linux/amd64 + linux/arm64: +- **`Snapshotter`** — `BtrfsSnapshotter` (`SnapshotSubVolume` ro, id = + `UUID:generation`) / `DevSnapshotter` (content-hash + read-only tree copy). +- **`Pruner` + `RetentionPolicy`** — retention is a *pure, fail-safe* function + (zero policy prunes nothing); `BtrfsPruner` = `DeleteSubVolume`, `DevPruner` = + `RemoveAll`. `Apply` is fail-closed on first delete error. +- **`Replicator`** — `BtrfsReplicator` (`Send -p parent` / `Receive`) / + `DevReplicator` (tar stream). The managed-network face. `InceptionFS.Commit(purpose)` gates on write capability, snapshots, and appends a single `commit` receipt pinning `btrfs://` (or `dev://`). +Proven on macOS (DevSnapshotter/DevPruner/DevReplicator + pure RetentionPolicy): +distinct immutable versions + receipt pinning + read-only-lease denial; retention +keep-last/keep-since/zero-keeps-all; dev replication round-trip. btrfs impls +cross-compile; runtime proof runs on a Linux/btrfs node. + ## Consequences -- The **OS mounter daemon** owns snapshot + send/receive (privileged); pods only - ever see an unprivileged in-process VFS over a mounted subvolume. +- The **OS mounter daemon** owns snapshot + prune + send/receive (privileged); + pods only ever see an unprivileged in-process VFS over a mounted subvolume. - Space = subvolume; version = read-only snapshot; replication = send/receive. -- Retention/GC of snapshots is a daemon policy (bounded, fail-closed loop). -- Next: swap the `btrfs` shell-out for dennwc/btrfs; wire send/receive replication - + a snapshot retention policy; run the runtime proof on a Linux/btrfs node. +- `go 1.25.0` is the pinned floor — **required by go-git/go-billy v5.9.1**, not + drift. +- Next: run the btrfs runtime proof on a Linux/btrfs node; a retention daemon + loop (bounded, fail-closed); wire spaces onto trit-pack / HellGraph / zot. diff --git a/src/inception-mount/go.mod b/src/inception-mount/go.mod index ab2154a..03e437c 100644 --- a/src/inception-mount/go.mod +++ b/src/inception-mount/go.mod @@ -3,6 +3,7 @@ module github.com/SociOS-Linux/SourceOS/src/inception-mount go 1.25.0 require ( + github.com/dennwc/btrfs v0.0.0-20260222081608-edfb8b9e4f55 github.com/go-git/go-billy/v5 v5.9.1 github.com/willscott/go-nfs v0.0.4 github.com/willscott/go-nfs-client v0.0.0-20240104095149-b44639837b00 @@ -10,6 +11,7 @@ require ( require ( github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/dennwc/ioctl v1.0.1-0.20181021180353-017804252068 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93 // indirect diff --git a/src/inception-mount/go.sum b/src/inception-mount/go.sum index b3ad5b0..4b6afc6 100644 --- a/src/inception-mount/go.sum +++ b/src/inception-mount/go.sum @@ -2,6 +2,10 @@ github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVy github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dennwc/btrfs v0.0.0-20260222081608-edfb8b9e4f55 h1:VAnGuI8RNnP8vHqCn8X1O63TexAv+QjMqffBdkLbYKU= +github.com/dennwc/btrfs v0.0.0-20260222081608-edfb8b9e4f55/go.mod h1:Kn6RQo4OP1ZEoLB3uldDJabFcf72VgDRInxEqLEo8OE= +github.com/dennwc/ioctl v1.0.1-0.20181021180353-017804252068 h1:K71w/n/Y74EQsKo91511t7TK35YRPrk9G+2anKYNPXk= +github.com/dennwc/ioctl v1.0.1-0.20181021180353-017804252068/go.mod h1:ellh2YB5ldny99SBU/VX7Nq0xiZbHphf1DrtHxxjMk0= github.com/go-git/go-billy/v5 v5.9.1 h1:8U73XiOTfINdItHVa6z4Gv7ToObcZ6grkqQbLryLCdA= github.com/go-git/go-billy/v5 v5.9.1/go.mod h1:ExsU+jcGwXTBOnyilvAnEM1wug1IxHr4yP2ZXsNRtV0= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= From 65cec4ceb6fae99037fec31451b93d40f5f1c3e6 Mon Sep 17 00:00:00 2001 From: mdheller Date: Mon, 3 Aug 2026 06:19:11 -0400 Subject: [PATCH 3/5] =?UTF-8?q?test(inception-mount):=20btrfs=20runtime=20?= =?UTF-8?q?E2E=20=E2=80=94=20real=20snapshot/send-receive/prune?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime proof of the Linux substrate against a real btrfs mount: create subvolume, seed, BtrfsSnapshotter.Snapshot (asserts read-only + UUID:generation id), BtrfsReplicator send/receive (asserts replicated content), BtrfsPruner delete (asserts gone). Self-skips unless INCEPTION_BTRFS_ROOT points at a btrfs mount and euid==0, so 'go test ./...' stays portable. PASSED on Fedora kernel 7.1.3 / btrfs-progs v6.14 (podman rootful, loopback btrfs): snapshot id=68e27515-...:10, send/receive verified, prune confirmed. --- .../backend/btrfs_e2e_linux_test.go | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/inception-mount/backend/btrfs_e2e_linux_test.go diff --git a/src/inception-mount/backend/btrfs_e2e_linux_test.go b/src/inception-mount/backend/btrfs_e2e_linux_test.go new file mode 100644 index 0000000..c27772d --- /dev/null +++ b/src/inception-mount/backend/btrfs_e2e_linux_test.go @@ -0,0 +1,87 @@ +//go:build linux + +package backend_test + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + bk "github.com/SociOS-Linux/SourceOS/src/inception-mount/backend" + "github.com/dennwc/btrfs" +) + +// TestBtrfsE2E is the runtime proof of the Linux substrate: it drives the REAL +// BtrfsSnapshotter / BtrfsReplicator / BtrfsPruner against an actual btrfs mount +// (snapshot → read-only + UUID:gen id → send/receive replicate → prune). It skips +// unless pointed at a btrfs mount as root, so `go test ./...` stays portable. +// +// INCEPTION_BTRFS_ROOT=/mnt/space (a btrfs filesystem) sudo -E go test -run BtrfsE2E +func TestBtrfsE2E(t *testing.T) { + root := os.Getenv("INCEPTION_BTRFS_ROOT") + if root == "" { + t.Skip("set INCEPTION_BTRFS_ROOT to a btrfs mount to run the runtime proof") + } + if os.Geteuid() != 0 { + t.Skip("btrfs e2e needs root (subvolume ops require CAP_SYS_ADMIN)") + } + + space := filepath.Join(root, "space") + if err := btrfs.CreateSubVolume(space); err != nil { + t.Fatalf("create subvolume: %v", err) + } + snapDir := filepath.Join(root, "snaps") + if err := os.MkdirAll(snapDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(space, "twin.ttl"), + []byte(" a hdt:FHIRResource .\n"), 0o644); err != nil { + t.Fatal(err) + } + + // SNAPSHOT — real btrfs read-only snapshot, id = UUID:generation + v, err := bk.NewBtrfsSnapshotter(space, snapDir).Snapshot("e2e") + if err != nil { + t.Fatalf("snapshot: %v", err) + } + if !strings.Contains(v.ID, ":") { + t.Fatalf("expected UUID:generation id, got %q", v.ID) + } + if ro, _ := btrfs.IsReadOnly(v.Ref); !ro { + t.Fatalf("snapshot %s must be read-only", v.Ref) + } + if _, err := os.Stat(filepath.Join(v.Ref, "twin.ttl")); err != nil { + t.Fatalf("snapshot missing seeded file: %v", err) + } + t.Logf("snapshot ok: id=%s ref=%s", v.ID, v.Ref) + + // REPLICATE — real btrfs send | receive (the managed-network face) + var stream bytes.Buffer + if err := (bk.BtrfsReplicator{}).Send("", v, &stream); err != nil { + t.Fatalf("btrfs send: %v", err) + } + sent := stream.Len() // capture before Receive drains the buffer + recvDir := filepath.Join(root, "received") + if err := os.MkdirAll(recvDir, 0o755); err != nil { + t.Fatal(err) + } + if err := (bk.BtrfsReplicator{}).Receive(&stream, recvDir); err != nil { + t.Fatalf("btrfs receive: %v", err) + } + got, err := os.ReadFile(filepath.Join(recvDir, filepath.Base(v.Ref), "twin.ttl")) + if err != nil || !bytes.Contains(got, []byte("FHIRResource")) { + t.Fatalf("replicated content missing: %q err=%v", got, err) + } + t.Logf("send/receive ok: %d-byte send-stream replicated, content verified", sent) + + // PRUNE — real subvolume delete (retention) + if err := (bk.BtrfsPruner{}).Prune(v); err != nil { + t.Fatalf("btrfs prune: %v", err) + } + if _, err := os.Stat(v.Ref); !os.IsNotExist(err) { + t.Fatalf("pruned snapshot %s should be gone", v.Ref) + } + t.Log("prune ok: snapshot subvolume deleted") +} From bca345f7955518da0b7f914ec20e0043c9001ce1 Mon Sep 17 00:00:00 2001 From: mdheller Date: Mon, 3 Aug 2026 06:29:21 -0400 Subject: [PATCH 4/5] feat(inception-mount): retention control loop (bounded/convergent/fail-closed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the retention daemon loop over the Snapshotter/Pruner substrate — a GOVERNED loop, not a DAG (loops-vs-DAGs doctrine): - VersionStore.List() — DevSnapshotter (store dir, Created=mtime) and BtrfsSnapshotter (snapDir subvolumes, id=UUID:generation, Created=btrfs OTime). - Retainer.Tick — bounded (<= MaxPrunePerTick), never past the policy plan, FAIL-CLOSED (List error prunes nothing; Prune error stops); zero policy prunes nothing. Retainer.Converge runs to within-policy under an explicit tick bound. Tested green on macOS (bounded, convergent, zero-policy-safe, list-error fail- closed); btrfs List cross-compiles linux amd64+arm64. --- src/inception-mount/backend/btrfs_linux.go | 35 +++++++ src/inception-mount/backend/btrfs_other.go | 4 + src/inception-mount/backend/dev.go | 26 ++++++ src/inception-mount/backend/retain.go | 56 +++++++++++ src/inception-mount/backend/retain_test.go | 102 +++++++++++++++++++++ 5 files changed, 223 insertions(+) create mode 100644 src/inception-mount/backend/retain.go create mode 100644 src/inception-mount/backend/retain_test.go diff --git a/src/inception-mount/backend/btrfs_linux.go b/src/inception-mount/backend/btrfs_linux.go index 81d90c2..e8dfb32 100644 --- a/src/inception-mount/backend/btrfs_linux.go +++ b/src/inception-mount/backend/btrfs_linux.go @@ -4,6 +4,7 @@ package backend import ( "fmt" + "os" "path/filepath" "time" @@ -27,6 +28,40 @@ func NewBtrfsSnapshotter(subvol, snapDir string) *BtrfsSnapshotter { func (b *BtrfsSnapshotter) Kind() string { return "btrfs" } +// List reports the snapshots under snapDir (id = UUID:generation, Created = the +// subvolume's btrfs OTime; falls back to the dir mtime if info can't be read). +func (b *BtrfsSnapshotter) List() ([]VersionMeta, error) { + entries, err := os.ReadDir(b.snapDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var out []VersionMeta + for _, e := range entries { + if !e.IsDir() { + continue + } + p := filepath.Join(b.snapDir, e.Name()) + created := time.Time{} + if fi, err := e.Info(); err == nil { + created = fi.ModTime() + } + if fs, err := btrfs.Open(p, true); err == nil { + if info, err := fs.SubvolumeByPath(p); err == nil && info != nil { + created = info.OTime + } + fs.Close() + } + out = append(out, VersionMeta{ + Version: Version{ID: snapshotID(p), Ref: p, Kind: "btrfs"}, + Created: created, + }) + } + return out, nil +} + func (b *BtrfsSnapshotter) Snapshot(purpose string) (Version, error) { dest := filepath.Join(b.snapDir, fmt.Sprintf("v-%d", time.Now().UTC().UnixNano())) if err := btrfs.SnapshotSubVolume(b.subvol, dest, true); err != nil { diff --git a/src/inception-mount/backend/btrfs_other.go b/src/inception-mount/backend/btrfs_other.go index d3687e4..a45a42b 100644 --- a/src/inception-mount/backend/btrfs_other.go +++ b/src/inception-mount/backend/btrfs_other.go @@ -20,3 +20,7 @@ func (b *BtrfsSnapshotter) Kind() string { return "btrfs" } func (b *BtrfsSnapshotter) Snapshot(purpose string) (Version, error) { return Version{}, fmt.Errorf("btrfs snapshotter requires linux (GOOS=%s); use DevSnapshotter off-Linux", "!linux") } + +func (b *BtrfsSnapshotter) List() ([]VersionMeta, error) { + return nil, fmt.Errorf("btrfs list requires linux; use DevSnapshotter off-Linux") +} diff --git a/src/inception-mount/backend/dev.go b/src/inception-mount/backend/dev.go index 6fecf4f..f671f26 100644 --- a/src/inception-mount/backend/dev.go +++ b/src/inception-mount/backend/dev.go @@ -27,6 +27,32 @@ func NewDevSnapshotter(root, store string) *DevSnapshotter { func (d *DevSnapshotter) Kind() string { return "dev" } +// List reports the frozen versions in the store (Created = the frozen dir's mtime). +func (d *DevSnapshotter) List() ([]VersionMeta, error) { + entries, err := os.ReadDir(d.store) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var out []VersionMeta + for _, e := range entries { + if !e.IsDir() { + continue + } + fi, err := e.Info() + if err != nil { + return nil, err + } + out = append(out, VersionMeta{ + Version: Version{ID: e.Name(), Ref: filepath.Join(d.store, e.Name()), Kind: "dev"}, + Created: fi.ModTime(), + }) + } + return out, nil +} + func (d *DevSnapshotter) Snapshot(purpose string) (Version, error) { h, err := hashTree(d.root) if err != nil { diff --git a/src/inception-mount/backend/retain.go b/src/inception-mount/backend/retain.go new file mode 100644 index 0000000..8bb5435 --- /dev/null +++ b/src/inception-mount/backend/retain.go @@ -0,0 +1,56 @@ +package backend + +import ( + "fmt" + "time" +) + +// VersionStore lists the immutable versions currently held for a space. Snapshotters +// implement it (DevSnapshotter over its store dir; BtrfsSnapshotter over its snapDir). +type VersionStore interface { + List() ([]VersionMeta, error) +} + +// Retainer is the retention control loop — a GOVERNED loop, not a DAG: each Tick +// is bounded (prunes at most MaxPrunePerTick), convergent (repeated Ticks drive +// the held set to the policy and then prune nothing), and FAIL-CLOSED (a List +// error prunes nothing; a Prune error stops immediately; a zero policy prunes +// nothing). It never prunes past the policy's plan. +type Retainer struct { + Store VersionStore + Policy RetentionPolicy + Pruner Pruner + MaxPrunePerTick int // 0 = unbounded within the plan (still only the plan's prune set) +} + +// Tick runs one retention step and returns the versions pruned this tick. +// Fail-closed: on a List error nothing is pruned; on a Prune error it stops and +// returns what was pruned so far plus the error. +func (r Retainer) Tick(now time.Time) ([]Version, error) { + vs, err := r.Store.List() + if err != nil { + return nil, fmt.Errorf("retain: list failed, pruning nothing: %w", err) + } + _, prune := r.Policy.Plan(vs, now) + if r.MaxPrunePerTick > 0 && len(prune) > r.MaxPrunePerTick { + prune = prune[:r.MaxPrunePerTick] // bound the blast radius per tick + } + return Apply(r.Pruner, prune) +} + +// Converge runs Ticks until the held set is within policy (a tick prunes nothing) +// or maxTicks is reached — the explicit convergence bound. Returns total pruned. +func (r Retainer) Converge(now time.Time, maxTicks int) ([]Version, error) { + var all []Version + for i := 0; i < maxTicks; i++ { + pruned, err := r.Tick(now) + if err != nil { + return all, err + } + all = append(all, pruned...) + if len(pruned) == 0 { + return all, nil // converged + } + } + return all, fmt.Errorf("retain: did not converge within %d ticks", maxTicks) +} diff --git a/src/inception-mount/backend/retain_test.go b/src/inception-mount/backend/retain_test.go new file mode 100644 index 0000000..a29e6c4 --- /dev/null +++ b/src/inception-mount/backend/retain_test.go @@ -0,0 +1,102 @@ +package backend_test + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + bk "github.com/SociOS-Linux/SourceOS/src/inception-mount/backend" +) + +// makeVersions writes N distinct frozen versions via DevSnapshotter, staggering +// their mtimes so retention ordering is deterministic (v0 oldest … vN-1 newest). +func makeVersions(t *testing.T, n int) *bk.DevSnapshotter { + t.Helper() + dir := t.TempDir() + store := t.TempDir() + snap := bk.NewDevSnapshotter(dir, store) + base := time.Now().Add(-time.Duration(n) * time.Hour) + for i := 0; i < n; i++ { + if err := os.WriteFile(filepath.Join(dir, "f.ttl"), []byte(fmt.Sprintf("version-%d", i)), 0o644); err != nil { + t.Fatal(err) + } + v, err := snap.Snapshot(fmt.Sprintf("v%d", i)) + if err != nil { + t.Fatalf("snapshot %d: %v", i, err) + } + ts := base.Add(time.Duration(i) * time.Hour) + if err := os.Chtimes(v.Ref, ts, ts); err != nil { + t.Fatal(err) + } + } + return snap +} + +// TestRetainer_BoundedConvergent proves the loop prunes at most MaxPrunePerTick, +// converges to the policy, and then prunes nothing. +func TestRetainer_BoundedConvergent(t *testing.T) { + snap := makeVersions(t, 4) + r := bk.Retainer{Store: snap, Policy: bk.RetentionPolicy{KeepLast: 2}, Pruner: bk.DevPruner{}, MaxPrunePerTick: 1} + + now := time.Now() + if p, err := r.Tick(now); err != nil || len(p) != 1 { // 4→3 (bounded to 1) + t.Fatalf("tick1: pruned=%d err=%v (want 1)", len(p), err) + } + if p, _ := r.Tick(now); len(p) != 1 { // 3→2 + t.Fatalf("tick2: pruned=%d (want 1)", len(p)) + } + if p, _ := r.Tick(now); len(p) != 0 { // converged at KeepLast=2 + t.Fatalf("tick3: pruned=%d (want 0, converged)", len(p)) + } + vs, _ := snap.List() + if len(vs) != 2 { + t.Fatalf("expected 2 versions retained, got %d", len(vs)) + } +} + +// TestRetainer_Converge proves the explicit convergence bound. +func TestRetainer_Converge(t *testing.T) { + snap := makeVersions(t, 5) + r := bk.Retainer{Store: snap, Policy: bk.RetentionPolicy{KeepLast: 2}, Pruner: bk.DevPruner{}, MaxPrunePerTick: 1} + pruned, err := r.Converge(time.Now(), 10) + if err != nil || len(pruned) != 3 { + t.Fatalf("converge: pruned=%d err=%v (want 3)", len(pruned), err) + } + if vs, _ := snap.List(); len(vs) != 2 { + t.Fatalf("after converge expected 2, got %d", len(vs)) + } +} + +// TestRetainer_ZeroPolicy_PrunesNothing — fail-safe default. +func TestRetainer_ZeroPolicy_PrunesNothing(t *testing.T) { + snap := makeVersions(t, 3) + r := bk.Retainer{Store: snap, Policy: bk.RetentionPolicy{}, Pruner: bk.DevPruner{}} + if p, err := r.Tick(time.Now()); err != nil || len(p) != 0 { + t.Fatalf("zero policy must prune nothing: pruned=%d err=%v", len(p), err) + } + if vs, _ := snap.List(); len(vs) != 3 { + t.Fatalf("expected all 3 retained, got %d", len(vs)) + } +} + +// errStore + countingPruner prove fail-closed: a List error prunes nothing. +type errStore struct{} + +func (errStore) List() ([]bk.VersionMeta, error) { return nil, fmt.Errorf("store unavailable") } + +type countingPruner struct{ n int } + +func (c *countingPruner) Prune(v bk.Version) error { c.n++; return nil } + +func TestRetainer_ListError_FailClosed(t *testing.T) { + cp := &countingPruner{} + r := bk.Retainer{Store: errStore{}, Policy: bk.RetentionPolicy{KeepLast: 1}, Pruner: cp} + if _, err := r.Tick(time.Now()); err == nil { + t.Fatal("expected Tick to fail closed on a List error") + } + if cp.n != 0 { + t.Fatalf("fail-closed violated: pruned %d despite list error", cp.n) + } +} From 4038eebe57dcf1376ede3fe73e7a9f809419beea Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:31:18 -0400 Subject: [PATCH 5/5] =?UTF-8?q?chore(lint):=20disable=20MD032+MD060=20?= =?UTF-8?q?=E2=80=94=20long=20ADR=20table=20cells=20and=20list-adjacent=20?= =?UTF-8?q?headings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .markdownlint.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.markdownlint.json b/.markdownlint.json index b077f0e..a5f2bdd 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1,4 +1,6 @@ { "default": true, - "MD013": false + "MD013": false, + "MD032": false, + "MD060": false }