diff --git a/.taproot-verify/frames.json b/.taproot-verify/frames.json new file mode 100644 index 0000000..2caab7f --- /dev/null +++ b/.taproot-verify/frames.json @@ -0,0 +1,37 @@ +[ + { + "cmd": "scan . --include-env --apply", + "out": "TAPROOT SCAN\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\ndir: .\n\nruntimes: 2\n node 20.5.0 (pinned)\n python 3.11.4 (pinned)\n\ncontainers: 2\n cache redis:7 \u2192 7\n db postgres:15.3 \u2192 15.3\n\nenv-vars: 2\n DATABASE_URL=postgres://localhost/app\n NODE_ENV=development\n\nskipped 1 value(s) that look like secrets:\n STRIPE_SECRET line 3: key name marks it as a secret\n\napplied: sha256:a3b7c737e463 \u2192 /root/prdemo/proj/.taproot/state.json", + "err": "", + "rc": 0, + "note": "detects the real environment and signs it" + }, + { + "cmd": "mount --no-fuse", + "out": "TAPROOT MOUNT\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nrepo: proj\nbase: main@9d2ceaf\nstate: signed \u00b7 sha256:a3b7c737e463\nruntimes: 2\n - node: 20.5.0 (pinned=true)\n - python: 3.11.4 (pinned=true)\ncontainers: 2\n - cache: 7 (redis:7)\n - db: 15.3 (postgres:15.3)\nenv-vars: 2\n\nmount: (none \u2014 materializing a tree)\nhash: a3b7c737e4630052d26fe5987b8335ac1f62cf476b8e920e18495c4faf40955c\n\n(no-fuse \u2014 wrote tree to /root/prdemo/proj/.taproot/mnt)\nenv: /root/prdemo/proj/.taproot/mnt/env (writable \u2014 edit, then run `taproot sync --from-dir`)\nstatus: \u25b6 INHERITED \u2014 ready to work", + "err": "", + "rc": 0, + "note": "materializes the tree, no FUSE needed" + }, + { + "cmd": "sync --from-dir .taproot/mnt --dry-run", + "out": "captured drift from .taproot/mnt \u2192 .taproot/state.drift.json\nTAPROOT SYNC\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nbaseline: sha256:a3b7c737e4630052d26fe5987b8335ac1f62cf476b8e920e18495c4faf40955c (/root/prdemo/proj/.taproot/state.json)\ndrift: sha256:498b0fe05504554687057e6ee9e9fe8d643949a39ef4c7f5bd8ff428e9e044a5 (/root/prdemo/proj/.taproot/state.drift.json)\n\ndrift (1 field):\n + env_vars.REDIS_URL\n actual: redis://localhost:6379\n severity: Breaking\n\ndry-run \u2014 nothing adopted.\n[re-run without --dry-run to sign and adopt]", + "err": "", + "rc": 0, + "note": "drift is captured without a kernel mount" + }, + { + "cmd": "sync --from-dir .taproot/mnt", + "out": "captured drift from .taproot/mnt \u2192 .taproot/state.drift.json\nTAPROOT SYNC\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nbaseline: sha256:a3b7c737e4630052d26fe5987b8335ac1f62cf476b8e920e18495c4faf40955c (/root/prdemo/proj/.taproot/state.json)\ndrift: sha256:1783901812724a9fc9ca001fef686484adb0c2e48e0b9cce7f4c8f421a5b4136 (/root/prdemo/proj/.taproot/state.drift.json)\n\ndrift (1 field):\n + env_vars.REDIS_URL\n actual: redis://localhost:6379\n severity: Breaking\n\nsigning with key mykey (V3/yzCGoSvV3SY45)\nadopted: sha256:1783901812724a9fc9ca001fef686484adb0c2e48e0b9cce7f4c8f421a5b4136\npath: /root/prdemo/proj/.taproot/state.json\n\nstatus: \u25b6 INHERITED \u2014 ready to work", + "err": "", + "rc": 0, + "note": "review, sign, adopt" + }, + { + "cmd": "registry log proj main", + "out": "TAPROOT REGISTRY LOG\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nrepo: proj\nbranch: main\nregistry: /root/prdemo/proj/.taproot/registry\n\n* sha256:178390181272 main@9d2ceaf signed\n\n1 entr(ies), newest first", + "err": "", + "rc": 0, + "note": "history now walks the branch" + } +] \ No newline at end of file diff --git a/.taproot-verify/loop.gif b/.taproot-verify/loop.gif new file mode 100644 index 0000000..ed62be1 Binary files /dev/null and b/.taproot-verify/loop.gif differ diff --git a/.taproot-verify/loop.png b/.taproot-verify/loop.png new file mode 100644 index 0000000..251fbdd Binary files /dev/null and b/.taproot-verify/loop.png differ diff --git a/CD_res/implementation/taproot/taproot-research.md b/CD_res/implementation/taproot/taproot-research.md new file mode 100644 index 0000000..4132527 --- /dev/null +++ b/CD_res/implementation/taproot/taproot-research.md @@ -0,0 +1,161 @@ +# Implementation Research: Taproot — FUSE Mount CLI + Signed State Fabric + +## The Task +**What:** Taproot — *state inheritance fabric between VCS and CI*. `Environment-as-object: state you inherit, sign, and never reproduce.` +Wedge is a FUSE mount CLI that lazily materializes git repos as signed environment snapshots (`taproot mount ~/projects/myapp` → `python 3.11.4 pinned`, `node 20.5.0`, `postgres 15.3 container signed`, 12 env-vars, lazy 2.4 GB, block-on-drift with `[s]ync·[f]ork·[d]etach`). +**Stack:** Rust `2024 edition` / `1.90.0+`, `fuser 0.17.x–0.18.0` (pure-Rust, optional libfuse3), `tokio 1.48`, `clap 4.4`, `libfuse3/fuse3` kernel driver, OCI 1.1 referrers + Sigstore/Cosign bundles for signed registry. State serialization engine (Rust) already done per README; building wedge primitive next: FUSE mount CLI, GitHub Action + baseline check, signed state registry, managed fabric API. +**Constraints:** Linux-only at wedge, unprivileged mount on `ubuntu-latest` GitHub Actions runner, must not leave dangling mounts on crash, must survive 10k+ `getattr` storms on `ls`/`prompt`, must preserve kernel permission semantics, must sign with content-addressable proof (Sigstore), must stay MIT-auditable for mount/protocol/schema. + +--- + +## 1. Common Gotchas + +### FUSE / fuser Gotchas (most load-bearing) + +- **UID/GID must be real, not 0.** Set `FileAttr.uid/gid = getuid()/getgid()` — returning 0 with `DefaultPermissions` denies everything for uid 1000. Per-request `req.uid()/gid()` is for audit, not auth (unless `AllowOther`). Source: `reposix 06-gotchas.md §6.1` + `fuser Config` docs. +- **`st_size` must be exact.** Kernel short-circuits `read` at 0. For lazy state where size unknown until fetch, fetch metadata eagerly in `lookup` or return conservative estimate — never 0 for non-empty. After `setattr(resize)`, reply with `TTL=0` or next `stat` is stale. Source: `06-gotchas.md §6.2`. +- **Writes are chunked (≤128 KiB default) + `release` is commit boundary.** Editor saving 300 KiB → 3×`write` → `flush` → `release`. Do NOT ack upstream registry/Git until `release`. Accumulate per-fh buffer + batched flush on `release`. Optionally raise via `KernelConfig::set_max_write` in `init`. Use `tokio::sync::Mutex` per-file, not outer `RwLock`. Source: `06-gotchas.md §6.3`. +- **`AutoUnmount` is mandatory.** Without it, panic = dangling mount where `ls` hangs; recovery is `fusermount3 -u`. Use `BackgroundSession` via `spawn_mount2`; dropping session = unmount. Don't `let _ = spawn_mount2` (drops instantly). CI must `trap "fusermount3 -u /tmp/mnt" EXIT`. Source: `06-gotchas.md §6.4`. +- **Kernel caching TTLs bite freshness.** `ReplyEntry`/`ReplyAttr` TTL=0 = correct but slow; MAX = stale. Default **1s**. For remote state changes, use short TTL or `notifier().inval_inode()` / `FUSE_NOTIFY_INVAL_ENTRY`; otherwise `.taproot/refresh` pseudo-file pattern. Source: `06-gotchas.md §6.5`. +- **`getattr` on inode 1 (root) is hot path.** Every `cd`, tab-complete, prompt does `stat(CWD)`. Root attr must be cached constant, no write-lock. Source: `06-gotchas.md §6.7`. +- **"Disappearing file" after `create`.** Cause: `lookup` and `create` report different inodes or `getattr` returns ENOENT. Order: allocate inode → insert into `inodes` map → insert into parent `children` → reply. Never reply before inserting. Source: `06-gotchas.md §6.8`. +- **Permission checks with `DefaultPermissions`.** Kernel checks `mode+uid+gid` before calling you. `0o644` owned by 1000 is unwritable by 1001 — you never see `write()`. Use `DefaultPermissions` so mode bits are real (maps to RBAC-to-POSIX). Without it you must implement `access()` yourself. Source: `06-gotchas.md §6.9`. +- **MountOption comma/backslash escaping is broken if naive.** `FSName("foo,rw")` becomes two options. `fuser` must escape `,`→`\,` and `\`→`\\` for `FSName`; `Subtype` cannot contain `, \ NUL` reliably with libfuse; `CUSTOM` cannot contain `, NUL`. `fuser` currently panics on NUL — should error not panic. Source: `cberner/fuser issue #424`. +- **Error codes must be negative.** Return `-ENOENT` (i32 negative). Returning positive with empty payload = kernel nukes mount `107 Transport endpoint not connected`. Source: `fuser-iouring blog 2026-04-11`. +- **ABI version negotiation can freeze terminal.** Responding INIT with 7.32 advertises `READDIRPLUS` (opcode 52). If you return `ENOSYS` for it, kernel loops forever. Fix: downgrade INIT to 7.1 until you implement READDIRPLUS. Source: `fuser-iouring blog`. +- **Dcache phantom after `mkdir`→`rmdir`→`ls`.** Even TTL=0 leaves ghost if `rmdir`+`ls` race. Fix: bump parent `mtime` + set `size = children.len()` as version integer on every mutation so dcache invalidates. Prefer `FUSE_NOTIFY_INVAL_ENTRY` when available. Source: `fuser-iouring blog`. + +### Async Bridge Gotcha +- **fuser 0.17/0.18 `Filesystem` is sync `&self` + `Send+Sync+'static`.** Don't use experimental `AsyncFilesystem` (shape churns per release). Recommended bridge: own a `tokio::runtime::Runtime` inside FS struct + `rt.block_on(...)` or `oneshot` roundtrip inside each callback. Pin `fuser = "0.17"` with `default-features = false` for Linux without `-dev` needed. Source: `reposix fuse-rust-patterns index + 05-async-bridge` + `fuser CHANGELOG 0.17.0` + `docs.rs fuser 0.18`. + +### Signed Registry Gotchas +- **Attestations >4 MiB break OCI manifest push.** Registry `SHOULD` enforce manifest limit; blob endpoints support chunked large payloads. Cosign/Sigstore bundle spec specifically stores bundle as blob + referrer manifest with `subject` pointing to image digest — don't embed large SBOM in manifest annotations. Keep annotations <40 KiB (100 descriptors × 4 MiB). Source: `sigstore/cosign issue #3577` + `BUNDLE_SPEC.md`. +- **Referrers API vs tag fallback race.** GHCR/ECR/ACR/Harbor/Docker Hub support OCI 1.1 referrers as of 2025, but old registries fallback to `sha256-…` referrers tag schema which is read-append-write — concurrent pushes can drop entries. Native Referrers API has no race. `go-containerregistry` auto-fallbacks; don't manually maintain both. Source: `oci-encoding-format docs` + `safeguard 2026 snapshot`. +- **`COSIGN_REPOSITORY` redirect splits signature location.** If set, signatures live in different repo than image — discovery must resolve digest first. Prefer same-repo storage via OCI 1.1 referrers (`artifactType: application/vnd.dev.sigstore.bundle.v0.3+json`) for Taproot's content-addressable proof. Source: `cosign SYSTEM_CONFIG registry_support`. + +### Reproducibility / DX Gotchas +- **Docker reproducibility is a myth without pinning every transitive.** Even pinned `FROM debian:bookworm-20240513` + `apt-get update && upgrade` = non-deterministic (different bits, libc drift). Leaf-node pin doesn't pin transitive deps. Build same Dockerfile 2 weeks apart → different image. Source: `arxiv 2601.12811 §3.2.2`, `Stahnke TNStack 2026-02-07`, `charemma blog`. +- **Dev Containers solve drift but tax inner-loop.** Docker FS sharing on macOS/Windows kills large-repo `watch` performance (bind-mount vs volume matters), coupled to VS Code, image pinned but Dockerfile not. Source: `dev.to/libme 2026-08-13`. +- **Nix is bit-for-bit but language is alien.** `flake.lock` gives exact hash (`python 3.11.4` same bits on macOS/Linux), but Nix language + `langserver not found` onboarding + binary cache misses (LLVM rebuild) make it single-expert risk. Source: `libme`, `dozen-donuts`, `howardjohn lazy-dev-env 2024-10-22`. + +--- + +## 2. Best Practices + +### Rust / CLI +- **Edition 2024 + Rust 1.90 idioms, `clap` derive, `cargo-dist` style releases.** Pin `fuser = "=0.17.0"` or `"0.18"`; check `docs.rs/fuser` for `Filesystem` trait migration (now `&self`, typed newtypes/bitflags). Use `Config` structured API (not `Vec`) + `n_threads` for multiple event loops. Source: `fuser docs.rs 0.18`, `CHANGELOG 0.17.0`. +- **Structured `Config` + ACL handling.** Replace old `mount2(Vec)` with `fuser::Config { mount_point, n_threads, ... }`. Explicitly set `allow_root`/`allow_other` only if `auto_unmount` needed — changelog notes `allow_root|allow_other must be enabled when using auto_unmount`. Source: `CHANGELOG 0.17.0`. +- **Logging: `trace!` per-op, `info` default.** Per-callback `debug!` floods `grep -r` (10k lines). Default `RUST_LOG=taproot_fuse=info`. Source: `06-gotchas.md §6.6`. +- **Error handling: typed `Errno` replies, not panics.** 0.17 adds `typed error handling across request/reply APIs` — propagate `Errno::ENOENT` etc. Panic in callback = dangling mount. Source: `CHANGELOG 0.17.0`. + +### FUSE Mount +- **Skeleton: pure-Rust without libfuse on Linux, `fuse3` runtime package only on CI.** `sudo apt-get install fuse3` (not `-dev`) on `ubuntu-latest` is sufficient if `default-features = false`. Require `pkg-config` at build. Source: `docs.rs fuser deps`. +- **Inode allocation: stable, never reuse quickly.** Use `u64` counter + `BTreeMap`; don't recycle inodes within TTL window or kernel dcache aliases stale entry. Source: `reposix 04-inode-allocation` (inferred from gotchas + fuser tests migration notes). +- **Mount inside GitHub Actions:** use `BackgroundSession` + `Config::n_threads=2` (one for event loop, one for async bridge), `fuse3` package preinstalled on `ubuntu-latest` runner, verify via `mount | grep fuse` and `trap`. Source: `reposix 02-github-actions-mount`. + +### Signed State Registry +- **Use SOCI pattern: don't convert image, add index artifact.** SOCI Snapshotter proves lazy-load without build-time conversion — builds separate `SOCI index` next to OCI image, queried via OCI Reference Types / referrers API. Taproot should build `taproot-index` (env manifest) as sidecar referrer, not mutate git object. Preserves signatures. Source: `awslabs/soci-snapshotter README` (76% of startup is download, only 6.4% needed — Harter FAST'16). +- **Cosign + Sigstore bundle v0.3 over OCI 1.1 referrers.** Push env snapshot as `application/vnd.oci.image.manifest.v1+json` with `subject: {digest: sha256:}` + `artifactType: application/vnd.dev.sigstore.bundle.v0.3+json`, `config: empty descriptor`, `layers: [bundle blob]`. Clients discover via `GET /v2//referrers/` or fallback tag. Works on all major registries 2025+. Source: `BUNDLE_SPEC.md` + `safeguard 2026`. +- **Keyless via Fulcio/Rekor in CI, hardened builder attestation.** Target: every production snapshot has signed SLSA 3 provenance, verified at admission (Kyverno/Policy Controller pattern — translate to `taproot verify` pre-exec). Source: `safeguard 2026` + `sigstore scaffolding`. + +### Environment Reproducibility +- **Don't replay Dockerfile — capture derivation hash.** Nix motto: `few KB of code that produces GB is reproducibility; GB of hashes lying around is not` (Croughan via `dozen-donuts`). For Taproot: store content-addressed `sha256:` where inputs = `flake.lock`/`Cargo.lock`/`package-lock.json` + provider (python/node/postgres) versions + env-vars, built in clean-room sandbox (no net). Source: `Stahnke`, `dozen-donuts`. +- **On-demand fetch, not eager.** Howard John's lazy-dev-env: eager `5-10 GB` kills onboarding. Taproot's wedge already proposes `lazy 2.4 GB` — materialize via FUSE `read` on-demand + SOCI-style prefetch window (first-N blocks). Combine with `nix run` shim + `direnv PATH_add ./bin` for binaries under `bin/`. Source: `howardjohn 2024-10-22`. + +--- + +## 3. Pitfalls & Language Quirks + +- **Rust: `&self` not `&mut self` now (0.17 breaking).** Filesystem impl must be `Send+Sync+'static` with interior mutability (`parking_lot::RwLock`). Old code using `&mut self` + exclusive lock will not compile. Source: `CHANGELOG 0.17.0`. +- **Rust: `allow_root`/`allow_other` gating `auto_unmount`.** Changelog: "`allow_root` or `allow_other` must be enabled when using `auto_unmount`" — if you want unprivileged mount with auto-cleanup, you must set one. Otherwise `mount2` fails. Source: `CHANGELOG 0.17.0`. +- **Rust: feature flag `libfuse` removed from defaults (0.16).** Linking with libfuse is now opt-in `features = ["libfuse"]`. Building without it gives pure-Rust backend (Linux only, handles mount via `/dev/fuse` directly). Mixing `libfuse` + pure-Rust in same workspace = double-mount confusion. Source: `CHANGELOG 0.16.0`. +- **Rust: `FUSERMOUNT_PATH` env override.** If `fusermount3` not in `PATH` (minimal CI image), set `FUSERMOUNT_PATH=/usr/bin/fusermount3`. Silent failure otherwise = `mount2` ioctl error. Source: `CHANGELOG 0.17.0`. +- **Quirk: kernel INIT max_write / max_pages negotiation.** 0.17 adds `max_pages`+`time_gran` in init; kernel may clamp `max_write` below your `KernelConfig`. Don't assume 128 KiB — check `init` reply's negotiated value and size your per-fh buffer accordingly. Source: `CHANGELOG 0.17.0`. +- **Quirk: `FUSE_DEV_IOC_CLONE` + passthrough fd (`BackingId`).** 0.17 adds passthrough descriptors (`ReplyCreate`/`ReplyOpen` with backing fd). Great for postgres `15.3 container, signed` path (pass through overlayfs fd) but leaks fd if you don't close on `release`. Source: `CHANGELOG 0.17.0`. +- **Quirk: `pkg-config` + `libfuse-dev` build coupling.** Even with `default-features=false`, build host still needs `pkg-config` crate's host `pkg-config` binary if any crate enables `libfuse`. CI must `apt-get install pkg-config` or build fails with opaque `failed to run pkg-config`. Source: `docs.rs fuser Linux deps`. +- **macOS: kext hell on Apple Silicon.** Requires `FUSE for macOS` + enable third-party kext (reboot, `csrutil`). Wedge should declare `linux-only` and provide `taproot check --dry-run` that validates without mounting on macOS. Don't promise macOS wedge v1. Source: `docs.rs fuser macOS`. +- **Silent failure: `TTL::MAX` on attrs hides drift.** If Taproot blocks execution on drifted state, stale `getattr` cache means process runs with old env-vars. Must `inval_inode` on `taproot sync` or use `TTL::ZERO` for env-file inodes. Source: `06-gotchas §6.5` + README "If the state has drifted from the signed baseline, Taproot blocks execution and offers a sync." +- **Nix interop quirk: `nix run` shebang + `direnv` exec overhead.** Howard's shim (`nix run` wrapping binary in `bin/`) adds ~70ms per exec cold (nix eval). For `python 3.11.4 (pinned)` hot path, cache resolved `nix store path` in `bin/python → exec /nix/store/…/bin/python` symlink after first fetch, not re-eval each call. Source: `howardjohn`. + +--- + +## 4. Differentiation + +**Industry standard — how environment reproducibility is *normally* done (2026):** + +| Approach | Mechanism | Trust model | Reproducibility guarantee | Cost | +|---|---|---|---|---| +| Dockerfile + Registry | `FROM …; RUN apt-get` → `docker push` → digest pinned | Trust registry + image digest | **Not reproducible:** rebuild ≠ same bits (Stahnke: leaf pin ≠ transitive; timestamps not epoch) | Low learning, high drift | +| Dev Container / Codespaces | `devcontainer.json` + `Dockerfile` + Features | Same as Docker | Same as Docker + `updateContentCommand` drift | Medium, VS Code coupled, Mac FS tax | +| Nix Flake (`flake.lock`) | Pure function of inputs → `/nix/store/-pkg` | Hash of full build graph, sandbox no-net, epoch timestamps | **Bit-for-bit** (same inputs → same bits) | High Nix language tax, single-expert risk | +| `asdf`/`pyenv`/`nvm` + Makefile | Per-lang version files + `make setup` | Trust lockfiles | Documents intent, doesn't enforce (PATH drift) | Lowest, but drift not eliminated | +| SOCI / Stargz Snapshotter | Sidecar index, lazy fetch from OCI image | OCI digest + sidecar index | Image unchanged, fetch on demand (76% time saved) | Requires snapshotter plugin | + +Source: `libme matrix`, `Stahnke`, `arxiv 2601.12811`, `SOCI README`, `howardjohn`. + +**Taproot's wedge — what's different:** + +- **Inheritance, not recipe:** `taproot mount` presents *already-built state* as a FUSE view (like an object you inherit), not a recipe you replay. State is **signed object** (`sha256:b2c1…`) with Sigstore bundle as OCI referrer (`subject: main@9f3a2c1`). Drift = execution blocked (README: `status: ▶ INHERITED — ready` vs blocked+sync). No other tool blocks on drift at exec-time; Docker/Nix trust you to be *in* the right env, Taproot enforces it. +- **Lazy materialization as first-class:** 2.4 GB lazy, not eager `nix develop` or `docker pull`. Uses SOCI-inspired index + FUSE on-demand `read`/`open` (not containerd snapshotter, so works without Docker daemon, without Kubernetes). Howard John notes *all* existing reproducible envs eagerly fetch GBs; Taproot's edge is **on-demand binary fetch** (`howardjohn: 5-10 GB but <10% used`). +- **Signed state registry as source of truth, not Git:** Git is snapshot *basis* (`base: main@9f3a2c1`); truth is signed state registry (OCI referrer store) that can be audited. Git forgot environment; Taproot remembers it as object you can `fork`/`detach`. Contrast: Nix's store is local + binary cache; OCI registry is shared, auditable, with Rekor transparency log. +- **UX as mount, not shell:** `taproot mount ~/projects/myapp` vs `nix develop` / `devcontainer open` / `docker run`. FUSE mount survives shell escape, works with any editor/tool without `direnv` hook, and `s/f/d` (sync/fork/detach) models inheritance semantics directly. + +**Does the difference translate to usefulness? Honest check:** + +- **Yes, if you nail these two:** (1) **Block-on-drift** is unique usefulness — no competitor enforces env at exec boundary (Docker checks image at *build*, Nix at *shell entry*, Taproot at *every syscall* via FUSE). That catches the 68% "works on my machine" and 52% CI env-failures from README cites at runtime, not post-mortem. (2) **Lazy + signed** removes the "download 5 GB to fix typo" tax that makes Nix/devcontainers abandoned (Howard's pain) while keeping audit trail (Sigstore) that Docker alone lacks. + +- **No, if you just rebuild Docker/Nix with a mount:** If Taproot's `state serialization` is just `tar` of `docker export` or a thin wrapper over `nix store path` without SOCI-style index and without Sigstore provenance, it's vanity — `flox activate` already does `same hash, cross-platform (x86 Mac + Linux ARM)` with `calculated` env (Stahnke/Flox) and `devenv` already caches. Saying "we follow SOCI indexing + Sigstore bundle spec exactly" is a valid *not different* conclusion for registry layer — **use the standard**, don't invent a new attestation format. Differentiation is in **mount semantics + enforcement**, not in reinventing OCI/Cosign. + +- **Risk if differentiation is fake:** "Environment-as-object" sounds novel but could devolve into `git clone + nix develop + fusermount` glue. Kill that darling: propose explicit kernel-enforced boundary — FUSE `open` handler checks `sha256` vs `baseline` and returns `EPERM` with sync hint, not just log warning. That's the firewall that makes inheritance real, not wiki 2.0. + +**Version pin for this assessment:** Rust 1.90 (2024 edition), `fuser 0.17.0 (2026-02-14) → 0.18.0 (2026-07-22)`, `libfuse 3.10.3`, `tokio 1.48`, Sigstore Bundle `v0.3` + OCI Image Spec `1.1` Referrers API (2025 GA). + +--- + +## Recommendation + +**What to actually build (grounded in above):** + +1. **Foundation: `taproot-mount` in Rust, `fuser 0.17` sync trait + tokio bridge, `Config` API, `AutoUnmount` + `DefaultPermissions` + 1s TTL + `BackgroundSession`.** Scaffold `src/fs.rs` implementing `Filesystem` with interior `RwLock`; root attr constant; `lookup` eagerly fetches metadata from local state store (serialized snapshot) or registry index; `read` does lazy block fetch (SOCI-style 128 KiB chunks + prefetch). Add `taproot check` dry-run for non-Linux. Pin `fuser = "=0.17.0"` (or `0.18` if you absorb `&self` migration now). `pkg-config` in CI. + +2. **State store: content-addressed, epoch-timestamped, sandbox-built.** Reuse existing Rust state serialization engine; add `state = sha256(filegraph + env-vars + provider versions)` with deterministic serialization (sort keys, epoch mtime). Store under `~/.taproot/store//`. This is the object you inherit — not Dockerfile replay. Verify with `sha256:b2c1…` on mount. Borrow Nix's sandbox+epoch principle without requiring Nix language. + +3. **Signed registry: OCI 1.1 referrer + Sigstore bundle, not custom.** Push `taproot snapshot` as empty-config manifest with `subject: git baseline digest` + `artifactType: application/vnd.dev.sigstore.bundle.v0.3+json`. Use `cosign` lib (`sigstore-rs`) keyless via OIDC in GitHub Action. Don't store attestations in manifest — blob reference. Discovery via referrers API with fallback tag for old registries. + +4. **GitHub Action + baseline check:** `taproot/action@v1` runs `taproot verify --baseline main@` before `cargo build`; fails CI with `drift detected: python expected 3.11.4 got 3.11.6 → taproot sync`. This closes the 52% CI env-failure loop. + +5. **Arena-provable next:** Run arena on **mount semantics** (3 candidates: FUSE-only vs FUSE+overlayfs passthrough vs Git-filter fallback) — the foundation doc in `CD_res/implementation/taproot/arena-synthesis.md` captures graft. Don't write mount code until arena picks base — gate rule holds. + +**Laziness Protocol:** smallest surface that proves inheritance — one `mount` that shows pinned `python/node/postgres` from signed snapshot and blocks drifted `env-var` exec. No `fork/detach` in wedge v1; they are graft candidates. + +--- + +## Sources + +- **Authoritative:** `docs.rs fuser 0.18.0` (cberner/fuser, 1273☆) — `Filesystem` trait, dependencies, platform deps, `mount2`/`Config` API. https://docs.rs/crate/fuser +- **Authoritative:** `fuser CHANGELOG 0.17.0 (2026-02-14)` + `0.16.0` — `&self` migration, `Config` replaces `Vec`, `allow_root|allow_other` gating `auto_unmount`, `libfuse` feature removal, `FUSERMOUNT_PATH`, `max_pages`/`FUSE_DEV_IOC_CLONE`. https://github.com/cberner/fuser/blob/master/CHANGELOG.md +- **Primary research:** `reposix 06-gotchas.md` (fuse-rust-patterns) — UID/GID, st_size, write buffering, AutoUnmount, kernel caching, getattr-hot, disappearing file, DefaultPermissions. High confidence on fuser 0.17. https://github.com/reubenjohn/reposix/blob/main/.planning/research/v0.1-fuse-era/fuse-rust-patterns/06-gotchas.md +- **Primary research:** `reposix index + 05-async-bridge + 02-github-actions-mount` — use `fuser 0.17.x default-features=false`, sync trait + tokio `rt.block_on`, `fuse3` on Actions runner. Same source tree. +- **Secondary:** `fuser-iouring blog 2026-04-11` — error code sign must be negative (107), ABI 7.32 READDIRPLUS loop, dcache phantom via `size=len`. https://blog.sdslabs.co/2026/04/fuser_iouring +- **Secondary:** `cberner/fuser issue #424 (2026-01-04)` — MountOption comma/backslash escaping, FSName vs Subtype vs CUSTOM handling. https://github.com/cberner/fuser/issues/424 +- **Authoritative (OCI/Sigstore):** `sigstore/cosign BUNDLE_SPEC.md` — bundle as blob + manifest with `subject`, `artifactType v0.3`, empty config, referrers API. https://github.com/sigstore/cosign/blob/main/specs/BUNDLE_SPEC.md +- **Authoritative:** `cosign SYSTEM_CONFIG registry_support` + `SIGNATURE_SPEC` — OCI 1.1 referrers, `COSIGN_REPOSITORY` redirect, digest-tag `sha256-` index. https://docs.sigstore.dev/cosign/system_config/registry_support/ +- **Authoritative:** `OCI Distribution Spec — Referrers Tag Schema + API` via `sigstore/cosign PR #2684` and `Tekton Chains oci-encoding-format` — `dsse` vs `sigstore-bundle`, fallback races, 4 MiB manifest limit, 40 KiB annotations. https://github.com/sigstore/cosign/pull/2684 + https://tekton.dev/docs/chains/oci-encoding-format/ +- **Secondary (reproducibility):** `Malka et al. arXiv 2601.12811 §3.2` — Docker non-reproducibility causes, pinning tradeoffs. https://arxiv.org/html/2601.12811 +- **Secondary:** `Stahnke / The New Stack 2026-02-07` — Docker reusable vs reproducible, Nix clean-room/epoch/no-net, Flox cross-platform `flox activate`. https://thenewstack.io/docker-versus-nix-the-quest-for-true-reproducibility/ +- **Secondary:** `libme dev.to 2026-08-13` — Makefile vs devcontainers vs Nix matrix (setup, reproducibility, Mac FS tax). https://dev.to/libme/dev-environment-as-code-devcontainers-nix-or-just-a-good-makefile-3loi +- **Secondary:** `dozen-donuts.com 2024-05-30` + `charemma 2026-03-20` — `FROM debian:latest` drift, Croughan "KB code not GB hashes", pin leaf ≠ transitive. https://www.dozen-donuts.com/blog/02-nix-dev-env/ +- **Secondary:** `SOCI Snapshotter (awslabs)` — 76% startup is download, 6.4% needed (Harter FAST'16), sidecar SOCI index without conversion, preserves signatures. https://github.com/awslabs/soci-snapshotter +- **Secondary:** `howardjohn 2024-10-22 lazy-dev-env` — eager 5-10 GB pain, Nix per-package container-like isolation, `nix run` + `bin/` shim + `direnv PATH_add`. https://blog.howardjohn.info/posts/lazy-dev-env/ +- **Secondary:** `safeguard.sh 2026-02-03 OCI+CNCF 2026 snapshot` + `sigstore/cosign #3577` — OCI 1.1 GA 2025, bundle protobuf, `Fulcio/Rekor` prod, SLSA 3 provenance target. https://safeguard.sh/resources/blog/ocid-cncf-image-supply-chain-2026 + +--- + +## Adversarial Verification + +- **Sources verified:** `docs.rs/fuser 0.18` + `CHANGELOG 0.17.0` exist (checked via webfetch; versions 0.18 2026-07-22, 0.17 2026-02-14). `reposix 06-gotchas.md` exists via websearch excerpt; `fuser issue #424` exists. `cosign BUNDLE_SPEC.md` + `registry_support` URLs resolve. `arxiv 2601.12811`, `thenewstack 2026-02-07`, `SOCI README`, `howardjohn` all returned highlights matching claims. One doc fetch 429 (exa MCP) ignored — claims cross-checked via other excerpts. +- **Numerical claims verified:** 76% download time / 6.4% needed — from Harter FAST'16 via SOCI README excerpt (exact). `max_write 128 KiB` default — from gotchas §6.3 excerpt. `fuser 1273 stars` — from docs.rs page header. 52%/68%/2.3 weeks figures — flagged as **README-cited** (DevOps Research 2026 etc.), not independently verified here; source is TAPROOT's research corpus `github.com/Epoch-AI-Lab/research`, not rechecked in this pass. +- **Logical coherence:** Confirmed — FUSE TTL tradeoff logic (short TTL vs inval) follows kernel dcache design; async bridge recommendation (sync trait + rt.block_on) follows 0.17 changelog "`Filesystem methods use &self, Send+Sync+'static` + experimental async unstable`. Differentiation "block-on-drift unique" follows from no other env tool hooking `open` to enforce `sha256` check — FUSE is only path that can intercept exec via filesystem, not just shell entry. +- **Omissions flagged:** Did not read `cdres/paper-hunting` corpus for environment drift papers beyond cited excerpts — manual verification should pull `DevOps Research 2026` + `CircleCI 2025` + `GitHub Octoverse 2026` sources before using stats externally. Did not test actual `fuser` mount on `ubuntu-latest` runner — recommend spike: `cargo new taproot-spike && cargo add fuser@0.17 && mount2` smoke test before committing to pure-Rust backend. +- **Status: GREEN** (after fixing: added `FUSERMOUNT_PATH` + `max_pages` negotiation + `BackingId` fd-leak pitfall from changelog re-read; added `artifactType` version `v0.3` correction from BUNDLE_SPEC). + diff --git a/README.md b/README.md index 320473a..6026bc3 100644 --- a/README.md +++ b/README.md @@ -6,18 +6,32 @@ Taproot is the state inheritance fabric between VCS and CI. Environment-as-objec ## The problem -- **68%** of "works on my machine" incidents trace to undocumented environment drift DevOps Research 2026 +Environment drift is invisible. A project can pin its language versions and still not pin the thing that actually breaks a build: + +- **52%** of CI failures are environment-related, not code-related CircleCI 2025 State of CI - New developers take **2.3 weeks** to reach full productivity due to environment setup Stripe Onboarding Study -- **52%** of CI failures are environment-related, not code-related CircleCI 2025 - Reproducing a colleague's exact dev environment is considered "nearly impossible" by **74%** of engineers GitHub Octoverse 2026 The environment is the code that git forgot. Taproot inherits it like an object, not a recipe. ## The wedge -A FUSE mount CLI that lazily materializes git repos as signed environment snapshots: +A mount CLI that materializes git repos as signed environment snapshots: ```bash +$ taproot scan . + + TAPROOT SCAN + ───────────────────────────────────────── + dir: . + + runtimes: 2 + node 20.5.0 (pinned) + python 3.11.4 (pinned) + + containers: 1 + db postgres:15.3 → 15.3 + $ taproot mount ~/projects/myapp TAPROOT MOUNT @@ -26,14 +40,14 @@ $ taproot mount ~/projects/myapp base: main@9f3a2c1 state: signed · sha256:b2c1... materialized: 2.4 GB (lazy) - + python: 3.11.4 (pinned) node: 20.5.0 (pinned) postgres: 15.3 (container, signed) env-vars: 12 loaded from baseline - + status: ▶ INHERITED — ready to work - + [s]ync · [f]ork · [d]etach ``` @@ -45,10 +59,12 @@ We are building the wedge primitive: - [x] State serialization engine (Rust) - [x] FUSE mount CLI (read-only, v0.0.1) - [x] GitHub Action + baseline check (`taproot check` strict, composite action) -- [x] Signed state registry (local content-addressed, `taproot registry push/pull/list`) +- [x] Signed state registry (local content-addressed, `taproot registry push/pull/list/log`) - [x] Key management (`taproot keys generate/list/rotate`) - [x] Managed fabric + registry API (`taproot serve`, `taproot remote`, `taproot fabric` audit/policy/tokens) - [x] Drift loop (v0.1.0): writable `env` file in the mount, drift captured on unmount, `taproot sync` to review, re-sign, and adopt +- [x] Environment capture (`taproot scan`): reads `.tool-versions`, `.mise.toml`, `Dockerfile`, `package.json`, and compose files +- [x] Registry history: every push links to the state it superseded, so `registry log` walks a branch back to its first commit ## Open source @@ -60,21 +76,45 @@ Taproot's mount CLI, protocol format, and state schema are MIT-licensed. The man git clone https://github.com/Epoch-AI-Lab/taproot.git cd taproot cargo build --release -./target/release/taproot keys generate --id mykey -./target/release/taproot init --repo myapp --branch main --commit 9f3a2c1 -./target/release/taproot registry push -./target/release/taproot registry list --repo myapp -./target/release/taproot mount --no-fuse ~/projects/myapp # requires existing dir; omit --no-fuse for real FUSE -./target/release/taproot status -./target/release/taproot verify -./target/release/taproot check --baseline .taproot/baseline.json --json # strict drift check +T=./target/release/taproot + +# 1. keypair (private stays local, public is shareable) +$T keys generate --id mykey + +# 2. detect the real environment, then sign it +$T scan . --include-env +$T scan . --apply + +# or hand-write a state if the project declares nothing +$T init --repo myapp --branch main --commit 9f3a2c1 +$T registry push +$T registry list myapp +$T registry log myapp main + +# 3. mount. omit --no-fuse for a real FUSE mount +$T mount --no-fuse # writes .taproot/mnt, works without /dev/fuse +$T mount ~/projects/myapp # real FUSE, env file writable + +# 4. the drift loop, without FUSE +echo 'DATABASE_URL=postgres://localhost/app' >> .taproot/mnt/env +$T sync --from-dir .taproot/mnt --dry-run +$T sync --from-dir .taproot/mnt # review, sign, adopt + +# 5. verify and gate in CI +$T status +$T verify +$T check --baseline .taproot/baseline.json --json # remote fabric -./target/release/taproot serve --addr 127.0.0.1:3000 & -./target/release/taproot remote push --remote http://127.0.0.1:3000 -./target/release/taproot fabric audit +$T serve --addr 127.0.0.1:3000 & +$T remote push --remote http://127.0.0.1:3000 +$T fabric audit ``` +`mount --no-fuse` writes the same tree a FUSE mount serves (`env`, `state.json`, `hash`, `version`, `runtimes/`, `containers/`) into a real directory. Only `env` is writable. That makes the whole drift loop testable in a container or CI runner, where `/dev/fuse` is usually unavailable. + +`scan` never reads your process environment. It reads files the project already commits, and it skips any value that looks like a live credential rather than storing it in a file you are about to commit. + ## Contribute We need: @@ -82,8 +122,6 @@ We need: - DevOps engineers who have automated onboarding - Anyone who has ever lost a day to "works on my machine" -See [CONTRIBUTING.md](./CONTRIBUTING.md). - ## Cite the research All figures in this README are verbatim from the [Developer Workflow Bottlenecks](https://github.com/Epoch-AI-Lab/research) corpus (23 bottlenecks, 21 sources, compiled 2026-08-08). diff --git a/src/cli.rs b/src/cli.rs index 11ee84a..62ae7fc 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -47,6 +47,8 @@ pub struct Cli { pub enum Commands { /// Initialise a new taproot state snapshot Init(InitArgs), + /// Scan a project for declared runtimes, containers, and env vars + Scan(ScanArgs), /// Mount a taproot state (env file writable; edits captured as drift) Mount(MountArgs), /// Show current state status @@ -92,24 +94,61 @@ pub struct InitArgs { pub no_sign: bool, } +#[derive(Debug, Args)] +pub struct ScanArgs { + /// Project directory to scan (default: current directory) + #[arg(value_name = "DIR")] + pub dir: Option, + + /// Print the findings as JSON instead of a table + #[arg(long, default_value_t = false)] + pub json: bool, + + /// Write the findings into the state file and sign it + #[arg(long, default_value_t = false)] + pub apply: bool, + + /// Path to state file to write with --apply (default: .taproot/state.json) + #[arg(long, value_name = "PATH")] + pub state_path: Option, + + /// Include env vars from .env files (skipped by default, they often hold secrets) + #[arg(long, default_value_t = false)] + pub include_env: bool, +} + #[derive(Debug, Args)] pub struct MountArgs { - /// Path to mount (must be an existing empty directory) - pub path: PathBuf, + /// Path to mount (must be an existing empty directory). Not needed with + /// --no-fuse, which writes the tree to --out instead. + #[arg(value_name = "PATH")] + pub path: Option, /// Path to state file (default: .taproot/state.json, relative to current directory) #[arg(long, value_name = "PATH")] pub state_path: Option, - /// Disable FUSE mount — just print header and exit (useful in CI without FUSE) + /// Write the mounted tree to a real directory instead of a FUSE mount (works without /dev/fuse) #[arg(long = "no-fuse", default_value_t = false)] pub no_fuse: bool, + /// Where --no-fuse writes the tree (default: .taproot/mnt next to the state file) + #[arg(long, value_name = "PATH")] + pub out: Option, + /// Where to write captured drift (default: state.drift.json next to the state file) #[arg(long, value_name = "PATH")] pub drift_out: Option, } +/// Default directory for a materialized tree: `.taproot/mnt` beside the state. +fn default_materialize_dir(state_path: &Path) -> PathBuf { + match state_path.parent() { + Some(p) if !p.as_os_str().is_empty() => p.join("mnt"), + _ => PathBuf::from("mnt"), + } +} + #[derive(Debug, Args)] pub struct SyncArgs { /// Path to baseline state file to re-sign into (default: .taproot/state.json) @@ -120,6 +159,10 @@ pub struct SyncArgs { #[arg(long, value_name = "PATH")] pub from: Option, + /// Read drift from a materialized tree's env file (from `mount --no-fuse`) + #[arg(long, value_name = "DIR", conflicts_with = "from")] + pub from_dir: Option, + /// Show the diff report without adopting anything #[arg(long = "dry-run", default_value_t = false)] pub dry_run: bool, @@ -522,6 +565,164 @@ fn print_unsigned_warning() { // Handlers // --------------------------------------------------------------------------- +pub fn handle_scan(args: ScanArgs) -> Result<(), TaprootError> { + let dir = args + .dir + .clone() + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + if !dir.is_dir() { + return Err(TaprootError::Mount(format!( + "not a directory: {}", + dir.display() + ))); + } + + let result = crate::scan::scan_project(&dir); + let (env_vars, env_skipped) = if args.include_env { + crate::scan::scan_env_vars(&dir) + } else { + (Default::default(), Default::default()) + }; + + if args.json { + let payload = serde_json::json!({ + "runtimes": result.runtimes, + "containers": result.containers, + "env_vars": env_vars, + "env_skipped": env_skipped, + }); + println!("{}", serde_json::to_string_pretty(&payload)?); + } else { + println!("TAPROOT SCAN"); + println!("─────────────────────────────────────────"); + println!("dir: {}", dir.display()); + println!(); + + if result.runtimes.is_empty() { + println!("runtimes: none detected"); + println!(" looked for .tool-versions, .mise.toml, Dockerfile, package.json"); + } else { + println!("runtimes: {}", result.runtimes.len()); + for r in &result.runtimes { + println!(" {:<20} {} (pinned)", r.name, r.version); + } + } + println!(); + + if result.containers.is_empty() { + println!("containers: none detected"); + println!(" looked for docker-compose.yml, compose.yml"); + } else { + println!("containers: {}", result.containers.len()); + for c in &result.containers { + println!(" {:<20} {} → {}", c.name, c.image, c.version); + } + } + println!(); + + if args.include_env { + if env_vars.is_empty() { + println!("env-vars: none captured"); + } else { + println!("env-vars: {}", env_vars.len()); + for (k, v) in &env_vars { + println!(" {k}={v}"); + } + } + if !env_skipped.is_empty() { + println!(); + println!( + "skipped {} value(s) that look like secrets:", + env_skipped.len() + ); + for (k, why) in &env_skipped { + println!(" {k:<24} {why}"); + } + } + } else { + println!("env-vars: not read (pass --include-env to read .env files)"); + } + println!(); + } + + if !args.apply { + return Ok(()); + } + + // --apply writes the findings into the state file, creating it when absent + // so `scan --apply` works before `init`. + let state_path = resolve_state_path(args.state_path); + let mut state = match StateEngine::load(&state_path) { + Ok(signed) => signed.state, + Err(_) => { + // `file_name()` is None for "." and for a bare root path, so + // canonicalize before naming the repo after the directory. + let named = dir + .canonicalize() + .ok() + .as_deref() + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().to_string()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| "unknown".to_string()); + let (branch, commit) = git_head(&dir); + TaprootState::new(named, branch, commit) + } + }; + state = result.apply_to(state); + if args.include_env { + state.env_vars.extend(env_vars); + } + state.created_at = chrono::Utc::now(); + + let keys_path = resolve_keys_path(None); + let priv_key = if keys_path.exists() { + crate::keys::KeyStore::init(&keys_path) + .and_then(|ks| ks.default_key()) + .map(|kp| kp.private_key) + .unwrap_or_else(|_| StateEngine::generate_keypair().0) + } else { + StateEngine::generate_keypair().0 + }; + let signed = StateEngine::sign(&state, &priv_key)?; + if let Some(parent) = state_path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + StateEngine::save(&state_path, &signed)?; + println!( + "applied: sha256:{} → {}", + &signed.hash[..12], + display_state_path(&state_path) + ); + Ok(()) +} + +/// Current branch and short commit for a directory, falling back to +/// placeholders when git is unavailable or the directory is not a repo. +fn git_head(dir: &Path) -> (String, String) { + let run = |args: &[&str]| -> Option { + let out = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.is_empty() { + None + } else { + Some(s) + } + }; + let branch = run(&["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_else(|| "main".into()); + let commit = run(&["rev-parse", "--short", "HEAD"]).unwrap_or_else(|| "unknown".into()); + (branch, commit) +} + pub fn handle_init(args: InitArgs) -> Result<(), TaprootError> { validate_non_empty("repo", &args.repo)?; validate_non_empty("branch", &args.branch)?; @@ -539,6 +740,7 @@ pub fn handle_init(args: InitArgs) -> Result<(), TaprootError> { hash, signature: None, public_key: None, + parent: None, } } else { // Prefer stored keys if available, else generate ephemeral @@ -632,7 +834,7 @@ fn ensure_distinct(a: &Path, b: &Path, what: &str) -> Result<(), TaprootError> { }) }; if resolve(a) == resolve(b) { - return Err(TaprootError::Mount(format!( + return Err(TaprootError::InvalidPaths(format!( "{what} points at the baseline state file itself ({}): refusing", display_state_path(b) ))); @@ -654,6 +856,7 @@ pub fn sign_state_with_keys( hash, signature: None, public_key: None, + parent: None, }); } if keys_path.exists() { @@ -709,28 +912,52 @@ pub fn handle_mount(args: MountArgs) -> Result<(), TaprootError> { print_mount_header(&signed); println!(); - println!("mount: {}", args.path.display()); - let target_meta = std::fs::symlink_metadata(&args.path); - match &target_meta { - Ok(m) if m.is_dir() => println!("target: exists (directory)"), - Ok(m) if m.file_type().is_symlink() => { - println!("target: exists (symlink — will be rejected)") + + // With --no-fuse there is no mountpoint: the tree goes to --out (or + // .taproot/mnt), so all the mountpoint validation below is skipped. + let target_meta = match &args.path { + Some(p) => { + println!("mount: {}", p.display()); + let meta = std::fs::symlink_metadata(p); + match &meta { + Ok(m) if m.is_dir() => println!("target: exists (directory)"), + Ok(m) if m.file_type().is_symlink() => { + println!("target: exists (symlink — will be rejected)") + } + Ok(_) => println!("target: exists (not a directory — will be rejected)"), + Err(_) => println!("target: not found"), + } + Some(meta) } - Ok(_) => println!("target: exists (not a directory — will be rejected)"), - Err(_) => println!("target: not found"), - } + None => { + println!("mount: (none — materializing a tree)"); + None + } + }; println!("hash: {}", signed.hash); if signed.signature.is_none() { print_unsigned_warning(); } println!(); + if args.path.is_none() && !args.no_fuse { + let e = TaprootError::Mount("mountpoint is required unless --no-fuse is set".into()); + eprintln!("✗ mount failed: {e}"); + println!("status: ✗ MOUNT FAILED — no mountpoint given"); + println!(); + return Err(e); + } + // Validate mountpoint before honoring --no-fuse — CI must not hide symlink/file attacks - if let Ok(m) = &target_meta { + if let Some(Ok(m)) = target_meta.as_ref() { + let path = args + .path + .as_ref() + .expect("path present when metadata resolved"); if m.file_type().is_symlink() { let e = TaprootError::Mount(format!( "mountpoint is a symlink (refusing): {}", - args.path.display() + path.display() )); eprintln!("✗ mount failed: {e}"); println!("status: ✗ MOUNT FAILED — symlink rejected"); @@ -738,10 +965,8 @@ pub fn handle_mount(args: MountArgs) -> Result<(), TaprootError> { return Err(e); } if !m.is_dir() { - let e = TaprootError::Mount(format!( - "mountpoint is not a directory: {}", - args.path.display() - )); + let e = + TaprootError::Mount(format!("mountpoint is not a directory: {}", path.display())); eprintln!("✗ mount failed: {e}"); println!("status: ✗ MOUNT FAILED — not a directory"); println!(); @@ -749,10 +974,12 @@ pub fn handle_mount(args: MountArgs) -> Result<(), TaprootError> { } } else if !args.no_fuse { // real mount requires existing dir - let e = TaprootError::Mount(format!( - "mountpoint does not exist: {}", - args.path.display() - )); + let shown = args + .path + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "(none)".into()); + let e = TaprootError::Mount(format!("mountpoint does not exist: {shown}")); eprintln!("✗ mount failed: {e}"); println!("status: ✗ MOUNT FAILED — mountpoint missing"); println!(); @@ -760,22 +987,62 @@ pub fn handle_mount(args: MountArgs) -> Result<(), TaprootError> { } if args.no_fuse { - println!("(no-fuse — skipping FUSE mount, mountpoint validated)"); - print_status_line(true); - println!(); - return Ok(()); + // Materialize the same tree a FUSE mount would serve, so the drift + // loop is reachable where /dev/fuse is not available. + let out = args + .out + .clone() + .unwrap_or_else(|| default_materialize_dir(&state_path)); + ensure_distinct(&state_path, &out, "--out")?; + match crate::mount::materialize_tree(&out, &signed) { + Ok(()) => { + println!("(no-fuse — wrote tree to {})", display_state_path(&out)); + println!( + "env: {}/env (writable — edit, then run `taproot sync --from-dir`)", + display_state_path(&out) + ); + let drift_path = args + .drift_out + .clone() + .unwrap_or_else(|| default_drift_path(&state_path)); + match crate::mount::capture_drift_from_dir(&out, &signed) { + Ok(Some(drift)) => { + StateEngine::save(&drift_path, &drift)?; + println!("drift: captured — env differs from baseline"); + println!("path: {}", display_state_path(&drift_path)); + } + Ok(None) => {} + Err(e) => { + eprintln!("warn: could not read drift from {}: {e}", out.display()); + } + } + print_status_line(true); + println!(); + return Ok(()); + } + Err(e) => { + eprintln!("✗ mount failed: {e}"); + println!("status: ✗ MOUNT FAILED — could not write tree"); + println!(); + return Err(e); + } + } } + // Reached only on the real FUSE path, so a mountpoint is guaranteed here. + let mountpoint = args.path.as_ref().ok_or_else(|| { + TaprootError::Mount("mountpoint is required unless --no-fuse is set".into()) + })?; println!( "attempting FUSE mount at {} (env writable, Ctrl-C to unmount)...", - args.path.display() + mountpoint.display() ); let drift_path = args .drift_out .clone() .unwrap_or_else(|| default_drift_path(&state_path)); ensure_distinct(&state_path, &drift_path, "--drift-out")?; - match crate::mount::mount_readonly(&args.path, &signed) { + match crate::mount::mount_readonly(mountpoint, &signed) { Ok(outcome) => { print_status_line(true); if let Some(drift) = outcome.drift { @@ -908,7 +1175,15 @@ pub fn handle_verify(args: VerifyArgs) -> Result<(), TaprootError> { } pub fn handle_sync(args: SyncArgs) -> Result<(), TaprootError> { - let state_path = resolve_state_path(args.state_path); + let state_path = resolve_state_path(args.state_path.clone()); + let baseline = StateEngine::load(&state_path)?; + + // `--from-dir` reads a materialized tree's env file directly, so the drift + // loop works without a FUSE unmount having written a drift file first. + if let Some(dir) = args.from_dir.clone() { + return sync_from_dir(args, state_path, baseline, dir); + } + let drift_path = args .from .clone() @@ -916,7 +1191,6 @@ pub fn handle_sync(args: SyncArgs) -> Result<(), TaprootError> { tracing::info!(?state_path, ?drift_path, "sync"); ensure_distinct(&state_path, &drift_path, "--from")?; - let baseline = StateEngine::load(&state_path)?; let current = StateEngine::load(&drift_path)?; println!("TAPROOT SYNC"); @@ -1012,6 +1286,52 @@ pub fn handle_sync(args: SyncArgs) -> Result<(), TaprootError> { Ok(()) } +/// Materialize-tree sync: read the edited `env` out of a `--no-fuse` tree, +/// capture it as drift, then hand off to the normal `--from` flow so review, +/// `--force` gating, signing, and adoption stay in one place. +fn sync_from_dir( + mut args: SyncArgs, + state_path: PathBuf, + baseline: crate::state::SignedState, + dir: PathBuf, +) -> Result<(), TaprootError> { + let env_path = dir.join("env"); + if !env_path.exists() { + eprintln!( + "✗ no env file at {} — is this a materialized tree? (run `taproot mount --no-fuse` first)", + env_path.display() + ); + return Err(TaprootError::Mount(format!( + "no env file in {}", + dir.display() + ))); + } + + let Some(drift) = crate::mount::capture_drift_from_dir(&dir, &baseline)? else { + println!("TAPROOT SYNC"); + println!("─────────────────────────────────────────"); + println!("tree: {}", display_state_path(&dir)); + println!(); + println!("no drift — env matches the signed baseline"); + return Ok(()); + }; + + let drift_path = default_drift_path(&state_path); + StateEngine::save(&drift_path, &drift)?; + println!( + "captured drift from {} → {}", + dir.display(), + drift_path.display() + ); + + args.from = Some(drift_path); + handle_sync(SyncArgs { + state_path: Some(state_path), + from_dir: None, + ..args + }) +} + pub fn handle_keys(args: KeysArgs) -> Result<(), TaprootError> { match args.command { KeysCommands::Generate(a) => handle_keys_generate(a), @@ -1334,6 +1654,10 @@ pub fn handle_check(args: CheckArgs) -> Result<(), TaprootError> { tracing::info!(?state_path, ?baseline_path, "check"); + // A baseline that is the state under test compares a file to itself and + // always reports no drift, which turns the gate into a no-op. Refuse it. + ensure_distinct(&baseline_path, &state_path, "--baseline")?; + // Load and verify both files — strict: unsigned is error let current = StateEngine::load(&state_path).map_err(|e| { eprintln!("✗ check failed — current state invalid: {e}"); @@ -1742,7 +2066,7 @@ pub fn handle_registry_log(args: RegistryLogArgs) -> Result<(), TaprootError> { if entries.is_empty() { println!("(no entries for {}/{})", args.repo, args.branch); } else { - for signed in &entries { + for (i, signed) in entries.iter().enumerate() { let short = if signed.hash.len() >= 12 { &signed.hash[..12] } else { @@ -1753,16 +2077,18 @@ pub fn handle_registry_log(args: RegistryLogArgs) -> Result<(), TaprootError> { } else { "unsigned" }; + // First entry is the current ref head; the rest are ancestors. + let marker = if i == 0 { "*" } else { " " }; println!( - "* {} {}@{} {sig_label} · sha256:{short}", - signed.hash, signed.state.base.branch, signed.state.base.commit + "{marker} sha256:{short} {}@{} {sig_label}", + signed.state.base.branch, signed.state.base.commit ); if let Some(notes) = &signed.state.notes { - println!(" notes: {notes}"); + println!(" notes: {notes}"); } } println!(); - println!("{} entr(ies)", entries.len()); + println!("{} entr(ies), newest first", entries.len()); } Ok(()) } diff --git a/src/engine.rs b/src/engine.rs index 10e0330..0d03682 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -69,6 +69,7 @@ impl StateEngine { hash: hash_hex, signature: Some(B64.encode(signature.to_bytes())), public_key: Some(public_key_b64), + parent: None, }) } @@ -209,20 +210,27 @@ mod tests { #[test] fn verify_fails_on_wrong_key() { + // Sign with one key, then verify against a different public key. The + // signature will not check out, so verify must fail. let state = sample_state(); let (priv_b64, _) = StateEngine::generate_keypair(); - let (other_priv, _) = StateEngine::generate_keypair(); - let mut signed = StateEngine::sign(&state, &priv_b64).unwrap(); - // re-sign hash with wrong key but keep hash - let other_signed = StateEngine::sign(&state, &other_priv).unwrap(); - signed.signature = other_signed.signature; - signed.public_key = other_signed.public_key; - // Now tamper one more way: sign with other key but verify should use that key — it will pass. - // So instead test: keep original signature, swap pubkey - let mut tampered = StateEngine::sign(&state, &priv_b64).unwrap(); let (_, other_pub) = StateEngine::generate_keypair(); - tampered.public_key = Some(other_pub); - assert!(StateEngine::verify(&tampered).is_err()); + let mut signed = StateEngine::sign(&state, &priv_b64).unwrap(); + signed.public_key = Some(other_pub); + assert!(matches!( + StateEngine::verify(&signed), + Err(TaprootError::InvalidSignature) + )); + } + + #[test] + fn verify_rejects_signature_without_public_key() { + // signature present, public_key absent: mismatched envelope, not a valid state. + let state = sample_state(); + let (priv_b64, _) = StateEngine::generate_keypair(); + let mut signed = StateEngine::sign(&state, &priv_b64).unwrap(); + signed.public_key = None; + assert!(StateEngine::verify(&signed).is_err()); } #[test] diff --git a/src/error.rs b/src/error.rs index 42afb9a..e673f15 100644 --- a/src/error.rs +++ b/src/error.rs @@ -20,6 +20,9 @@ pub enum TaprootError { #[error("mount failed: {0}")] Mount(String), + #[error("invalid paths: {0}")] + InvalidPaths(String), + #[error("baseline not found: {0}")] BaselineMissing(String), diff --git a/src/lib.rs b/src/lib.rs index 4262d68..1028984 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod fabric; pub mod keys; pub mod mount; pub mod registry; +pub mod scan; pub mod server; pub mod state; pub(crate) mod util; diff --git a/src/main.rs b/src/main.rs index 317e2af..0d77b86 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,8 @@ use clap::Parser; use taproot::cli::{ handle_check, handle_fabric, handle_init, handle_keys, handle_mount, handle_registry, - handle_remote, handle_serve, handle_status, handle_sync, handle_verify, Cli, Commands, + handle_remote, handle_scan, handle_serve, handle_status, handle_sync, handle_verify, Cli, + Commands, }; fn main() { @@ -16,6 +17,7 @@ fn main() { let result = match cli.command { Commands::Init(args) => handle_init(args), + Commands::Scan(args) => handle_scan(args), Commands::Mount(args) => handle_mount(args), Commands::Status(args) => handle_status(args), Commands::Verify(args) => handle_verify(args), diff --git a/src/mount.rs b/src/mount.rs index 095813b..c3f51d1 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -93,6 +93,101 @@ fn is_safe_filename(name: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') } +// --------------------------------------------------------------------------- +// Plain-directory materialization +// --------------------------------------------------------------------------- + +/// Write the mounted file tree into a real directory instead of a FUSE mount. +/// +/// Same layout the kernel mount serves: `README.taproot`, `state.json`, `env`, +/// `hash`, `version`, plus `runtimes/` and `containers/`. Everything but `env` +/// is written read-only. `env` is left writable and its original bytes are +/// recorded so `capture_drift_from_dir` can diff it later. +/// +/// This exists so the drift loop is reachable without `/dev/fuse`. Containers +/// and CI runners have no FUSE, and a feature you cannot exercise is a feature +/// you cannot ship. +pub fn materialize_tree(mountpoint: &Path, signed: &SignedState) -> Result<(), TaprootError> { + let fs = TaprootFS::new(signed); + materialize_inode_tree(&fs, mountpoint)?; + Ok(()) +} + +/// Walk the same inode table the FUSE layer serves, writing each node out. +fn materialize_inode_tree(fs: &TaprootFS, mountpoint: &Path) -> Result<(), TaprootError> { + let root = fs + .inodes + .get(&ROOT_INO) + .ok_or_else(|| TaprootError::Mount("missing root inode".into()))?; + write_dir(fs, root, mountpoint) +} + +fn write_dir(fs: &TaprootFS, inode: &Inode, dir: &Path) -> Result<(), TaprootError> { + std::fs::create_dir_all(dir)?; + for child_ino in &inode.children { + let Some(child) = fs.inodes.get(child_ino) else { + continue; + }; + let path = dir.join(&child.name); + // Only regular files and directories are ever built into the table; + // devices and pipes are skipped rather than materialized. + match child.kind { + FileType::Directory => write_dir(fs, child, &path)?, + FileType::RegularFile => { + std::fs::write(&path, &child.data)?; + if child.writable { + make_writable(&path); + } else { + make_read_only(&path); + } + } + _ => {} + } + } + Ok(()) +} + +#[cfg(unix)] +fn make_read_only(path: &Path) { + use std::os::unix::fs::PermissionsExt; + // 0o444. The tree is disposable state materialization, not user data. + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o444)); +} + +/// `env` is the one writable file. `fs::write` inherits the process umask, which +/// can leave it owner-only, so the mode is set explicitly rather than assumed. +#[cfg(unix)] +fn make_writable(path: &Path) { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o644)); +} + +#[cfg(not(unix))] +fn make_read_only(_path: &Path) {} + +#[cfg(not(unix))] +fn make_writable(_path: &Path) {} + +/// Diff a materialized `env` file against the signed baseline and return the +/// drift a FUSE unmount would have produced. Returns `None` when `env` is +/// unchanged or missing, so an untouched tree is not reported as drift. +pub fn capture_drift_from_dir( + mountpoint: &Path, + signed: &SignedState, +) -> Result, TaprootError> { + let env_path = mountpoint.join("env"); + if !env_path.exists() { + return Ok(None); + } + let edited = std::fs::read(&env_path)?; + let original = TaprootFS::env_content(signed).into_bytes(); + if edited == original { + return Ok(None); + } + let drift = extract_env_drift(signed, &edited)?; + Ok(Some(drift)) +} + // --------------------------------------------------------------------------- // TaprootFS // --------------------------------------------------------------------------- @@ -665,6 +760,7 @@ pub fn extract_env_drift( hash, signature: None, public_key: None, + parent: None, }) } @@ -767,6 +863,7 @@ mod tests { hash, signature: None, public_key: None, + parent: None, } } diff --git a/src/registry.rs b/src/registry.rs index 820a0e2..56c7cdd 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -19,6 +19,10 @@ pub struct Registry { root: PathBuf, } +/// Upper bound on `log` chain walks, so a corrupted or cyclic parent link +/// cannot spin forever. Far above any real branch history. +const MAX_LOG_HOPS: usize = 10_000; + impl Registry { /// Create a registry handle without touching the filesystem. pub fn new(root: &Path) -> Self { @@ -66,6 +70,11 @@ impl Registry { } /// Push a signed state: verify, persist object, update ref. + /// + /// The ref's current hash is recorded as the new object's `parent`, so + /// `log` can walk a branch back to its first push. A re-push of the same + /// hash is a no-op for history, so pushing twice does not fork the chain. + /// /// Returns the hash on success. pub fn push(&self, signed: &SignedState) -> Result { // Validate + verify before any IO. @@ -85,34 +94,44 @@ impl Registry { fs::create_dir_all(self.objects_dir())?; fs::create_dir_all(self.refs_dir())?; + // Link to whatever this ref pointed at before, but not to itself: a + // re-push of the same object must not make the chain cyclic. + let ref_path = self.ref_path(&signed.state.base.repo, &signed.state.base.branch)?; + let previous = self.resolve_ref(&signed.state.base.repo, &signed.state.base.branch)?; + let parent = previous.filter(|p| p != &signed.hash); + + let mut stored = signed.clone(); + if parent.is_some() { + stored.parent = parent; + } + // Write object atomically if not already present. - let obj_path = self.object_path(&signed.hash); + let obj_path = self.object_path(&stored.hash); if !obj_path.exists() { - let bytes = serde_json::to_vec_pretty(signed)?; + let bytes = serde_json::to_vec_pretty(&stored)?; atomic_write(&obj_path, &bytes)?; - tracing::info!(hash=%signed.hash, ?obj_path, "registry object written"); + tracing::info!(hash=%stored.hash, ?obj_path, "registry object written"); } else { // Verify existing object matches (defensive). - let existing = self.pull(&signed.hash)?; - if existing != *signed { - tracing::warn!(hash=%signed.hash, "registry object exists with different content"); + let existing = self.pull(&stored.hash)?; + if existing.state != stored.state { + tracing::warn!(hash=%stored.hash, "registry object exists with different content"); } } // Update ref atomically. - let ref_path = self.ref_path(&signed.state.base.repo, &signed.state.base.branch)?; if let Some(parent) = ref_path.parent() { fs::create_dir_all(parent)?; } - atomic_write(&ref_path, signed.hash.as_bytes())?; + atomic_write(&ref_path, stored.hash.as_bytes())?; tracing::info!( - repo=%signed.state.base.repo, - branch=%signed.state.base.branch, - hash=%signed.hash, + repo=%stored.state.base.repo, + branch=%stored.state.base.branch, + hash=%stored.hash, "registry ref updated" ); - Ok(signed.hash.clone()) + Ok(stored.hash.clone()) } /// Pull an object by hash. Verifies signature and hash. @@ -193,16 +212,37 @@ impl Registry { Ok(out) } - /// Log for a repo/branch. Currently returns the single SignedState - /// pointed to by the ref, if any (no history chain yet). + /// Log for a repo/branch. Returns every state ever pushed to that ref, + /// newest first, following the parent chain recorded at push time. pub fn log(&self, repo: &str, branch: &str) -> Result, TaprootError> { - match self.resolve_ref(repo, branch)? { - Some(hash) => { - let signed = self.pull(&hash)?; - Ok(vec![signed]) + let Some(head) = self.resolve_ref(repo, branch)? else { + return Ok(Vec::new()); + }; + let mut out = Vec::new(); + let mut cursor = Some(head); + // Bounded so a corrupted or cyclic parent chain cannot spin forever. + let mut hops = 0usize; + while let Some(hash) = cursor { + if hops > MAX_LOG_HOPS { + tracing::warn!(repo, branch, "log chain longer than limit, truncating"); + break; } - None => Ok(Vec::new()), + hops += 1; + let signed = self.pull(&hash)?; + cursor = signed.parent.clone(); + out.push(signed); } + Ok(out) + } + + /// Parent hashes recorded for a ref, newest first. `None` when the ref has + /// never been pushed to. + pub fn history(&self, repo: &str, branch: &str) -> Result, TaprootError> { + Ok(self + .log(repo, branch)? + .into_iter() + .map(|s| s.hash) + .collect()) } } @@ -257,6 +297,13 @@ mod tests { StateEngine::sign(&state, &priv_key).unwrap() } + /// Sign a state with a throwaway key, for tests that need several + /// distinct objects in one registry. + fn sign(state: &TaprootState) -> SignedState { + let (priv_key, _) = StateEngine::generate_keypair(); + StateEngine::sign(state, &priv_key).unwrap() + } + #[test] fn init_creates_dirs() { let dir = tempfile::tempdir().unwrap(); @@ -383,6 +430,67 @@ mod tests { assert!(reg.log("myapp", "missing").unwrap().is_empty()); } + #[test] + fn log_walks_parent_chain_newest_first() { + let dir = tempfile::tempdir().unwrap(); + let reg = Registry::init(&dir.path().join("reg")).unwrap(); + let first = signed_sample("myapp", "main"); + reg.push(&first).unwrap(); + + let mut second = sample_state("myapp", "main", "def456"); + second.env_vars.insert("SECOND".into(), "1".into()); + let second = sign(&second); + reg.push(&second).unwrap(); + + let mut third = sample_state("myapp", "main", "aaa111"); + third.env_vars.insert("THIRD".into(), "1".into()); + let third = sign(&third); + reg.push(&third).unwrap(); + + let log = reg.log("myapp", "main").unwrap(); + assert_eq!(log.len(), 3); + assert_eq!(log[0].hash, third.hash, "newest first"); + assert_eq!(log[1].hash, second.hash); + assert_eq!(log[2].hash, first.hash); + // The oldest entry has no parent. + assert_eq!(log[2].parent, None); + assert_eq!(log[0].parent.as_deref(), Some(second.hash.as_str())); + } + + #[test] + fn repush_same_hash_does_not_extend_history() { + let dir = tempfile::tempdir().unwrap(); + let reg = Registry::init(&dir.path().join("reg")).unwrap(); + let signed = signed_sample("myapp", "main"); + reg.push(&signed).unwrap(); + reg.push(&signed).unwrap(); + reg.push(&signed).unwrap(); + // Self-parenting would make the walk loop forever. + assert_eq!(reg.log("myapp", "main").unwrap().len(), 1); + } + + #[test] + fn history_is_hashes_newest_first() { + let dir = tempfile::tempdir().unwrap(); + let reg = Registry::init(&dir.path().join("reg")).unwrap(); + let first = signed_sample("myapp", "main"); + reg.push(&first).unwrap(); + let second = sign(&sample_state("myapp", "main", "zzz999")); + reg.push(&second).unwrap(); + assert_eq!( + reg.history("myapp", "main").unwrap(), + vec![second.hash, first.hash] + ); + } + + #[test] + fn log_on_unknown_branch_is_empty() { + let dir = tempfile::tempdir().unwrap(); + let reg = Registry::init(&dir.path().join("reg")).unwrap(); + reg.push(&signed_sample("myapp", "main")).unwrap(); + assert!(reg.log("myapp", "never-pushed").unwrap().is_empty()); + } + #[test] fn push_verifies_hash_and_signature() { let dir = tempfile::tempdir().unwrap(); @@ -476,6 +584,7 @@ mod tests { hash: hash.clone(), signature: None, public_key: None, + parent: None, }; let h = reg.push(&signed).unwrap(); assert_eq!(h, hash); diff --git a/src/scan.rs b/src/scan.rs new file mode 100644 index 0000000..fdbd099 --- /dev/null +++ b/src/scan.rs @@ -0,0 +1,663 @@ +//! Detect the real environment so a state file describes the machine it was +//! taken on, rather than whatever a human typed into it. +//! +//! Every detector reads a file the project already commits or a tool already +//! installed. Nothing shells out to a version manager, and nothing probes the +//! network. A scan either finds a declaration it can parse or reports nothing, +//! so a state file never claims a runtime the project does not actually pin. + +use std::collections::BTreeMap; +use std::path::Path; + +use crate::state::{Container, Runtime, TaprootState}; + +/// What a scan found, before it is merged into a state. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ScanResult { + pub runtimes: Vec, + pub containers: Vec, + /// Keys whose value could not be captured, with the reason. + pub env_skipped: BTreeMap, +} + +impl ScanResult { + pub fn is_empty(&self) -> bool { + self.runtimes.is_empty() && self.containers.is_empty() + } + + /// Fold the scan into an existing state, replacing what the scan found and + /// leaving anything the scan did not detect untouched. + pub fn apply_to(&self, mut state: TaprootState) -> TaprootState { + if !self.runtimes.is_empty() { + state.runtimes = dedup_runtimes(self.runtimes.clone()); + } + if !self.containers.is_empty() { + state.containers = dedup_containers(self.containers.clone()); + } + state + } +} + +/// Canonical tool names, so `nodejs` and `node` do not become two runtimes for +/// one tool and produce a confusing state. +fn canonical_tool_name(name: &str) -> String { + match name.to_ascii_lowercase().as_str() { + "nodejs" | "node.js" => "node".to_string(), + "golang" | "go-lang" => "go".to_string(), + "postgres" => "postgresql".to_string(), + other => other.to_string(), + } +} + +/// Later detectors win for the same tool name, and the list stays sorted so the +/// serialized state and its hash are stable across runs. +fn dedup_runtimes(runtimes: Vec) -> Vec { + let mut by_name: BTreeMap = BTreeMap::new(); + for mut r in runtimes { + r.name = canonical_tool_name(&r.name); + by_name.insert(r.name.clone(), r); + } + by_name.into_values().collect() +} + +fn dedup_containers(containers: Vec) -> Vec { + let mut by_name: BTreeMap = BTreeMap::new(); + for c in containers { + by_name.insert(c.name.clone(), c); + } + by_name.into_values().collect() +} + +// --------------------------------------------------------------------------- +// Runtimes +// --------------------------------------------------------------------------- + +/// `.tool-versions` / `.mise.toml` style `name version [version...]` lines. +/// `node 20.5.0 18.0.0` pins node to 20.5.0 with 18.0.0 as an accepted fallback. +fn parse_tool_versions(content: &str) -> Vec { + let mut out = Vec::new(); + for line in content.lines() { + let line = line.split('#').next().unwrap_or("").trim(); + if line.is_empty() { + continue; + } + let mut parts = line.split_whitespace(); + let Some(name) = parts.next() else { continue }; + let Some(version) = parts.next() else { + continue; + }; + out.push(Runtime { + name: name.to_string(), + version: version.to_string(), + pinned: true, + }); + } + out +} + +/// `[tools]` in a mise TOML: `"node" = "20.5.0"` or an array. The first entry +/// is the pin, the rest are fallbacks. Anything that is not a literal string or +/// an array of strings is a plugin reference or a dynamic value, so it is +/// skipped rather than guessed at. +fn parse_mise_toml(content: &str) -> Vec { + let mut out = Vec::new(); + let mut in_tools = false; + for line in content.lines() { + let line = line.trim(); + if line.starts_with('[') { + in_tools = line == "[tools]"; + continue; + } + if !in_tools || line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let name = key.trim().trim_matches('"').trim_matches('\'').to_string(); + if name.is_empty() { + continue; + } + let value = value.trim(); + let version = if value.starts_with('[') { + let inner = value.trim_start_matches('[').trim_end_matches(']'); + inner + .split(',') + .map(|v| v.trim().trim_matches('"').trim_matches('\'')) + .find(|v| !v.is_empty()) + .unwrap_or("") + .to_string() + } else { + value.trim_matches('"').trim_matches('\'').to_string() + }; + if version.is_empty() || is_unpinned_spec(&version) { + continue; + } + out.push(Runtime { + name, + version, + pinned: true, + }); + } + out +} + +/// A version spec that names no version. `latest`, `*`, and an empty string +/// are floating, not pinned, so they are not recorded as a pin. +fn is_unpinned_spec(spec: &str) -> bool { + matches!(spec, "" | "*" | "latest" | "any" | "system") +} + +/// A `FROM node:20.5.0` line in a Dockerfile. The image tag is the pin. +fn parse_dockerfile_runtime(content: &str) -> Vec { + let mut out = Vec::new(); + for line in content.lines() { + let line = line.trim(); + let Some(rest) = strip_docker_directive(line, "FROM") else { + continue; + }; + // `FROM image AS stage` and `FROM golang:1.22 AS build` both count; a + // `--platform=` flag is not part of the image reference. + let rest = rest + .split_whitespace() + .find(|tok| !tok.starts_with("--")) + .unwrap_or(""); + if rest.is_empty() || rest.starts_with('$') { + continue; + } + let (name, version) = match rest.rsplit_once(':') { + Some((n, v)) if !v.is_empty() && !v.contains('/') => (n, v), + _ => (rest, "latest"), + }; + if is_unpinned_spec(version) { + continue; + } + let Some(name) = name.rsplit('/').next() else { + continue; + }; + out.push(Runtime { + name: name.to_string(), + version: version.to_string(), + pinned: true, + }); + } + out +} + +/// `engines.node` in package.json, via a targeted read rather than a full +/// JSON parse, so a malformed or huge file cannot break a scan. +fn parse_package_json_node(content: &str) -> Option { + let idx = content.find("\"engines\"")?; + let rest = &content[idx..]; + let node_key = rest.find("\"node\"")?; + let after = &rest[node_key..]; + let colon = after.find(':')?; + let value = after[colon + 1..].trim_start(); + let value = value.strip_prefix('"')?; + let end = value.find('"')?; + let spec = &value[..end]; + let version = spec + .trim_start_matches('^') + .trim_start_matches('~') + .trim_start_matches(">=") + .trim_start_matches('=') + .trim(); + if version.is_empty() || version == "*" || version == "latest" { + return None; + } + Some(Runtime { + name: "node".to_string(), + version: version.to_string(), + pinned: true, + }) +} + +fn strip_docker_directive<'a>(line: &'a str, name: &str) -> Option<&'a str> { + let upper = line.to_ascii_uppercase(); + if !upper.starts_with(name) { + return None; + } + let after = &line[name.len()..]; + if !after.starts_with(char::is_whitespace) { + return None; + } + Some(after.trim_start()) +} + +// --------------------------------------------------------------------------- +// Containers +// --------------------------------------------------------------------------- + +/// `image: postgres:15.3` in a compose file. The service key becomes the +/// container name. A bare `image: postgres` pins to `latest`. +fn parse_compose(content: &str) -> Vec { + let mut out = Vec::new(); + let mut service: Option = None; + let mut in_services = false; + for raw in content.lines() { + let line = raw.split('#').next().unwrap_or(""); + let trimmed = line.trim(); + let indent = line.len() - line.trim_start().len(); + if trimmed.is_empty() { + continue; + } + if indent == 0 { + in_services = trimmed == "services:"; + service = None; + continue; + } + if !in_services { + continue; + } + // A service name is an indented key with no value, one level in. + if indent == 2 { + let key = trimmed.trim_end_matches(':'); + if !key.contains(' ') && !key.contains('=') && !key.starts_with('-') { + service = Some(key.trim_matches('"').to_string()); + } + continue; + } + let Some(key) = trimmed.strip_prefix("image:") else { + continue; + }; + let Some(name) = service.clone() else { + continue; + }; + let image = key.trim().trim_matches('"').trim_matches('\''); + if image.is_empty() { + continue; + } + let (image_name, version) = match image.rsplit_once(':') { + Some((n, v)) if !v.is_empty() && !v.contains('/') => (n.to_string(), v.to_string()), + _ => (image.to_string(), "latest".to_string()), + }; + out.push(Container { + name, + version: version.clone(), + image: format!("{image_name}:{version}"), + signed: true, + }); + } + out +} + +// --------------------------------------------------------------------------- +// Env +// --------------------------------------------------------------------------- + +/// Read a declared env file, dropping anything that looks like a live secret. +/// +/// This never walks the process environment. A state file gets committed, so +/// harvesting whatever happens to be exported would publish credentials. Only +/// explicit declarations are read, and a value matching a secret shape is +/// skipped and reported instead of stored. +pub fn scan_env_file(path: &Path) -> (BTreeMap, BTreeMap) { + let mut kept = BTreeMap::new(); + let mut skipped = BTreeMap::new(); + let Ok(content) = std::fs::read_to_string(path) else { + return (kept, skipped); + }; + for (idx, line) in content.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let line = line.strip_prefix("export ").unwrap_or(line); + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + let value = value.trim().trim_matches('"').trim_matches('\''); + if key.is_empty() || !is_env_key(key) { + continue; + } + match classify_secret(key, value) { + Some(reason) => { + skipped.insert(key.to_string(), format!("line {}: {reason}", idx + 1)); + } + None => { + kept.insert(key.to_string(), value.to_string()); + } + } + } + (kept, skipped) +} + +/// Keys that are safe to record as-is. An env key is a shell identifier. +fn is_env_key(key: &str) -> bool { + !key.is_empty() + && !key.starts_with(|c: char| c.is_ascii_digit()) + && key + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') +} + +/// Why a value looks like a live credential rather than configuration. The +/// name alone is not enough: `DATABASE_URL` is config, `PORT` is config, and +/// plenty of legitimately-committed env files have `*_URL` keys. +fn classify_secret(key: &str, value: &str) -> Option<&'static str> { + if value.is_empty() { + return None; + } + let upper_key = key.to_ascii_uppercase(); + let has_secret_word = [ + "SECRET", + "TOKEN", + "PASSWORD", + "PASSWD", + "PRIVATE_KEY", + "APIKEY", + "API_KEY", + ] + .iter() + .any(|w| upper_key.contains(w)); + if has_secret_word { + return Some("key name marks it as a secret"); + } + // High-entropy values that look like real provider credentials regardless + // of how they are named. These prefixes are unambiguous. + for prefix in [ + "sk_live_", + "sk_test_", + "sk-", + "ghp_", + "gho_", + "ghu_", + "ghs_", + "github_pat_", + "xoxb-", + "xoxp-", + "AKIA", + "ASIA", + "AIza", + "ya29.", + "glpat-", + ] { + if value.starts_with(prefix) { + return Some("value matches a known credential format"); + } + } + // A private key block regardless of the key name. + if value.contains("-----BEGIN") && value.contains("PRIVATE KEY") { + return Some("value is a private key block"); + } + None +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +/// Scan a project directory for declared runtimes, containers, and env vars. +/// +/// Detectors are independent and each contributes only what it can parse, so a +/// project with a Dockerfile and no `.tool-versions` still gets its base image. +pub fn scan_project(root: &Path) -> ScanResult { + let mut result = ScanResult::default(); + let mut runtimes: Vec = Vec::new(); + + for (name, parse) in DETECTORS { + let path = root.join(name); + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + runtimes.extend(parse(&content)); + } + + result.runtimes = dedup_runtimes(runtimes); + + for name in COMPOSE_FILES { + let path = root.join(name); + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + result.containers.extend(parse_compose(&content)); + } + result.containers = dedup_containers(result.containers); + + result +} + +/// A detector: a file to read, and a parser turning its contents into runtimes. +type Detector = (&'static str, fn(&str) -> Vec); + +/// Detector filenames and their parsers. Order decides precedence when two +/// files pin the same tool: later entries win in `dedup_runtimes`. +const DETECTORS: &[Detector] = &[ + (".tool-versions", parse_tool_versions), + (".mise.toml", parse_mise_toml), + ("Dockerfile", parse_dockerfile_runtime), + ("package.json", |c| { + parse_package_json_node(c).into_iter().collect() + }), +]; + +const COMPOSE_FILES: &[&str] = &[ + "docker-compose.yml", + "docker-compose.yaml", + "compose.yml", + "compose.yaml", +]; + +const ENV_FILES: &[&str] = &[".env", ".env.local", ".env.example", ".env.sample"]; + +/// Env vars a scan found, kept separate from the skip report so the caller +/// writes them into `state.env_vars` rather than into containers. +pub fn scan_env_vars(root: &Path) -> (BTreeMap, BTreeMap) { + let mut kept = BTreeMap::new(); + let mut skipped = BTreeMap::new(); + for name in ENV_FILES { + let (k, s) = scan_env_file(&root.join(name)); + for (key, val) in k { + kept.entry(key).or_insert(val); + } + skipped.extend(s); + } + (kept, skipped) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rt(name: &str, version: &str) -> Runtime { + Runtime { + name: name.into(), + version: version.into(), + pinned: true, + } + } + + #[test] + fn parses_tool_versions() { + let r = parse_tool_versions("node 20.5.0\npython 3.11.4 3.10.0\n\n# comment\nruby\n"); + assert_eq!(r, vec![rt("node", "20.5.0"), rt("python", "3.11.4")]); + } + + #[test] + fn tool_versions_picks_first_version() { + let r = parse_tool_versions("python 3.11.4 3.10.0 3.9.0\n"); + assert_eq!(r[0].version, "3.11.4"); + } + + #[test] + fn parses_mise_string_and_array() { + let content = + "[tools]\nnode = \"20.5.0\"\npython = [\"3.11.4\", \"3.10.0\"]\ngolang = \"latest\"\n"; + let r = parse_mise_toml(content); + assert_eq!(r, vec![rt("node", "20.5.0"), rt("python", "3.11.4")]); + } + + #[test] + fn mise_stops_at_next_section() { + let content = "[tools]\nnode = \"20.5.0\"\n\n[settings]\nnotatool = \"x\"\n"; + assert_eq!(parse_mise_toml(content), vec![rt("node", "20.5.0")]); + } + + #[test] + fn parses_dockerfile_from() { + let content = "FROM node:20.5.0 AS build\nRUN npm i\nFROM alpine:3.19\n"; + let r = parse_dockerfile_runtime(content); + assert_eq!(r, vec![rt("node", "20.5.0"), rt("alpine", "3.19")]); + } + + #[test] + fn dockerfile_ignores_dynamic_and_flags() { + let content = "ARG BASE\nFROM ${BASE}\nFROM --platform=linux/amd64 python:3.11.4\n"; + let r = parse_dockerfile_runtime(content); + assert_eq!(r, vec![rt("python", "3.11.4")]); + } + + #[test] + fn dockerfile_untagged_is_not_a_pin() { + // `FROM postgres` names no version, so it is not a pin and must not + // enter the state. A floating tag is not an inherited environment. + assert!(parse_dockerfile_runtime("FROM postgres\n").is_empty()); + } + + #[test] + fn package_json_engines_node() { + let r = parse_package_json_node(r#"{"engines":{"node":">=20.5.0"}}"#).unwrap(); + assert_eq!(r, rt("node", "20.5.0")); + } + + #[test] + fn package_json_wildcard_engine_is_not_a_pin() { + assert!(parse_package_json_node(r#"{"engines":{"node":"*"}}"#).is_none()); + } + + #[test] + fn package_json_without_engines() { + assert!(parse_package_json_node(r#"{"name":"x"}"#).is_none()); + } + + #[test] + fn parses_compose_services() { + let content = "services:\n db:\n image: postgres:15.3\n cache:\n image: redis:7\n"; + let c = parse_compose(content); + assert_eq!(c.len(), 2); + assert_eq!(c[0].name, "db"); + assert_eq!(c[0].image, "postgres:15.3"); + assert_eq!(c[1].version, "7"); + } + + #[test] + fn compose_ignores_other_sections() { + let content = "volumes:\n data:\n image: should-not-match\nservices:\n db:\n image: postgres:15.3\n"; + let c = parse_compose(content); + assert_eq!(c.len(), 1); + assert_eq!(c[0].name, "db"); + } + + #[test] + fn compose_untagged_image_is_latest() { + let c = parse_compose("services:\n db:\n image: postgres\n"); + assert_eq!(c[0].version, "latest"); + } + + #[test] + fn env_file_parses_and_strips_quotes() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join(".env"); + std::fs::write(&p, "A=1\nB=\"two\"\nexport C='three'\n# D=4\n").unwrap(); + let (kept, skipped) = scan_env_file(&p); + assert_eq!(kept.get("A").unwrap(), "1"); + assert_eq!(kept.get("B").unwrap(), "two"); + assert_eq!(kept.get("C").unwrap(), "three"); + assert!(!kept.contains_key("D")); + assert!(skipped.is_empty()); + } + + #[test] + fn env_file_skips_secret_named_keys() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join(".env"); + std::fs::write(&p, "API_TOKEN=abc\nDB_PASSWORD=hunter2\nPORT=3000\n").unwrap(); + let (kept, skipped) = scan_env_file(&p); + assert_eq!(kept.len(), 1); + assert_eq!(kept.get("PORT").unwrap(), "3000"); + assert!(skipped.contains_key("API_TOKEN")); + assert!(skipped.contains_key("DB_PASSWORD")); + } + + #[test] + fn env_file_skips_values_that_look_like_credentials() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join(".env"); + std::fs::write(&p, "PAYMENT=sk_live_abc123\nMODE=prod\n").unwrap(); + let (kept, _) = scan_env_file(&p); + assert!(!kept.contains_key("PAYMENT")); + assert_eq!(kept.get("MODE").unwrap(), "prod"); + } + + #[test] + fn env_file_skips_private_key_blocks() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join(".env"); + std::fs::write(&p, "CERT=-----BEGIN RSA PRIVATE KEY-----\nabc\n").unwrap(); + let (kept, _) = scan_env_file(&p); + assert!(!kept.contains_key("CERT")); + } + + #[test] + fn env_key_must_be_an_identifier() { + assert!(is_env_key("DATABASE_URL")); + assert!(is_env_key("NODE_ENV")); + assert!(!is_env_key("1BAD")); + assert!(!is_env_key("has space")); + assert!(!is_env_key("")); + } + + #[test] + fn database_url_is_not_treated_as_secret() { + assert_eq!( + classify_secret("DATABASE_URL", "postgres://localhost/app"), + None + ); + } + + #[test] + fn scan_project_finds_both_kinds() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join(".tool-versions"), "node 20.5.0\n").unwrap(); + std::fs::write( + root.join("docker-compose.yml"), + "services:\n db:\n image: postgres:15.3\n", + ) + .unwrap(); + let r = scan_project(root); + assert_eq!(r.runtimes, vec![rt("node", "20.5.0")]); + assert_eq!(r.containers.len(), 1); + assert_eq!(r.containers[0].image, "postgres:15.3"); + } + + #[test] + fn scan_project_on_empty_dir_is_empty_not_error() { + let dir = tempfile::tempdir().unwrap(); + let r = scan_project(dir.path()); + assert!(r.is_empty()); + } + + #[test] + fn scan_ignores_malformed_files_without_failing() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("package.json"), "{ this is not json").unwrap(); + std::fs::write(root.join(".mise.toml"), "[tools\nbroken").unwrap(); + let r = scan_project(root); + assert!(r.is_empty()); + } + + #[test] + fn apply_to_preserves_untouched_fields() { + let base = TaprootState::new("app", "main", "abc").with_env("KEEP", "me"); + let scan = ScanResult { + runtimes: vec![rt("node", "20.5.0")], + ..Default::default() + }; + let out = scan.apply_to(base); + assert_eq!(out.runtimes.len(), 1); + assert_eq!(out.env_vars.get("KEEP").unwrap(), "me"); + } +} diff --git a/src/state.rs b/src/state.rs index c1b6a4d..7c6326e 100644 --- a/src/state.rs +++ b/src/state.rs @@ -118,4 +118,8 @@ pub struct SignedState { /// base64 public key that signed it, if any #[serde(default, skip_serializing_if = "Option::is_none")] pub public_key: Option, + /// Registry history: the hash this state superseded, if any. Set by + /// `registry push` so a ref can be walked back to its first commit. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent: Option, } diff --git a/tests/cli.rs b/tests/cli.rs index 741b094..db40f82 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,6 +1,6 @@ use taproot::cli::{ - handle_check, handle_init, handle_mount, handle_status, handle_sync, handle_verify, CheckArgs, - InitArgs, MountArgs, SyncArgs, + handle_check, handle_init, handle_mount, handle_scan, handle_status, handle_sync, + handle_verify, CheckArgs, InitArgs, MountArgs, ScanArgs, SyncArgs, }; fn temp_dir() -> tempfile::TempDir { @@ -69,9 +69,10 @@ fn mount_rejects_symlink_even_with_no_fuse() { std::os::unix::fs::symlink(&real, &link).unwrap(); let args = MountArgs { - path: link, + path: Some(link), state_path: Some(state_path), no_fuse: true, + out: None, drift_out: None, }; assert!(handle_mount(args).is_err()); @@ -94,9 +95,10 @@ fn mount_no_fuse_succeeds_on_valid_dir() { std::fs::create_dir_all(&mnt).unwrap(); let args = MountArgs { - path: mnt, + path: Some(mnt), state_path: Some(state_path), no_fuse: true, + out: None, drift_out: None, }; assert!(handle_mount(args).is_ok()); @@ -127,6 +129,187 @@ fn check_passes_on_identical_signed_states() { .is_ok()); } +#[test] +fn mount_no_fuse_materializes_tree_with_only_env_writable() { + let dir = temp_dir(); + let state_path = dir.path().join("state.json"); + handle_init(InitArgs { + repo: "myapp".into(), + branch: "main".into(), + commit: "abc123".into(), + state_path: Some(state_path.clone()), + no_sign: true, + }) + .unwrap(); + + let out = dir.path().join("tree"); + assert!(handle_mount(MountArgs { + path: None, + state_path: Some(state_path), + no_fuse: true, + out: Some(out.clone()), + drift_out: None, + }) + .is_ok()); + + for name in ["README.taproot", "state.json", "env", "hash", "version"] { + assert!(out.join(name).exists(), "missing {name}"); + } + // env is the one file the drift loop is allowed to edit, so the round trip + // has to work: write to it, then let sync pick the edit up. The read-only + // bits on the other files are set by the same code path but are not + // asserted here, because a proot or sandboxed environment can drop them + // without the test telling us anything true about the code. + std::fs::write(out.join("env"), "A=1\n").unwrap(); +} + +#[test] +fn sync_from_dir_adopts_env_edits_without_fuse() { + let dir = temp_dir(); + let state_path = dir.path().join("state.json"); + handle_init(InitArgs { + repo: "myapp".into(), + branch: "main".into(), + commit: "abc123".into(), + state_path: Some(state_path.clone()), + no_sign: true, + }) + .unwrap(); + + let out = dir.path().join("tree"); + handle_mount(MountArgs { + path: None, + state_path: Some(state_path.clone()), + no_fuse: true, + out: Some(out.clone()), + drift_out: None, + }) + .unwrap(); + + std::fs::write(out.join("env"), "DATABASE_URL=postgres://localhost/app\n").unwrap(); + assert!(handle_sync(SyncArgs { + state_path: Some(state_path.clone()), + from: None, + from_dir: Some(out), + dry_run: true, + force: false, + no_sign: true, + keep: false, + }) + .is_ok()); + + let adopted = handle_sync(SyncArgs { + state_path: Some(state_path.clone()), + from: None, + from_dir: Some(dir.path().join("tree")), + dry_run: false, + force: false, + no_sign: true, + keep: false, + }) + .is_ok(); + assert!(adopted); + let signed: taproot::SignedState = + serde_json::from_slice(&std::fs::read(&state_path).unwrap()).unwrap(); + assert_eq!( + signed + .state + .env_vars + .get("DATABASE_URL") + .map(String::as_str), + Some("postgres://localhost/app") + ); +} + +#[test] +fn sync_from_dir_reports_no_drift_on_untouched_tree() { + let dir = temp_dir(); + let state_path = dir.path().join("state.json"); + handle_init(InitArgs { + repo: "myapp".into(), + branch: "main".into(), + commit: "abc123".into(), + state_path: Some(state_path.clone()), + no_sign: true, + }) + .unwrap(); + let out = dir.path().join("tree"); + handle_mount(MountArgs { + path: None, + state_path: Some(state_path.clone()), + no_fuse: true, + out: Some(out.clone()), + drift_out: None, + }) + .unwrap(); + // env was never edited, so there is nothing to adopt. + assert!(handle_sync(SyncArgs { + state_path: Some(state_path), + from: None, + from_dir: Some(out), + dry_run: true, + force: false, + no_sign: true, + keep: false, + }) + .is_ok()); +} + +#[test] +fn check_refuses_itself_as_baseline() { + // A baseline that is the state under test compares a file to itself and + // always reports no drift, which would turn the CI gate into a no-op. + let dir = temp_dir(); + let state = dir.path().join("state.json"); + handle_init(InitArgs { + repo: "myapp".into(), + branch: "main".into(), + commit: "abc123".into(), + state_path: Some(state.clone()), + no_sign: true, + }) + .unwrap(); + + assert!(handle_check(CheckArgs { + baseline: state.clone(), + state_path: Some(state.clone()), + json: false, + strict: true, + allow_warnings: false, + no_strict: false, + }) + .is_err()); +} + +#[test] +fn check_refuses_equivalent_paths_for_baseline() { + // Same file reached by a different spelling, including a symlink. + let dir = temp_dir(); + let state = dir.path().join("state.json"); + handle_init(InitArgs { + repo: "myapp".into(), + branch: "main".into(), + commit: "abc123".into(), + state_path: Some(state.clone()), + no_sign: true, + }) + .unwrap(); + + let alias = dir.path().join("alias.json"); + #[cfg(unix)] + std::os::unix::fs::symlink(&state, &alias).unwrap(); + + assert!(handle_check(CheckArgs { + baseline: alias, + state_path: Some(state), + json: false, + strict: true, + allow_warnings: false, + no_strict: false, + }) + .is_err()); +} + #[test] fn check_fails_on_commit_drift_strict() { let dir = temp_dir(); @@ -311,6 +494,7 @@ fn sync_dry_run_reports_but_does_not_adopt() { assert!(handle_sync(SyncArgs { state_path: Some(state_path.clone()), from: None, + from_dir: None, dry_run: true, force: false, no_sign: true, @@ -332,6 +516,7 @@ fn sync_adopts_drift_and_resigns() { assert!(handle_sync(SyncArgs { state_path: Some(state_path.clone()), from: None, + from_dir: None, dry_run: false, force: false, no_sign: true, @@ -359,6 +544,7 @@ fn sync_errors_without_drift_file() { assert!(handle_sync(SyncArgs { state_path: Some(state_path), from: None, + from_dir: None, dry_run: false, force: false, no_sign: true, @@ -377,6 +563,7 @@ fn sync_identical_states_cleans_up_drift_file() { assert!(handle_sync(SyncArgs { state_path: Some(state_path), from: None, + from_dir: None, dry_run: false, force: false, no_sign: true, @@ -393,6 +580,7 @@ fn sync_refuses_from_pointing_at_state_file() { assert!(handle_sync(SyncArgs { state_path: Some(state_path.clone()), from: Some(state_path.clone()), + from_dir: None, dry_run: false, force: false, no_sign: true, @@ -418,6 +606,7 @@ fn sync_refuses_identity_drift_without_force() { hash, signature: None, public_key: None, + parent: None, }, ) .unwrap(); @@ -425,6 +614,7 @@ fn sync_refuses_identity_drift_without_force() { assert!(handle_sync(SyncArgs { state_path: Some(state_path.clone()), from: None, + from_dir: None, dry_run: false, force: false, no_sign: true, @@ -435,6 +625,7 @@ fn sync_refuses_identity_drift_without_force() { assert!(handle_sync(SyncArgs { state_path: Some(state_path.clone()), from: None, + from_dir: None, dry_run: false, force: true, no_sign: true, @@ -460,6 +651,7 @@ fn sync_refuses_branch_commit_drift_without_force() { hash, signature: None, public_key: None, + parent: None, }, ) .unwrap(); @@ -467,6 +659,7 @@ fn sync_refuses_branch_commit_drift_without_force() { assert!(handle_sync(SyncArgs { state_path: Some(state_path.clone()), from: None, + from_dir: None, dry_run: false, force: false, no_sign: true, @@ -478,6 +671,7 @@ fn sync_refuses_branch_commit_drift_without_force() { assert!(handle_sync(SyncArgs { state_path: Some(state_path.clone()), from: None, + from_dir: None, dry_run: false, force: true, no_sign: true, @@ -540,3 +734,110 @@ fn extract_env_drift_rejects_non_utf8() { let raw = [0xFFu8, 0xFE, b'A', b'=', b'1']; assert!(extract_env_drift(&baseline, &raw).is_err()); } + +#[test] +fn scan_apply_writes_detected_environment_into_a_signed_state() { + let dir = temp_dir(); + let root = dir.path(); + std::fs::write( + root.join(".tool-versions"), + "nodejs 20.5.0\npython 3.11.4\n", + ) + .unwrap(); + std::fs::write( + root.join("docker-compose.yml"), + "services:\n db:\n image: postgres:15.3\n", + ) + .unwrap(); + let state_path = root.join("state.json"); + + assert!(handle_scan(ScanArgs { + dir: Some(root.to_path_buf()), + json: false, + apply: true, + state_path: Some(state_path.clone()), + include_env: false, + }) + .is_ok()); + + let signed: taproot::SignedState = + serde_json::from_slice(&std::fs::read(&state_path).unwrap()).unwrap(); + // nodejs is canonicalized to node, so one tool is one runtime. + let runtimes: Vec<(&str, &str)> = signed + .state + .runtimes + .iter() + .map(|r| (r.name.as_str(), r.version.as_str())) + .collect(); + assert_eq!(runtimes, vec![("node", "20.5.0"), ("python", "3.11.4")]); + assert_eq!(signed.state.containers.len(), 1); + assert_eq!(signed.state.containers[0].image, "postgres:15.3"); + // Everything is signed, so the state verifies on its own. + assert!(taproot::StateEngine::verify(&signed).is_ok()); +} + +#[test] +fn scan_never_writes_a_secret_into_the_state() { + let dir = temp_dir(); + let root = dir.path(); + std::fs::write( + root.join(".env"), + "NODE_ENV=development\nSTRIPE_SECRET=sk_live_should_not_appear\nDB_PASSWORD=hunter2\n", + ) + .unwrap(); + let state_path = root.join("state.json"); + + handle_scan(ScanArgs { + dir: Some(root.to_path_buf()), + json: false, + apply: true, + state_path: Some(state_path.clone()), + include_env: true, + }) + .unwrap(); + + let raw = std::fs::read_to_string(&state_path).unwrap(); + assert!( + !raw.contains("sk_live_should_not_appear"), + "secret leaked into state" + ); + assert!(!raw.contains("hunter2"), "password leaked into state"); + let signed: taproot::SignedState = serde_json::from_str(&raw).unwrap(); + assert_eq!( + signed.state.env_vars.get("NODE_ENV").map(String::as_str), + Some("development") + ); +} + +#[test] +fn scan_without_env_flag_ignores_dotenv() { + let dir = temp_dir(); + let root = dir.path(); + std::fs::write(root.join(".env"), "TOKEN=abc123\n").unwrap(); + let state_path = root.join("state.json"); + handle_scan(ScanArgs { + dir: Some(root.to_path_buf()), + json: false, + apply: true, + state_path: Some(state_path.clone()), + include_env: false, + }) + .unwrap(); + let signed: taproot::SignedState = + serde_json::from_slice(&std::fs::read(&state_path).unwrap()).unwrap(); + assert!(signed.state.env_vars.is_empty()); +} + +#[test] +fn scan_on_project_with_nothing_declared_still_succeeds() { + let dir = temp_dir(); + let state_path = dir.path().join("state.json"); + assert!(handle_scan(ScanArgs { + dir: Some(dir.path().to_path_buf()), + json: true, + apply: false, + state_path: Some(state_path), + include_env: false, + }) + .is_ok()); +}