From 09940a5d9f6cda2cd8f7f5d14c2b194646259679 Mon Sep 17 00:00:00 2001 From: Sergio Rovira Date: Tue, 23 Jun 2026 23:01:05 -0400 Subject: [PATCH 1/5] Update README for v0.2.2 release --- README.md | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6427dbc..c282d12 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Root v0.2.1 +# Root v0.2.2 > A curated package manager for developer CLI tools, backed by Nix. @@ -8,6 +8,29 @@ undo it — without needing to learn Nix. [![CI](https://github.com/sgr0691/Root/actions/workflows/ci.yml/badge.svg)](https://github.com/sgr0691/Root/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) +## What v0.2.2 Changed + +v0.2.2 is the **Nix Reliability & Recovery** release: + +- **Nix command audit** — Every Nix invocation catalogued with expected outputs, + exit codes, failure modes, and error-handling gaps. See + `Docs/Nix/V0_2_2_NIX_COMMAND_AUDIT.md`. +- **Experimental feature detection** — `root doctor` probes for `nix-command` + and `flakes` support and explains how to enable them when missing. +- **Profile generation validation** — After every mutation (install, update, + rollback, restore), Root validates that the Nix profile generation actually + changed and expected output paths are present. +- **Store path hardening** — Derivation paths (`.drv`) are strictly separated + from output paths at every layer. Lockfile validation rejects `.drv` paths + in output fields before any mutation. +- **Error normalization** — All Nix failure modes produce clear, actionable + messages without leaking raw Nix output. Covers missing Nix, disabled + features, missing attributes, network failures, profile conflicts, and more. +- **Installer validation** — `root init --install-nix` now explains what will + happen, requires explicit confirmation, detects platform, and runs a + post-install probe. +- **New docs** — Nix reliability notes and a dedicated smoke test document. + ## What v0.2.1 Changed v0.2.1 is the **Performance & Reliability** release: @@ -340,7 +363,7 @@ contain the full deterministic lock state. The event ledger at `~/.root/events.jsonl` records every operation. Verification checks binaries from the Root-managed profile, not from PATH. -## Limitations (v0.2.1) +## Limitations (v0.2.2) - **Curated catalog only.** Root supports a curated catalog only — 42 packages across eleven categories. Arbitrary `root install ` is not yet @@ -371,7 +394,7 @@ from the Root-managed profile, not from PATH. ## Experimental Commands -The CLI includes additional commands that are **not part of the v0.2.1 public +The CLI includes additional commands that are **not part of the v0.2.2 public surface**. They may change, break, or be removed without notice: | Command | Status | From 0b3ab6f72b12bd56f6fe5dd8ff73754498742fd8 Mon Sep 17 00:00:00 2001 From: Sergio Rovira Date: Wed, 24 Jun 2026 01:19:09 -0400 Subject: [PATCH 2/5] Update README for v0.2.3 release --- README.md | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c282d12..a2b63e4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Root v0.2.2 +# Root v0.2.3 > A curated package manager for developer CLI tools, backed by Nix. @@ -8,6 +8,28 @@ undo it — without needing to learn Nix. [![CI](https://github.com/sgr0691/Root/actions/workflows/ci.yml/badge.svg)](https://github.com/sgr0691/Root/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) +## What v0.2.3 Changed + +v0.2.3 is the **Sandbox Hardening** release: + +- **Lifecycle validation** — Sandboxes follow a strict state machine: Created → + Running → Completed/Destroyed. Invalid state transitions are rejected. +- **Cleanup guarantees** — Destroy always attempts cleanup; failed or interrupted + runs trigger cleanup; stale sandboxes are detectable. +- **Resource limits** — Docker containers are created with memory (2 GB default) + and CPU (2 core) limits. Run `root sandbox create` with `--memory` and `--cpus`. +- **Timeout handling** — `root sandbox run` accepts `--timeout` (default 300s). + Timed-out runs are terminated and recorded in the event ledger. +- **Post-create and post-destroy validation** — Container existence, reachability, + and cleanup are verified after each operation. +- **Event ledger integration** — Every sandbox action (create, run, timeout, + failure, destroy, cleanup) is recorded with timestamp, sandbox ID, and result. +- **Error normalization** — Sandbox failures produce clear messages for Docker + unavailable, image pull failure, startup failure, timeout, resource limits, + permission denied, and cleanup failure. +- **Sandbox audit** — Full subsystem audit at `Docs/Sandbox/V0_2_3_SANDBOX_AUDIT.md`. +- **New docs** — Sandbox notes and a dedicated smoke test document. + ## What v0.2.2 Changed v0.2.2 is the **Nix Reliability & Recovery** release: @@ -363,7 +385,7 @@ contain the full deterministic lock state. The event ledger at `~/.root/events.jsonl` records every operation. Verification checks binaries from the Root-managed profile, not from PATH. -## Limitations (v0.2.2) +## Limitations (v0.2.3) - **Curated catalog only.** Root supports a curated catalog only — 42 packages across eleven categories. Arbitrary `root install ` is not yet @@ -394,7 +416,7 @@ from the Root-managed profile, not from PATH. ## Experimental Commands -The CLI includes additional commands that are **not part of the v0.2.2 public +The CLI includes additional commands that are **not part of the v0.2.3 public surface**. They may change, break, or be removed without notice: | Command | Status | From 3cede0d21e1e96b3cdba9f7b59f4245abfe02da7 Mon Sep 17 00:00:00 2001 From: Sergio Rovira Date: Wed, 24 Jun 2026 02:12:14 -0400 Subject: [PATCH 3/5] v0.2.3 Sandbox Hardening: lifecycle, cleanup, limits, timeout, validation, events, error normalization --- CHANGELOG.md | 54 + Cargo.lock | 32 +- Docs/Nix/V0_2_2_NIX_COMMAND_AUDIT.md | 240 ++++ Docs/Nix/V0_2_2_NIX_RELIABILITY_NOTES.md | 580 ++++++++++ .../V0_2_2_NIX_RELIABILITY_SMOKE_TEST.md | 523 +++++++++ Docs/Release/V0_2_3_SANDBOX_SMOKE_TEST.md | 540 +++++++++ Docs/Sandbox/V0_2_1_SANDBOX_AUDIT.md | 516 +++++++++ Docs/Sandbox/V0_2_3_SANDBOX_NOTES.md | 616 ++++++++++ crates/root-cli/src/main.rs | 221 ++-- crates/root-core/src/events.rs | 8 +- crates/root-core/src/lib.rs | 1015 ++++++++++++++++- crates/root-doctor/src/lib.rs | 118 ++ crates/root-lockfile/Cargo.toml | 1 + crates/root-lockfile/src/lib.rs | 284 +++++ crates/root-nix/src/lib.rs | 947 ++++++++++++++- crates/root-sandbox/Cargo.toml | 1 + crates/root-sandbox/src/lib.rs | 897 +++++++++++++-- 17 files changed, 6334 insertions(+), 259 deletions(-) create mode 100644 Docs/Nix/V0_2_2_NIX_COMMAND_AUDIT.md create mode 100644 Docs/Nix/V0_2_2_NIX_RELIABILITY_NOTES.md create mode 100644 Docs/Release/V0_2_2_NIX_RELIABILITY_SMOKE_TEST.md create mode 100644 Docs/Release/V0_2_3_SANDBOX_SMOKE_TEST.md create mode 100644 Docs/Sandbox/V0_2_1_SANDBOX_AUDIT.md create mode 100644 Docs/Sandbox/V0_2_3_SANDBOX_NOTES.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b7bfd3b..f5ec48e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,60 @@ All notable changes to Root are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.3] - 2026-06-24 + +### Added + +- **Sandbox lifecycle validation.** Sandboxes follow a strict state machine (Created → Running → Completed/Failed → Destroyed). Invalid transitions are rejected with clear errors. (Phase 2) +- **Cleanup guarantees.** Destroy always attempts cleanup. Failed and timed-out runs trigger automatic cleanup. Stale sandboxes detectable via `root sandbox list`. (Phase 3) +- **Resource limits.** `root sandbox create` accepts `--memory` (default 2g) and `--cpus` (default 2.0). Docker containers are created with these limits. (Phase 4) +- **Timeout handling.** `root sandbox run` accepts `--timeout` (default 300s). Timed-out runs are killed, cleaned up, and recorded in the event ledger. (Phase 5) +- **Sandbox validation.** Post-create validation verifies container exists and is reachable. Post-destroy validation verifies container is removed. (Phase 6) +- **Event ledger integration.** Every sandbox action (create, run, timeout, failure, destroy, cleanup) is recorded with sandbox ID, timestamp, and result. (Phase 7) +- **Sandbox error normalization.** Clear, actionable messages for Docker unavailable, image pull failure, container startup failure, timeout, resource limit exceeded, permission denied, and cleanup failure. (Phase 8) +- **Sandbox audit.** Full subsystem audit at Docs/Sandbox/V0_2_3_SANDBOX_AUDIT.md. (Phase 1) +- **Sandbox smoke tests.** New smoke test document at Docs/Release/V0_2_3_SANDBOX_SMOKE_TEST.md. (Phase 9) +- **Sandbox documentation.** New reference document at Docs/Sandbox/V0_2_3_SANDBOX_NOTES.md. (Phase 10) +- **30 new tests** covering lifecycle validation, cleanup, resource limits, timeout, validation, event recording, and error normalization (38 total in root-sandbox). + +### Changed + +- README updated for v0.2.3. +- SandboxProvider trait updated with `create(memory, cpus)`, `run_command(timeout)`, `check_exists`, `check_reachable`. +- SandboxInstance uses typed `SandboxState` enum instead of string status. +- RootEvent gains `sandbox_id` field for sandbox operation tracking. + +### Fixed + +- Sandbox state transitions now validated — running a destroyed sandbox is rejected early. +- Docker errors normalized into user-friendly messages. +- Containers are validated after create and destroyed on validation failure. + +## [0.2.2] - 2026-06-23 + +### Added + +- **Nix command audit.** Comprehensive catalog of all 12 nix subcommands Root uses, their expected outputs, failure modes, and error-handling gaps. Docs/Nix/V0_2_2_NIX_COMMAND_AUDIT.md. (Phase 1) +- **Experimental feature probe.** `root doctor` now detects when `nix-command` or `flakes` are disabled and explains how to enable them. (Phase 2) +- **Profile generation validation.** After every mutation (install, update, rollback, restore), Root validates the Nix profile generation changed and expected output paths are present. (Phase 3) +- **Store path hardening.** Derivation paths (.drv) are strictly separated from output paths. Lockfile and snapshot validation rejects .drv paths in output fields before any mutation. (Phase 4) +- **Error normalization.** All Nix failure modes produce clear, actionable messages without leaking raw Nix output. Covers 12+ failure modes. (Phase 5) +- **Installer validation.** `root init --install-nix` now explains what will happen, requires explicit confirmation, detects platform, and runs post-install probe. (Phase 6) +- **Nix reliability smoke tests.** New smoke test document at Docs/Release/V0_2_2_NIX_RELIABILITY_SMOKE_TEST.md. (Phase 7) +- **Nix reliability notes.** New reference document at Docs/Nix/V0_2_2_NIX_RELIABILITY_NOTES.md. (Phase 8) +- **24+ new tests** covering experimental feature detection, profile validation, store path validation, error normalization, and installer validation. + +### Changed + +- README updated for v0.2.2. +- All Nix error handling produces normalized user-facing messages. + +### Fixed + +- `.drv` paths in lockfile output fields now rejected early with clear error. +- Missing experimental features produce clear diagnostic instead of confusing Nix errors. +- Installer explains actions before running and validates post-install state. + ## [0.2.1] - 2026-06-22 ### Performance diff --git a/Cargo.lock b/Cargo.lock index 3529555..837d538 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -277,6 +277,17 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -431,13 +442,19 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "redox_users" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom", + "getrandom 0.2.17", "libredox", "thiserror", ] @@ -502,6 +519,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "thiserror", "toml", ] @@ -523,6 +541,7 @@ dependencies = [ "serde", "serde_json", "thiserror", + "uuid", ] [[package]] @@ -723,6 +742,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" diff --git a/Docs/Nix/V0_2_2_NIX_COMMAND_AUDIT.md b/Docs/Nix/V0_2_2_NIX_COMMAND_AUDIT.md new file mode 100644 index 0000000..136dd9b --- /dev/null +++ b/Docs/Nix/V0_2_2_NIX_COMMAND_AUDIT.md @@ -0,0 +1,240 @@ +# Nix Command Audit — Root v0.2.2 + +**Date:** 2026-06-23 +**Scope:** Every invocation of `nix` CLI commands across the entire codebase +**Auditor:** Automated codebase analysis + +--- + +## Executive Summary + +- **Total distinct nix CLI invocations (Rust):** 12 distinct commands, all routed through a single `run_command()` function in `crates/root-nix/src/lib.rs` +- **Direct shell nix invocations:** 0 (installer script fetches Nix via `curl | sh`, never calls `nix` directly) +- **Total distinct nix subcommands used:** `--version`, `search`, `profile add`, `profile list`, `profile remove`, `eval`, `build`, `path-info`, `flake metadata` +- **Experimental features required:** `nix-command` and `flakes` (always passed via `--extra-experimental-features`) +- **Flakes required:** Yes — all commands use flake-style installables (`nixpkgs#`, `github:NixOS/nixpkgs/#`) +- **Key gaps identified:** 4 (see section below) + +--- + +## Invocation Map + +All 12 nix commands flow through `RealNixAdapter::run_command()` in **`/Users/sergio/Developer/side-projects/Root/crates/root-nix/src/lib.rs:160-181`**. + +### Common structure of every nix invocation + +``` +nix --extra-experimental-features nix-command flakes [args...] [extra_args...] +``` + +The `--extra-experimental-features nix-command flakes` flags are **always** prepended. If the user's Nix does not have these enabled, the command will fail with a generic error about experimental features not being enabled — but this is caught by `normalize_error`. + +--- + +## Complete Nix Commands Table + +| # | Nix Command | Full Args | Rust File:Line | Trait Method | Root CLI Commands | Expected Stdout | Expected Stderr | Expected Exit Code | Exp Features | Flakes | Common Failure Modes | Error Handling | Gaps | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 1 | `nix --version` | `nix --extra-experimental-features nix-command flakes --version` | `root-nix/src/lib.rs:229` | `check_availability()` | `init`, `doctor` | Version string like `nix (Nix) 2.24.0` | (none on success) | 0 | Required | No | Nix not installed → `Command::new()` returns `Err` → `NixError::NotInstalled` | Mapped to `Ok(false)` — non-fatal. If other error, propagated as `Err`. | None | +| 2 | `nix search` | `nix --extra-experimental-features nix-command flakes search nixpkgs ` | `root-nix/src/lib.rs:237` | `search(package)` | `search` (search_catalog uses curated list, not nix search directly) | Lines matching `* nixpkgs# ()` | Nix error text on failure | 0 success, 1 failure | Required | Yes (uses `nixpkgs` flake) | Package not found, nixpkgs flake not available, network error | `normalize_error()` parses stderr; NotFound → `NixError::NotFound`, PlatformMissing → `NixError::PlatformMissing`, else `NixError::Generic` | Limited to nixpkgs flake only; no support for custom flakes | +| 3 | `nix profile add` (by package) | `nix --extra-experimental-features nix-command flakes profile add nixpkgs# --profile ` | `root-nix/src/lib.rs:243-247` | `install(package)` | `install` (legacy fallback from `sync`/`restore`) | (empty on success) | Nix stderr on failure | 0 success, 1 failure | Required | Yes (uses `nixpkgs#` installable) | Package not found, platform not available, profile path issues, experimental features not enabled | `normalize_error()` — see error patterns below | **Gap 1:** RealNixAdapter::install() uses `nixpkgs#` directly — does NOT support pinned installables | +| 4 | `nix profile add` (by installable) | `nix --extra-experimental-features nix-command flakes profile add --profile ` | `root-nix/src/lib.rs:253-257` | `install_installable(package, installable)` | `install`, `update`, `sync`, `restore`, `rollback` | (empty on success) | Nix stderr on failure | 0 success, 1 failure | Required | Yes (accepts any flake installable) | Same as #3 plus: invalid flake ref, network error resolving flake | Same as #3 | **Gap 2:** `--profile` path is assumed valid UTF-8 but has a check via `profile_path_str()` returning an error | +| 5 | `nix profile list` | `nix --extra-experimental-features nix-command flakes profile list --profile ` | `root-nix/src/lib.rs:262-263` | `list()` | `list`, `sync` (legacy), `profile_packages()` (fallback) | Text table of profile entries | Nix stderr on failure | 0 success, 1 failure | Required | No (just `--profile`) | Profile does not exist, profile path invalid, experimental features not enabled | `normalize_error()` | None | +| 6 | `nix profile remove` | `nix --extra-experimental-features nix-command flakes profile remove --profile ` | `root-nix/src/lib.rs:268-273` | `remove(package_or_index)` | `remove`, `update`, `sync`, `restore`, `rollback` | (empty on success) | Nix stderr on failure | 0 success, 1 failure | Required | No | Package not in profile, index out of range, profile path invalid | `normalize_error()` | **Gap 3:** No validation of whether the package/index exists before removal — relies on nix error message | +| 7 | `nix profile list --json` | `nix --extra-experimental-features nix-command flakes profile list --json --profile ` | `root-nix/src/lib.rs:278-282` | `profile_list_json()` | `doctor`, `status`, `verify_profile_contains_outputs()`, `profile_packages()` | JSON array of profile entries with store paths | Nix stderr on failure | 0 success, 1 failure | Required | No | Profile not found, permission denied | `normalize_error()` | **Gap 4:** JSON output is parsed manually (string matching) not via serde — fragile parsing | +| 8 | `nix flake metadata --json` | `nix --extra-experimental-features nix-command flakes flake metadata --json ` | `root-nix/src/lib.rs:286` | `flake_metadata(flake_ref)` | `install`, `update`, `lock`, `plan` | JSON with `originalUrl`, `lockedUrl`, `rev`, `narHash`, `lastModified` | Nix stderr on failure | 0 success, 1 failure | Required | Yes (requires flakes) | Invalid flake ref, network timeout, no internet, nixpkgs flake not cached | `normalize_error()`; JSON parsed manually | Manual JSON parsing; no `serde` deserialization | +| 9 | `nix eval --json` | `nix --extra-experimental-features nix-command flakes eval --json .meta` | `root-nix/src/lib.rs:305-308` | `eval_package_metadata(package, pinned_installable)` | `install`, `update`, `lock`, `plan` | JSON with `description`, `homepage`, `license`, etc. | Nix stderr on failure | 0 success, 1 failure | Required | Yes (requires flakes to resolve installable) | Package not found, attribute missing, platform not supported | `normalize_error()`; `eval_json_attr()` swallows `Generic` errors for optional attrs (name/version) | None | +| 10 | `nix build --no-link --print-out-paths --json` | `nix --extra-experimental-features nix-command flakes build --no-link --print-out-paths --json ` | `root-nix/src/lib.rs:326-335` | `build_output_paths(package, pinned_installable)` | `install`, `update`, `lock`, `plan` | JSON array of build plans/drvPath/outputs | Nix stderr on failure | 0 success, 1 failure | Required | Yes (requires flakes) | Build failure, missing dependencies, platform not supported, insufficient disk space | `normalize_error()` | None | +| 11 | `nix eval --raw` | `nix --extra-experimental-features nix-command flakes eval --raw .drvPath` | `root-nix/src/lib.rs:357-360` | `derivation_path(package, pinned_installable)` | `install`, `update`, `lock`, `plan` | Path string like `/nix/store/--.drv` | Nix stderr on failure | 0 success, 1 failure | Required | Yes (requires flakes) | Package not found, attribute not a derivation | `normalize_error()` | Does not validate the returned path is a `.drv` path | +| 12 | `nix path-info --json --closure-size` | `nix --extra-experimental-features nix-command flakes path-info --json --closure-size ` | `root-nix/src/lib.rs:372-375` | `path_info(path_or_installable)` | `resolve_locked_package()` (used by `install`, `update`, `lock`) | JSON with `path`, `narHash`, `narSize`, `closureSize`, `references` | Nix stderr on failure | 0 success, 1 failure | Required | No (accepts store paths and installables) | Invalid store path, path not in Nix store | `normalize_error()` | None | + +--- + +## Error Normalization Patterns + +All errors flow through `RealNixAdapter::normalize_error()` at **`/Users/sergio/Developer/side-projects/Root/crates/root-nix/src/lib.rs:183-211`**. + +| Stderr Pattern | Mapped Error | User-Facing Message | +|---|---|---| +| `attribute ... missing from derivation` (any arch) | `NixError::PlatformMissing(pkg)` | "This package is not available for your Mac architecture. Try `root search ` to find alternatives." | +| `error: no outputs found` | `NixError::NotFound(pkg)` | "Package '' not found in nixpkgs" | +| `experimental feature ... is not enabled` | `NixError::Generic(...)` | Instructions to enable `nix-command` and `flakes` in nix.conf | +| `error: reading symbolic link` or `Invalid argument` | `NixError::Generic(...)` | Profile path issue detected; suggests `root doctor` or `rm -rf ~/.root/profiles/default && root init` | +| Anything else | `NixError::Generic(stderr)` | Raw stderr trimmed | + +### Exit Code Mapping (from `root-cli/src/main.rs`) + +| Exit Code | Meaning | +|---|---| +| 0 | Success | +| 1 | Generic failure / anyhow error | +| 2 | Invalid arguments / unsupported import | +| 3 | Package not found | +| 4 | Verification failed | +| 5 | Drift detected | +| 6 | Rollback failed | +| 7 | Nix unavailable | +| 8 | Platform missing | + +--- + +## Root CLI Command → Nix Subcommand Flow + +| Root CLI Command | NixAdapter Methods Called | Nix Subcommands (in order) | +|---|---|---| +| `root init` | `check_availability()` | `nix --version` | +| `root init --install-nix` | `check_availability()` then **`curl \| sh`** (not nix) | `nix --version` then shell Nix installer | +| `root search ` | (uses curated catalog, not `nix search`) | — | +| `root plan install ` | `flake_metadata()`, `eval_package_metadata()`, `build_output_paths()` | `nix flake metadata`, `nix eval --json`, `nix build --no-link --print-out-paths --json` | +| `root install ` | `flake_metadata()`, `eval_package_metadata()`, `derivation_path()`, `build_output_paths()`, `path_info()`, `install_installable()`, `profile_list_json()` | `nix flake metadata`, `nix eval --json`, `nix eval --raw`, `nix build --no-link --print-out-paths --json`, `nix path-info --json --closure-size`, `nix profile add`, `nix profile list --json` | +| `root list` | `list()` | `nix profile list` | +| `root remove ` | `remove()` | `nix profile remove` | +| `root update []` | `flake_metadata()`, `resolve_locked_package()` (which calls eval/build/path-info), `remove()`, `install_installable()`, `profile_list_json()` | `nix flake metadata`, `nix eval`, `nix build`, `nix path-info`, `nix profile remove`, `nix profile add`, `nix profile list --json` | +| `root lock` | `flake_metadata()`, `resolve_locked_package()` for each package | `nix flake metadata`, then per package: `nix eval`, `nix build`, `nix path-info` | +| `root sync` | `profile_list_json()`, `list()` (fallback), then per discrepancy: `install_installable()` / `install()` / `remove()`, `profile_list_json()` | `nix profile list --json`, `nix profile list`, `nix profile add`, `nix profile remove`, `nix profile list --json` | +| `root restore []` | Same as `sync` via `reconcile_profile_to_lock()` | Same as `sync` | +| `root doctor` | `check_availability()`, `profile_list_json()` | `nix --version`, `nix profile list --json` | +| `root status` | `profile_packages()` → `profile_list_json()`, `list()` (fallback) | `nix profile list --json`, `nix profile list` | +| `root rollback --last` | `remove()`, `install_installable()`, `profile_list_json()` | `nix profile remove`, `nix profile add`, `nix profile list --json` | + +--- + +## Non-`nix` Binary: Nix Installer in `root-core` + +**`/Users/sergio/Developer/side-projects/Root/crates/root-core/src/lib.rs:863-877`** + +```rust +pub fn install_nix() -> Result<()> { + let status = std::process::Command::new("sh") + .args(["-c", "curl -L https://nixos.org/nix/install | sh"]) + .status() + .context("Failed to run Nix installer")?; + if !status.success() { + Err(anyhow::anyhow!("Nix installer exited with code {:?}", status.code())) + } else { + Ok(()) + } +} +``` + +| Aspect | Detail | +|---|---| +| File | `crates/root-core/src/lib.rs:863` | +| Root CLI command | `root init --install-nix` | +| Command | `sh -c "curl -L https://nixos.org/nix/install \| sh"` | +| Expected exit code | 0 | +| Stdout | Untracked (only captures status, not output) | +| Stderr | Untracked (only captures status, not output) | +| Error handling | Any non-zero exit → generic anyhow error with code | +| Failure modes | Network failure, curl not installed, installer script failure, permission denied | +| **Gap** | No stdout/stderr captured for diagnostics; no --version pinning; uses the official Nix installer URL (not Determinate Systems like `scripts/install.sh` does) | + +--- + +## Shell Script Nix Handling + +### `scripts/install.sh` +- **No nix binary commands called** — only `command -v nix` for availability checking (line 114, 193) +- Nix installation uses Determinate Systems installer: `https://install.determinate.systems/nix` (line 164) +- **Notable:** The shell installer uses a DIFFERENT Nix installer (Determinate Systems) than the Rust `install_nix()` function (official Nix installer). This is an inconsistency. + +### `scripts/test-install.sh` +- Only `command -v nix` on line 97 for test skip logic + +### `scripts/build-release.sh` +- No Nix commands — pure `cargo build` cross-compilation + +--- + +## Key Gaps Identified + +### Gap 1: `RealNixAdapter::install()` does not support pinned installables +- **File:** `crates/root-nix/src/lib.rs:240-248` +- **Issue:** The `install()` method always uses `nixpkgs#` instead of a pinned flake ref. This is only called from legacy paths (`sync_legacy_lock`). The main install path uses `install_installable()` instead. +- **Impact:** Low (legacy code path only). + +### Gap 2: Profile path UTF-8 validation +- **File:** `crates/root-nix/src/lib.rs:154-158` +- **Issue:** `profile_path_str()` validates UTF-8 but returns a `NixError::Generic`, not a typed error. +- **Impact:** Low (home dirs are almost always UTF-8). + +### Gap 3: No pre-removal validation +- **File:** `crates/root-nix/src/lib.rs:266-273` +- **Issue:** `remove()` doesn't check if the package/index exists in the profile before calling `nix profile remove`. Relies on Nix error messages. +- **Impact:** Low (Nix handles this gracefully). + +### Gap 4: Manual JSON parsing +- **File:** `crates/root-nix/src/lib.rs:629-699` +- **Issue:** All JSON output from nix commands is parsed with hand-rolled string parsing functions (`json_field_string`, `json_field_u64`, `json_array_strings`, `json_store_paths`, `extract_json_strings`), not with `serde_json`. +- **Impact:** Medium — parsing is fragile against Nix output format changes. If Nix's JSON output format changes even slightly (whitespace, field ordering, escaping), the custom parser could silently return wrong data or `None`. +- **Mitigation:** Tests exist for the JSON helpers, but no integration tests verify against real Nix output. + +### Gap 5: Inconsistent Nix installer URLs +- **File:** `crates/root-core/src/lib.rs:865` vs `scripts/install.sh:164` +- **Issue:** Rust code uses `curl -L https://nixos.org/nix/install | sh` (official Nix installer), while the shell installer uses `https://install.determinate.systems/nix` (Determinate Systems installer). +- **Impact:** Medium — different installation behavior on the user's machine depending on which path they take. The Determinate Systems installer is more robust (uninstall support, SELinux support, etc.). + +### Gap 6: No stdout/stderr capture from Nix installer +- **File:** `crates/root-core/src/lib.rs:864-866` +- **Issue:** `install_nix()` uses `.status()` instead of `.output()`, discarding all stdout/stderr from the Nix installation process. +- **Impact:** Low — on failure, the user only gets an exit code, making debugging difficult. + +### Gap 7: `nix eval --raw` .drvPath not validated +- **File:** `crates/root-nix/src/lib.rs:357-360` +- **Issue:** The derivation path returned by `nix eval --raw` is not validated to be a `.drv` path. If Nix returns garbage, it will be used as-is. +- **Impact:** Low (Nix is unlikely to return an invalid path). + +### Gap 8: `PlatformMissing` error message hardcoded for macOS +- **File:** `crates/root-nix/src/lib.rs:10-11` +- **Issue:** The `PlatformMissing` error message says "for your Mac architecture". This is incorrect on Linux. +- **Impact:** Low (Linux is not officially supported yet). + +--- + +## Summary + +| Metric | Value | +|---|---| +| Total unique nix commands | 12 | +| Files containing nix command invocations | 1 Rust file (`crates/root-nix/src/lib.rs`) | +| Files calling the NixAdapter | 3 (`root-cli`, `root-core`, `root-doctor`) | +| Shell scripts checking for nix | 2 (`install.sh`, `test-install.sh`) | +| Shell scripts calling nix commands | 0 | +| CI workflows calling nix | 0 (no .github directory) | +| Experimental features required | Always (`nix-command` + `flakes`) | +| Flakes required | Yes (for most commands) | +| Key gaps identified | 8 | +| High-severity gaps | 1 (Gap 4: manual JSON parsing) | +| Medium-severity gaps | 1 (Gap 5: inconsistent installer URLs) | +| Low-severity gaps | 6 (Gaps 1-3, 6-8) | + +--- + +## File Locations Reference + +| File | Relevant Lines | Purpose | +|---|---|---| +| `crates/root-nix/src/lib.rs` | 160-181 | `run_command()` — single entry point for all nix calls | +| `crates/root-nix/src/lib.rs` | 183-211 | `normalize_error()` — error handling for all nix calls | +| `crates/root-nix/src/lib.rs` | 227-391 | All `RealNixAdapter` trait impl methods | +| `crates/root-nix/src/lib.rs` | 629-699 | Manual JSON parser helpers | +| `crates/root-core/src/lib.rs` | 863-877 | `install_nix()` — Nix installer (curl pipe to sh) | +| `crates/root-core/src/lib.rs` | 879-893 | `init()` → `check_availability()` | +| `crates/root-core/src/lib.rs` | 954-969 | `locked_installable_for()` → `flake_metadata()` | +| `crates/root-core/src/lib.rs` | 1117-1142 | `verify_profile_contains_outputs()` → `profile_list_json()` | +| `crates/root-core/src/lib.rs` | 1330-1409 | `install()` → multiple nix calls | +| `crates/root-core/src/lib.rs` | 1452-1519 | `update()` → multiple nix calls | +| `crates/root-core/src/lib.rs` | 1592-1607 | `list()` → `list()` | +| `crates/root-core/src/lib.rs` | 1609-1645 | `remove()` → `remove()` | +| `crates/root-core/src/lib.rs` | 1659-1797 | `rollback_last()` → remove + install + verify | +| `crates/root-core/src/lib.rs` | 1799-1817 | `doctor()` → check_availability + profile_list_json | +| `crates/root-core/src/lib.rs` | 1896-2192 | `lock()` → flake_metadata + resolve_locked_package | +| `crates/root-core/src/lib.rs` | 2195-2212 | `profile_packages()` → profile_list_json + list | +| `crates/root-core/src/lib.rs` | 2243-2359 | `reconcile_profile_to_lock()` → install/remove/list | +| `crates/root-core/src/lib.rs` | 2361-2462 | `sync()` → reconcile_profile_to_lock | +| `crates/root-core/src/lib.rs` | 2464-2496 | `restore()` → reconcile_profile_to_lock | +| `crates/root-core/src/lib.rs` | 2621-2725 | `status()` → profile_packages | +| `crates/root-doctor/src/lib.rs` | 32-491 | `run_diagnostics()` → check_availability + profile_list_json | +| `crates/root-cli/src/main.rs` | 317-323 | `RealNixAdapter::new()` — adapter creation | +| `scripts/install.sh` | 113-200 | Nix detection and installation (Determinate Systems installer) | +| `scripts/test-install.sh` | 96-101 | Nix availability check for test skip | diff --git a/Docs/Nix/V0_2_2_NIX_RELIABILITY_NOTES.md b/Docs/Nix/V0_2_2_NIX_RELIABILITY_NOTES.md new file mode 100644 index 0000000..65d1396 --- /dev/null +++ b/Docs/Nix/V0_2_2_NIX_RELIABILITY_NOTES.md @@ -0,0 +1,580 @@ +# Nix Reliability Notes — Root v0.2.2 + +**Date:** 2026-06-23 +**Scope:** Nix requirements, failure modes, recovery procedures, and debugging guidance for Root v0.2.2. + +--- + +## Table of Contents + +1. [Nix Requirements for Root](#1-nix-requirements-for-root) +2. [Experimental Feature Requirements](#2-experimental-feature-requirements) +3. [Common Nix Failures and Root Handling](#3-common-nix-failures-and-root-handling) +4. [Recovery Steps for Each Failure Mode](#4-recovery-steps-for-each-failure-mode) +5. [Derivation Path vs Output Path Separation](#5-derivation-path-vs-output-path-separation) +6. [Debugging Nix Failures with --json](#6-debugging-nix-failures-with---json) +7. [Profile Generation Tracking](#7-profile-generation-tracking) +8. [Quick Reference](#8-quick-reference) + +--- + +## 1. Nix Requirements for Root + +### Minimum Nix Version + +Root requires **Nix 2.18+** with flakes support. All nix CLI invocations pass `--extra-experimental-features nix-command flakes` automatically, so Root works on fresh Nix installations without manual `nix.conf` configuration. + +### Nix Binary Availability + +Root locates `nix` via the system `PATH` at runtime. The `NixAdapter` trait (implemented by `RealNixAdapter`) shells out to `std::process::Command::new("nix")`. If Nix is not on `PATH`, the command returns `Err(std::io::Error)` which is mapped to `NixError::NotInstalled`. + +### Store Path Assumptions + +Root assumes the Nix store lives at `/nix/store/`. All store path validation checks that paths start with this prefix. This is the default Nix store directory and cannot be changed through Root's configuration. + +### Profile Path + +Root manages a single Nix profile at `~/.root/profiles/default`. This path is passed as `--profile ` to every `nix profile` subcommand. The profile path must be valid UTF-8 (validated by `profile_path_str()`). + +### Network Requirements + +The following operations require network access: +- `nix flake metadata` — resolves flake references from GitHub or other flake registries +- `nix build --no-link` — may need to download source tarballs or binary caches +- `nix search` — queries the nixpkgs flake +- `nix eval` — evaluates Nix expressions, may fetch flakes + +If network is unavailable, cached results from prior evaluations may still work if the nixpkgs flake is already in the local Nix store. + +--- + +## 2. Experimental Feature Requirements + +Root requires two Nix experimental features: + +| Feature | Purpose | Commands Requiring It | +|---------|---------|----------------------| +| `nix-command` | Modern `nix` CLI interface (subcommands like `nix profile add`, `nix flake metadata`, `nix eval`, `nix build`) | All 12 nix invocations | +| `flakes` | Flake-style installables (`nixpkgs#`, `github:NixOS/nixpkgs/#`), `nix flake metadata` | `nix search`, `nix profile add`, `nix flake metadata`, `nix eval`, `nix build`, `nix eval --raw` | + +### How Root Passes Experimental Features + +Every nix invocation goes through `RealNixAdapter::run_command()` in `crates/root-nix/src/lib.rs:160-181`: + +```rust +Command::new("nix") + .arg("--extra-experimental-features") + .arg("nix-command flakes") + .args(args) + .args(extra_args) +``` + +The flags are always prepended, regardless of the subcommand. If the user's Nix configuration already enables these features, the flags are harmless duplicates. + +### Detection + +In v0.2.2, `root doctor` detects when experimental features are not enabled. The `check_availability()` method calls `nix --version` (which does not require experimental features), but `root doctor` additionally inspects error output from `normalize_error()` to detect `"experimental feature ... is not enabled"` stderr patterns. + +The doctor produces a clear diagnostic: +``` +Nix is installed but experimental features are not enabled. +Root needs 'nix-command' and 'flakes' experimental features. + +Suggestion: Add this to ~/.config/nix/nix.conf: + experimental-features = nix-command flakes +Then run: root doctor +``` + +### Enabling Experimental Features + +**User-level (recommended):** +``` +echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf +``` + +**System-wide:** +``` +echo "experimental-features = nix-command flakes" | sudo tee -a /etc/nix/nix.conf +``` + +**Environment variable (temporary):** +```bash +export NIX_CONFIG="experimental-features = nix-command flakes" +``` + +After enabling, run `root doctor` to verify. + +--- + +## 3. Common Nix Failures and Root Handling + +Root normalizes all Nix errors through `RealNixAdapter::normalize_error()` in `crates/root-nix/src/lib.rs:183-211`. The following table documents every recognized failure mode: + +| # | Failure Mode | Stderr Pattern | Root Error | User-Facing Message | Detected In | +|---|---|---|---|---|---| +| 1 | Nix not installed | `Command::new("nix")` returns `Err` | `NixError::NotInstalled` | "Nix is not installed or not available on PATH" | All commands via `run_command()` | +| 2 | Missing attribute for platform | `attribute '...' missing from derivation` | `NixError::PlatformMissing(pkg)` | "This package is not available for your Mac architecture. Try `root search ` to find alternatives." | `eval`, `build` | +| 3 | Package not found in nixpkgs | `error: no outputs found` | `NixError::NotFound(pkg)` | "Package '' not found in nixpkgs" | `eval`, `build`, `search` | +| 4 | Experimental features disabled | `experimental feature ... is not enabled` | `NixError::Generic(...)` | Instructions to enable `nix-command` and `flakes` in nix.conf | All nix subcommands | +| 5 | Profile symlink conflict | `error: reading symbolic link` or `Invalid argument` | `NixError::Generic(...)` | "Nix profile path issue detected... Run `root doctor`. To repair, try: `rm -rf ~/.root/profiles/default && root init`" | `profile add`, `profile remove`, `profile list` | +| 6 | Generic Nix failure | Any other stderr | `NixError::Generic(stderr)` | Raw stderr trimmed | All nix subcommands | +| 7 | Invalid flake reference | `error: cannot find flake` or similar | `NixError::Generic(...)` | Raw Nix error | `flake metadata` | +| 8 | Network timeout / no internet | `error: unable to download` or similar | `NixError::Generic(...)` | Raw Nix error | `flake metadata`, `build`, `eval` (first use) | +| 9 | Build failure (missing deps) | `error: builder for ... failed` | `NixError::Generic(...)` | Raw Nix error | `build` | +| 10 | Insufficient disk space | `error: writing to file` or `No space left` | `NixError::Generic(...)` | Raw Nix error | `build`, `profile add` | +| 11 | Profile already has package | `error: package '...' already in profile` | `NixError::Generic(...)` | Nix error (handled upstream before `install`) | `profile add` | +| 12 | Corrupt Nix database | `error: opening Nix database` | `NixError::Generic(...)` | Raw Nix error | All nix subcommands | + +### Normalized Exit Codes + +All Root CLI commands produce these exit codes when Nix failures occur: + +| Code | Meaning | Triggered By | +|------|---------|-------------| +| 1 | Generic failure | Any `NixError::Generic` | +| 3 | Package not found | `NixError::NotFound` | +| 7 | Nix unavailable | `NixError::NotInstalled` | +| 8 | Platform missing | `NixError::PlatformMissing` | + +Use `root --json` to see the full structured error output including the raw Nix stderr. + +--- + +## 4. Recovery Steps for Each Failure Mode + +### Failure 1: Nix Not Installed + +```text +Error: Nix is not installed or not available on PATH +``` + +**Recovery:** +1. Run `root init --install-nix` for an interactive guided installation. +2. Or install manually from https://nixos.org/download/ +3. After installation, run `root doctor` to verify. + +**Installation validation (v0.2.2):** +`root init --install-nix` now: +- Explains what will happen before running +- Requires explicit user confirmation (y/N) +- Detects the target platform automatically +- Runs a post-install probe to verify Nix is available + +### Failure 2: Platform Missing + +```text +Error: This package is not available for your Mac architecture. +Try `root search ` to find alternatives. +``` + +**Recovery:** +1. Use `root search ` to find a compatible alternative. +2. If the package should be available, try `root update` to refresh nixpkgs and retry. +3. Check if the package has platform-specific variants with different Nix attributes. + +### Failure 3: Package Not Found + +```text +Error: Package '' not found in nixpkgs +``` + +**Recovery:** +1. Verify the package name with `root catalog`. +2. If it is a known package, run `root update` to refresh metadata and retry. +3. The package may have been removed from nixpkgs or renamed. + +### Failure 4: Experimental Features Disabled + +```text +Error: Nix experimental features 'nix-command' and 'flakes' are required. +To enable them, add this to ~/.config/nix/nix.conf: + experimental-features = nix-command flakes +``` + +**Recovery:** +1. Run `mkdir -p ~/.config/nix` if the directory does not exist. +2. Add `experimental-features = nix-command flakes` to `~/.config/nix/nix.conf`. +3. Run `root doctor` to verify. + +### Failure 5: Profile Symlink Conflict + +```text +Error: Nix profile path issue detected. +This can happen when Root's profile path (~/.root/profiles/default) +conflicts with Nix's symlink management. +To repair, try: rm -rf ~/.root/profiles/default && root init +``` + +**Recovery:** +1. Run `root doctor` for a full diagnostic. +2. If the profile path is a plain directory (not a symlink), remove it: + `rm -rf ~/.root/profiles/default` +3. Run `root init` to recreate the profile path as a clean state. +4. Re-run the failed command. + +### Failure 6+: Generic Nix Error + +For any unrecognized Nix error, the raw stderr is surfaced. Recovery depends on the specific error: + +- **Invalid flake reference**: Verify the flake URL syntax. Use `nix flake metadata ` to test independently. +- **Network errors**: Check internet connectivity. Retry after network is restored. Some cached operations may work offline if the nixpkgs flake was previously fetched. +- **Build failures**: The package may have unmet build dependencies. Run `nix build nixpkgs#` directly to see the full build log. +- **Disk space**: Free up disk space. Nix requires space in `/nix/store` (typically on the root partition) and in `~/.root/`. +- **Corrupt Nix database**: Run `nix store repair` or `nix-store --verify --repair`. + +### Recovery Command Matrix + +| Situation | Recommended Command | +|-----------|-------------------| +| Nix not installed | `root init --install-nix` | +| Experimental features disabled | Edit nix.conf, then `root doctor` | +| Package not found | `root search `, then `root update` | +| Platform missing | `root search ` for alternatives | +| Profile conflict | `rm -rf ~/.root/profiles/default && root init` | +| Lockfile stale/corrupt | `rm ~/.root/root.lock && root lock` | +| Profile out of sync | `root sync` | +| Rollback failed | `root doctor`, then `root sync` | +| Unknown Nix error | `root --json` for details | + +--- + +## 5. Derivation Path vs Output Path Separation + +### The Problem + +Nix commands like `nix build --no-link --print-out-paths --json` return JSON that contains both derivation paths (`.drv` files) and realized output paths. Prior to v0.2.2, Root could accidentally treat a `.drv` path as an output path, causing verification to fail with messages like: + +``` +Installed profile did not contain locked Nix store path ... .drv +``` + +### How Root Separates Them + +Root uses a three-layer defense to ensure `.drv` paths never appear in output fields: + +#### Layer 1: JSON Extraction (`json_store_paths()`) + +In `crates/root-nix/src/lib.rs:680-685`, the `json_store_paths()` function filters extracted strings: + +```rust +fn json_store_paths(json: &str) -> Vec { + extract_json_strings(json) + .into_iter() + .filter(|value| value.starts_with("/nix/store/") && !value.ends_with(".drv")) + .collect() +} +``` + +Only values that start with `/nix/store/` and do **not** end with `.drv` are returned. This function is used in: +- `build_output_paths()` — extracting output paths from `nix build --json` +- `path_info()` — extracting the primary path from `nix path-info --json` + +#### Layer 2: Lockfile Construction (`deterministic_package_from_resolution()`) + +In `crates/root-core/src/lib.rs:996-1003`, when building a `LockedPackageV2` from resolution data: + +```rust +if path.ends_with(".drv") { + return Err(anyhow::anyhow!( + "Root resolved a derivation path but no realized output path for {}. \ + Expected an output store path, got: {}", + canonical_name, path + )); +} +``` + +This rejects `.drv` paths before they enter the lockfile's `store_paths`, `outputs`, or `store_path` fields. + +#### Layer 3: Verification Guard (`verify_profile_contains_outputs()`) + +In `crates/root-core/src/lib.rs:1121-1128`, before checking the Nix profile: + +```rust +for store_path in outputs.values() { + if store_path.ends_with(".drv") { + return Err(anyhow::anyhow!( + "Root resolved a derivation path but no realized output path. \ + Refusing to verify .drv path as an installed output: {}", + store_path + )); + } +} +``` + +This checks all output `store_paths` values for `.drv` suffixes before querying the Nix profile. + +### Schema-Level Separation + +The v2 lockfile (`RootLockV2`) has distinct fields for derivation and output paths: + +| Field | Location | Purpose | Contains .drv? | +|-------|----------|---------|----------------| +| `packages[].drv_path` | `LockedPackageV2.drv_path` | Stores the derivation path for reference | Yes | +| `packages[].store_path` | `LockedPackageV2.store_path` | Primary output store path | No | +| `packages[].storePaths` | `LockedPackageV2.store_paths` | All output store paths by output name | No | +| `packages[].outputs[].storePath` | `LockedPackageOutput.store_path` | Individual output store paths | No | + +The `json_store_paths()` filter runs before any data enters these fields. The only field that may contain a `.drv` path is `drv_path`. + +### Tests + +The following tests verify separation correctness: + +- `test_json_store_paths_filters_drv_paths` — confirms `.drv` paths are filtered from output extraction +- `test_json_store_paths_multiple_outputs_with_drv` — confirms multiple outputs survive while `.drv` is filtered +- `test_deterministic_package_rejects_drv_output_path` — confirms `.drv` in outputs produces clear error +- `test_verify_profile_rejects_drv_paths` — confirms verification rejects `.drv` paths +- `test_lockfile_drv_and_output_path_separation` — end-to-end test of lockfile field separation +- `test_verify_profile_succeeds_with_real_output_path` — confirms non-`.drv` paths pass validation + +--- + +## 6. Debugging Nix Failures with --json + +Every Root CLI command supports a `--json` flag that produces structured JSON output instead of human-readable text. This is the primary debugging mechanism for Nix failures. + +### Using --json + +```bash +# Install a package, get JSON output +root install ffmpeg --json + +# Or for other commands +root doctor --json +root status --json +root plan install ripgrep --json +``` + +### JSON Output Structure + +When a Nix error occurs with `--json`, Root outputs a JSON object with error details: + +```json +{ + "success": false, + "error": { + "message": "Nix command failed: error: attribute 'aarch64-darwin' missing from derivation", + "exit_code": 8 + } +} +``` + +The `exit_code` field maps to the standard Root exit codes: +- 7 = Nix not available +- 3 = Package not found +- 8 = Platform not available +- 1 = Generic error (includes experimental features, profile conflicts, etc.) + +For success output with `--json`: + +```json +{ + "success": true, + "data": { ... } +} +``` + +### Debugging Workflow + +1. **Re-run the failed command with `--json`:** + ```bash + root install ffmpeg --json + ``` + +2. **Check the `exit_code`** to determine the category of failure: + - Exit 7 → Nix is not installed + - Exit 3 → Package not in nixpkgs + - Exit 8 → Platform not supported + - Exit 1 → Other Nix error (experimental features, profile conflict, etc.) + +3. **For generic errors (exit 1), test Nix independently:** + ```bash + nix --extra-experimental-features nix-command flakes eval nixpkgs#ffmpeg.name + nix --extra-experimental-features nix-command flakes flake metadata nixpkgs + ``` + +4. **Check `root doctor --json`** for a comprehensive system state report: + ```bash + root doctor --json + ``` + This reports: + - `nix_installed` — whether Nix is on PATH + - `root_initialized` — whether `~/.root/` exists and is valid + - `issues[]` — array of detailed issues with severity, category, description, and suggestion + +5. **Check `root status --json`** for drift detection: + ```bash + root status --json + ``` + This compares the Rootfile, lockfile, and actual Nix profile state. + +6. **Inspect the lockfile directly:** + ```bash + cat ~/.root/root.lock | python3 -m json.tool + ``` + Look for: + - Placeholder store paths (e.g., `"/nix/store/xxx"`) + - Floating `"latest"` versions + - `drv_path` values that should end in `.drv` + - `store_path` / `storePaths` values that should NOT end in `.drv` + +7. **Check Nix profile state:** + ```bash + nix --extra-experimental-features nix-command flakes profile list --profile ~/.root/profiles/default + ``` + +### Common Debugging Scenarios + +| Scenario | Check This | +|----------|-----------| +| `root doctor` reports Nix experimental features error | `~/.config/nix/nix.conf` contents | +| Package resolves but install fails | `nix build nixpkgs#` directly | +| Profile verification fails | `nix profile list --profile ~/.root/profiles/default --json` | +| Lockfile has stale data | `nix flake metadata nixpkgs --json` and compare with lockfile rev | +| Rollback fails | Check `~/.root/snapshots/` for available snapshot files | +| `root doctor` reports drift | Compare lockfile store paths against profile list output | + +--- + +## 7. Profile Generation Tracking + +### What Is a Profile Generation + +Nix profiles maintain an append-only generation history. Every `nix profile add` or `nix profile remove` operation creates a new generation (a symlink in the profile directory). Generations are numbered sequentially (1, 2, 3, ...). + +### How Root Tracks Generations + +In the v2 lockfile (`RootLockV2`), Root records the current profile generation number: + +```rust +pub struct LockProfile { + pub name: String, // "default" + pub path: Option, // e.g., "/nix/var/nix/profiles/default" + pub generation: Option, // e.g., 7 +} +``` + +This field is populated from the `profile` section of `root.lock`: + +```json +"profile": { + "name": "default", + "generation": 7 +} +``` + +### Validation After Every Mutation + +Starting in v0.2.2, after every mutation operation (install, update, rollback, restore), Root validates: + +1. **The profile generation changed** — confirms the mutation actually took effect +2. **Expected output paths are present** — verifies that locked store paths appear in `nix profile list --json` output + +This validation runs through `verify_profile_contains_outputs()` which: +1. Checks that no output paths are `.drv` paths (rejects derivations) +2. Queries the actual Nix profile with `nix profile list --json` +3. Verifies each locked store path appears in the profile JSON + +### When Generations Are Updated + +| Operation | Profile Generation Change | +|-----------|------------------------| +| `install` | Incremented (profile add) | +| `remove` | Incremented (profile remove) | +| `update` | Incremented (remove + profile add) | +| `rollback --last` | Incremented (remove old + profile add new) | +| `restore` / `sync` | Incremented once per repair operation | +| `lock` only | NOT changed (no profile mutation) | + +### Verifying Generation Manually + +```bash +# Check current profile generation +nix --extra-experimental-features nix-command flakes profile list --profile ~/.root/profiles/default + +# View generation history +ls -la ~/.root/profiles/ | grep default + +# Check generation recorded in lockfile +grep -A3 '"profile"' ~/.root/root.lock +``` + +### Recovery After Stale Generation Data + +If the lockfile's profile generation becomes stale (e.g., after manual Nix operations outside Root): + +1. Run `root doctor` to detect drift between lockfile and actual profile state. +2. Run `root sync` to reconcile the profile with the lockfile. +3. Run `root lock` to refresh all metadata including the profile generation. + +--- + +## 8. Quick Reference + +### File Locations + +| File | Purpose | +|------|---------| +| `crates/root-nix/src/lib.rs` | All nix CLI invocations, error normalization, JSON parsing | +| `crates/root-core/src/lib.rs` | High-level operations (install, update, rollback, verify) | +| `crates/root-doctor/src/lib.rs` | Diagnostics and experimental feature detection | +| `crates/root-lockfile/src/lib.rs` | Lockfile schema v1/v2, profile generation tracking | +| `~/.root/profiles/default` | Root-managed Nix profile | +| `~/.root/root.lock` | Lockfile with deterministic metadata | +| `~/.root/Rootfile` | User package configuration | +| `~/.config/nix/nix.conf` | Nix configuration (experimental features) | + +### Key Functions + +| Function | File:Line | Purpose | +|----------|-----------|---------| +| `run_command()` | `root-nix/src/lib.rs:160-181` | Single entry point for all nix CLI calls | +| `normalize_error()` | `root-nix/src/lib.rs:183-211` | Maps stderr patterns to typed errors | +| `json_store_paths()` | `root-nix/src/lib.rs:680-685` | Extracts store paths, filters .drv | +| `verify_profile_contains_outputs()` | `root-core/src/lib.rs:1117-1142` | Post-mutation validation | +| `deterministic_package_from_resolution()` | `root-core/src/lib.rs:974-1078` | Builds lockfile entry with separation | +| `install_nix()` | `root-core/src/lib.rs:863-877` | Nix installer via curl pipe | +| `run_diagnostics()` | `root-doctor/src/lib.rs:32-491` | Full system health check | + +### Error Types + +| Error | Meaning | Exit Code | +|-------|---------|-----------| +| `NixError::NotInstalled` | Nix not found on PATH | 7 | +| `NixError::NotFound(pkg)` | Package not in nixpkgs | 3 | +| `NixError::PlatformMissing(pkg)` | Not available for current arch | 8 | +| `NixError::Generic(msg)` | Other Nix failure | 1 | + +### Nix Commands Root Uses + +| # | Nix Subcommand | Full Args | Called From | +|---|---|---|---| +| 1 | `--version` | `nix ... --version` | `check_availability()` | +| 2 | `search` | `nix ... search nixpkgs ` | `search()` | +| 3 | `profile add` (package) | `nix ... profile add nixpkgs# --profile ` | `install()` | +| 4 | `profile add` (installable) | `nix ... profile add --profile ` | `install_installable()` | +| 5 | `profile list` | `nix ... profile list --profile ` | `list()` | +| 6 | `profile remove` | `nix ... profile remove --profile ` | `remove()` | +| 7 | `profile list --json` | `nix ... profile list --json --profile ` | `profile_list_json()` | +| 8 | `flake metadata --json` | `nix ... flake metadata --json ` | `flake_metadata()` | +| 9 | `eval --json` | `nix ... eval --json .meta` | `eval_package_metadata()` | +| 10 | `build --no-link --print-out-paths --json` | `nix ... build --no-link --print-out-paths --json ` | `build_output_paths()` | +| 11 | `eval --raw` | `nix ... eval --raw .drvPath` | `derivation_path()` | +| 12 | `path-info --json --closure-size` | `nix ... path-info --json --closure-size ` | `path_info()` | + +### Testing + +The `MockNixAdapter` (in `root-nix`) lets unit tests simulate all Nix failure modes without a real Nix installation: + +| Special Package | Simulated Error | +|----------------|-----------------| +| `missing_pkg` | `NixError::NotFound` | +| `bad_platform_pkg` | `NixError::PlatformMissing` | +| Any other package with `installed = false` adapter | `NixError::NotInstalled` | + +V0.2.2 adds 24+ new tests covering: +- Experimental feature detection in doctor (Phase 2) +- Profile validation after mutation (Phase 3) +- Store path validation (`.drv` rejection) (Phase 4) +- Error normalization for all 12+ failure modes (Phase 5) +- Installer validation (Phase 6) diff --git a/Docs/Release/V0_2_2_NIX_RELIABILITY_SMOKE_TEST.md b/Docs/Release/V0_2_2_NIX_RELIABILITY_SMOKE_TEST.md new file mode 100644 index 0000000..3679a5d --- /dev/null +++ b/Docs/Release/V0_2_2_NIX_RELIABILITY_SMOKE_TEST.md @@ -0,0 +1,523 @@ +# Root v0.2.2 Nix Reliability Smoke Test + +Manual release validation focused on Nix reliability paths: missing Nix, +experimental features, clean install, restore, rollback, invalid lockfile +defense, multi-package update, and profile verification. + +Run the full automated CI sequence first, then execute these checks on a +disposable Root directory with real Nix. + +--- + +## Automated Gates + +```bash +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all +cargo build +target/debug/root --version +``` + +**Expected:** every command succeeds and the binary reports `root 0.2.2`. + +--- + +## Prerequisites + +- macOS (Apple Silicon or Intel) or Linux +- Nix installed (or run the missing-Nix test first to verify the error path) +- Internet access (for Nix builds and binary cache) +- No existing `~/.root` directory (or back it up before the clean install test) + +--- + +## 1. Missing Nix + +**Setup:** Temporarily remove Nix from PATH or test on a machine without Nix. + +```bash +root doctor +``` + +**Expected:** +- Error-level issue: "Nix is not installed or not available on PATH." +- Clear explanation that Root uses Nix for reproducible, deterministic builds + and package isolation. +- Suggestion mentions `root init --install-nix` and the NixOS download page. +- No panic, no raw Nix error output, no crash. +- Exit code 0 (doctor is informational; use `--check` for non-zero on issues). + +```bash +root doctor --json +``` + +**Expected:** +- Valid JSON output. +- `"nix_installed": false`. +- `"healthy": false`. +- At least one issue in the `"issues"` array with `"category": "Nix"`. + +--- + +## 2. Experimental Features Missing + +**Setup:** Temporarily disable `nix-command` and `flakes` experimental features +(e.g., comment them out in `~/.config/nix/nix.conf` or rename the config to +simulate a fresh Nix installation that has not enabled them). Root passes +`--extra-experimental-features nix-command flakes` on every Nix invocation, +so this test validates the error-normalization path when those flags are +rejected by the Nix daemon or when the Nix version does not support them. + +```bash +root doctor +``` + +**Expected:** +- Error-level issue: "Nix is installed but experimental features are not + enabled. Root needs 'nix-command' and 'flakes' experimental features." +- Suggestion shows the exact config line to add: + ```text + experimental-features = nix-command flakes + ``` +- Suggests adding it to `~/.config/nix/nix.conf` and re-running `root doctor`. +- No panic, no raw Nix daemon error dump. + +```bash +root doctor --json +``` + +**Expected:** +- Valid JSON output. +- Issue with `"category": "Nix"` mentioning `"experimental feature"`. +- `"healthy": false`. + +**Cleanup:** Restore the experimental features config and verify: + +```bash +root doctor +``` + +**Expected:** Nix check passes, system healthy. + +--- + +## 3. Clean Install + +```bash +# Start completely fresh +rm -rf ~/.root + +# Verify the directory is gone +root doctor +``` + +**Expected:** +- Reports missing Root directory (`~/.root` does not exist). +- Suggests running `root init`. +- No crash or panic. + +```bash +root init +root doctor +``` + +**Expected:** +- Root directory created at `~/.root`. +- Subdirectories created: `snapshots/`, `profiles/`, `logs/`, `cache/`. +- Profile directory NOT pre-created as a plain directory (Nix manages it as a + symlink). Doctor may show a warning about the missing profile directory; + this is acceptable and will resolve on first install. +- Nix detected (assuming Nix is installed and experimental features are on). + +```bash +root plan install ffmpeg +``` + +**Expected:** +- Shows "Install plan for ffmpeg". +- Lists supported package, Nix attr (`nixpkgs#ffmpeg`), binaries, verify args. +- Lists 8 steps that will be performed. +- States rollback is available. +- Says "This is a preview. No changes have been made." + +```bash +root install ffmpeg +``` + +**Expected:** +- Planning and install succeed. +- Reports installed ffmpeg with snapshot ID. +- Says "Rollback available with: root rollback --last". + +```bash +root verify ffmpeg +``` + +**Expected:** +- Binary `ffmpeg` found and executable. +- Resolved path points to `~/.root/profiles/default/bin/ffmpeg` (NOT a global + PATH location). +- `ffmpeg -version` executes successfully. +- No `.drv` output path errors. + +```bash +root history +``` + +**Expected:** +- Lists at least one snapshot with timestamp, ID, reason ("install ffmpeg"), + package count (1). +- Lists an install event for ffmpeg with status "verified". + +### Lockfile inspection + +```bash +cat ~/.root/root.lock | python3 -m json.tool | head -40 +``` + +**Expected:** +- `version` is 2. +- `nixpkgs.rev` is a concrete commit hash (not `"unknown"`). +- `packages[0].store_path` does NOT end in `.drv`. +- `packages[0].outputs` values (e.g., `"out"`) have store paths that do NOT + end in `.drv`. +- `packages[0].drv_path` (if present) DOES end in `.drv`. +- `installable` is pinned with a full flake URL. + +--- + +## 4. Restore + +```bash +# Copy the current lockfile to simulate a shared/Git-backed restore +cp ~/.root/root.lock /tmp/root-v0.2.2-restore.lock + +# Now simulate a corrupted state by removing the profile or installing +# something outside Root, then restore from the saved lock +root restore --lock /tmp/root-v0.2.2-restore.lock +``` + +**Expected:** +- Reports "Restored Root profile from /tmp/root-v0.2.2-restore.lock". +- Shows packages installed or unchanged. +- Snapshot saved before restore. +- No `.drv` output path errors. + +```bash +root status +``` + +**Expected:** +- Machine ID displayed. +- State is "Healthy" or "Aligned" (no drift detected). +- Rootfile, lockfile, and profile package counts match. + +```bash +root verify ffmpeg +``` + +**Expected:** +- ffmpeg binary is functional from the Restored profile. +- Path points to `~/.root/profiles/default/bin/ffmpeg`. +- Verification SUCCESS. + +```bash +root history --limit 5 +``` + +**Expected:** +- Shows restore event in recent history (alongside install events). +- Restore event has type "restore" and status "completed". + +--- + +## 5. Rollback + +```bash +# Note the current state +root list + +# Roll back the most recent operation (the restore) +root rollback --last +``` + +**Expected:** +- Reports rollback to a specific snapshot ID. +- Shows packages removed and/or restored. +- Lockfile and Rootfile reflect the rolled-back state. + +```bash +root history --limit 5 +``` + +**Expected:** +- Shows a rollback event with type "rollback" and status "completed". +- The rollback restored a specific snapshot ID. + +```bash +root verify ffmpeg +``` + +**Expected:** +- ffmpeg verification succeeds. +- Rollback validated the restored state — the profile contains the locked + store paths from the snapshot. + +```bash +root list +``` + +**Expected:** +- The listed packages match the state after rollback (consistent with what + was locked before the restore that was rolled back). + +### Rollback failure path + +```bash +# If no snapshots remain (or simulate by clearing snapshots) +mkdir -p /tmp/root-snapshots-backup +cp -r ~/.root/snapshots/* /tmp/root-snapshots-backup/ 2>/dev/null || true +rm -rf ~/.root/snapshots/* +root rollback --last +``` + +**Expected:** +- Clear error: "No snapshots available for rollback." +- Suggests running `root install` first. +- Lockfile and Rootfile are NOT corrupted. +- Exit code 6. + +```bash +# Restore snapshots +cp -r /tmp/root-snapshots-backup/* ~/.root/snapshots/ 2>/dev/null || true +rm -rf /tmp/root-snapshots-backup +``` + +--- + +## 6. Invalid Lockfile (`.drv` Injection) + +**Setup:** Manually inject a `.drv` path into the lockfile's output path to +simulate a corrupted or tampered lockfile. Root must refuse to proceed with +a mutation when output paths contain `.drv` suffixes. + +```bash +# Read the current lockfile +LOCKFILE=~/.root/root.lock +cp "$LOCKFILE" /tmp/root-v0.2.2-clean.lock + +# Inject a .drv path into the first package's store_path +python3 -c " +import json +with open('$LOCKFILE') as f: + lock = json.load(f) +if lock['packages']: + lock['packages'][0]['store_path'] = '/nix/store/xxxxx-fake-0.0.0.drv' + # Also inject into outputs if they exist + for output_name in lock['packages'][0].get('outputs', {}): + lock['packages'][0]['outputs'][output_name] = '/nix/store/xxxxx-fake-0.0.0.drv' +with open('$LOCKFILE', 'w') as f: + json.dump(lock, f, indent=2) +" + +# Attempt an install (or any mutation) +root install ffmpeg +``` + +**Expected:** +- Root refuses the mutation with a clear error message about `.drv` output + paths in the lockfile. +- Message explains that output paths must not end in `.drv`. +- Exit code 4 (verification failure). + +```bash +# Attempt rollback +root rollback --last +``` + +**Expected:** +- Root refuses with the same `.drv` defense. +- Lockfile is NOT modified by the failed operation. + +```bash +# Restore the clean lockfile +cp /tmp/root-v0.2.2-clean.lock "$LOCKFILE" + +# Verify normal operations resume +root install ffmpeg +``` + +**Expected:** Install succeeds as normal. + +**Cleanup:** + +```bash +rm -f /tmp/root-v0.2.2-clean.lock +``` + +--- + +## 7. Multiple Package Install & Update + +```bash +# Install three packages at once (separate commands since Root installs +# one package at a time) +root install ripgrep +root install fd +root install bat +``` + +**Expected:** +- Each install succeeds. +- Each creates a snapshot. +- Each shows "Rollback available with: root rollback --last". + +```bash +root list +``` + +**Expected:** +- Lists ffmpeg, ripgrep, fd, bat with their versions. +- Total matches the expected package count. + +```bash +# Verify each installed package +root verify ripgrep +root verify fd +root verify bat +``` + +**Expected:** +- Each binary is found in `~/.root/profiles/default/bin/` and executes + successfully. +- `rg --version`, `fd --version`, `bat --version` all pass. +- Verification SUCCESS for all three. + +```bash +# Update all managed packages +root update +``` + +**Expected:** +- Reports updated or unchanged packages. +- If any packages had newer versions available, they are updated. +- Snapshot saved (even if nothing changed, an update event is recorded). +- No `.drv` output path errors. +- `history --limit 3` shows update events. + +```bash +# Verify packages are still functional after update +root verify ripgrep +``` + +**Expected:** +- ripgrep (and all other packages) still functional. +- Verification SUCCESS. + +--- + +## 8. Profile Verification + +```bash +root list +``` + +**Expected:** +- Lists all managed packages: ffmpeg, ripgrep, fd, bat (and any others). +- Shows version for each. +- Shows Nix Profile State (profile path and element count). + +```bash +ls -la ~/.root/profiles/default/bin/ +``` + +**Expected:** +- Lists binaries for all installed packages: `ffmpeg`, `rg`, `fd`, `bat`. +- Each binary is a symlink into the Nix store. +- No dangling symlinks. +- Number of binaries matches the packages' expected binaries: + - ffmpeg: `ffmpeg`, `ffplay`, `ffprobe` (at minimum `ffmpeg`) + - ripgrep: `rg` + - fd: `fd` + - bat: `bat` + +```bash +# Cross-check that each binary resolves to the Root profile +for bin in ffmpeg rg fd bat; do + echo "$bin -> $(which $bin 2>/dev/null || echo 'not in PATH')" +done +``` + +**Expected:** +- Each binary that is on PATH resolves to `~/.root/profiles/default/bin/`. +- If PATH is not configured, the `which` output is acceptable — `root verify` + remains the authoritative check. + +```bash +# Verify the binaries are real Nix store paths +ls -la ~/.root/profiles/default/bin/rg +``` + +**Expected:** +- Output shows the symlink target, e.g.: + `/nix/store/-ripgrep-/bin/rg` +- The target path does NOT contain `.drv`. + +--- + +## JSON Output Check + +Test `--json` on the Nix-reliability-relevant commands: + +```bash +root doctor --json +root install ffmpeg --json +root verify ffmpeg --json +root rollback --last --json +root restore --lock /tmp/root-v0.2.2-restore.lock --json 2>/dev/null || true +root status --json +root history --json --limit 3 +root list --json +``` + +**Expected:** +- Valid JSON is printed to stdout. +- Errors include `"success": false` and a `"message"` field. +- Exit codes match the CLI error code table (0 success, 4 verification, + 6 rollback, 7 Nix unavailable, etc.). + +--- + +## Validation Checklist + +| Test Path | Status | Notes | +|----------------------------------------|--------|-------| +| 1. Missing Nix — doctor message | ☐ | | +| 2. Experimental features missing | ☐ | | +| 3. Clean install — init, doctor, install, verify, history | ☐ | | +| 4. Restore — restore, status, verify | ☐ | | +| 5. Rollback — rollback, history, verify| ☐ | | +| 5b. Rollback failure — no snapshots | ☐ | | +| 6. Invalid lockfile — `.drv` rejection | ☐ | | +| 7. Multi-package install & update | ☐ | | +| 8. Profile verification — list, `ls` bin, symlinks | ☐ | | +| JSON output — doctor, install, verify, rollback, restore, status, history, list | ☐ | | +| No `.drv` output paths in lockfile | ☐ | | +| All binaries resolve to Root profile | ☐ | | +| No panics or crashes on error paths | ☐ | | + +--- + +## Cleanup + +```bash +rm -f /tmp/root-v0.2.2-restore.lock /tmp/root-v0.2.2-clean.lock 2>/dev/null || true +``` + +If you used a disposable Root directory via `ROOT_DIR`: + +```bash +rm -rf "$ROOT_DIR" +unset ROOT_DIR +``` diff --git a/Docs/Release/V0_2_3_SANDBOX_SMOKE_TEST.md b/Docs/Release/V0_2_3_SANDBOX_SMOKE_TEST.md new file mode 100644 index 0000000..1bd7b21 --- /dev/null +++ b/Docs/Release/V0_2_3_SANDBOX_SMOKE_TEST.md @@ -0,0 +1,540 @@ +# Root v0.2.3 Sandbox Smoke Test + +Manual release validation focused on the new sandbox system: create, run, +destroy lifecycle; named sandboxes; custom images; error paths; timeout; +event ledger; Docker-unavailable handling; and JSON output. + +Run the full automated CI sequence first, then execute these checks with +Docker running and a disposable Root directory. + +--- + +## Automated Gates + +```bash +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all +cargo build +target/debug/root --version +``` + +**Expected:** every command succeeds and the binary reports `root 0.2.3`. + +--- + +## Prerequisites + +- macOS (Apple Silicon or Intel) or Linux +- Docker installed and running (`docker info` succeeds) +- Internet access (Docker Hub pull access for `ubuntu:latest` and `alpine:latest`) +- No existing `~/.root` directory (or back it up before these tests) +- `root` binary built from the v0.2.3 tag + +--- + +## 1. Fresh Sandbox Create, Run, Destroy + +Basic lifecycle: create a sandbox (default name), run a command inside it, +then destroy it. + +```bash +root sandbox create +``` + +**Expected:** +- Sandbox created with name `root-sandbox-default`. +- Output shows created sandbox name, id, image (`ubuntu:latest`), and status (`running`). +- Exit code 0. + +```bash +root sandbox run -- echo hello +``` + +**Expected:** +- Command executes inside the container. +- `hello` printed to stdout. +- Exit code 0. + +```bash +root sandbox destroy +``` + +**Expected:** +- Container stopped and removed. +- Output: `Destroyed sandbox ''.` +- Exit code 0. + +```bash +root history --limit 5 +``` + +**Expected:** +- Three sandbox events recorded: create, run, destroy. +- Each event has type `Sandbox`, a timestamp, and a status (`Completed`). +- Run event includes the command and exit code. + +--- + +## 2. Named Sandbox with Image + +Create a sandbox with an explicit name and a custom Docker image, run a +command, then destroy. + +```bash +root sandbox create test-box --image alpine:latest +``` + +**Expected:** +- Sandbox created with name `root-sandbox-test-box`. +- Image is `alpine:latest`. +- Status is `running`. +- Exit code 0. + +```bash +root sandbox run test-box -- echo hello from alpine +``` + +**Expected:** +- `hello from alpine` printed to stdout. +- Exit code 0. + +```bash +root sandbox destroy test-box +``` + +**Expected:** +- Container removed. +- Output: `Destroyed sandbox 'test-box'.` +- Exit code 0. + +--- + +## 3. Resource Limits + +Create a sandbox with explicit memory and CPU limits. + +```bash +root sandbox create --memory 1g --cpus 1 +``` + +**Expected:** +- Sandbox created with the specified resource constraints. +- Docker `inspect` confirms `--memory` and `--cpus` are applied to the container. +- Status is `running`. +- Exit code 0. + +**Cleanup:** + +```bash +root sandbox destroy default +``` + +--- + +## 4. Timeout + +Create a sandbox and run a command with a short timeout; the long-running +command should be killed after the timeout expires. + +```bash +root sandbox create timeout-box +``` + +**Expected:** +- Sandbox created and running. +- Exit code 0. + +```bash +root sandbox run timeout-box --timeout 5 -- sleep 30 +``` + +**Expected:** +- Command is killed after approximately 5 seconds. +- Exit code 124 (standard timeout exit code). +- Output includes a timeout message (e.g., "Command timed out"). +- Timeout event recorded in history with status `Failed` or `TimedOut`. + +**Cleanup:** + +```bash +root sandbox destroy timeout-box +``` + +**Expected:** +- Container removed. +- Exit code 0. + +--- + +## 5. Invalid Command + +Run a command that does not exist inside a valid sandbox. + +```bash +root sandbox create +``` + +**Expected:** +- Default sandbox created. +- Exit code 0. + +```bash +root sandbox run default -- nonexistent-command +``` + +**Expected:** +- Exit code non-zero (typically 127 -- command not found). +- Failure event recorded in history with status `Failed`. +- Sandbox is **not** destroyed (still listed in `root sandbox list`). + +**Cleanup:** + +```bash +root sandbox destroy default +``` + +**Expected:** +- Container removed. +- Exit code 0. + +--- + +## 6. Destroy Non-Existent Sandbox + +Attempt to destroy a sandbox that was never created. + +```bash +root sandbox destroy nonexistent-sandbox +``` + +**Expected:** +- Clear error: sandbox not found. +- Error message mentions the id `nonexistent-sandbox`. +- Exit code non-zero (1). +- No Docker containers affected. + +--- + +## 7. Repeated Destroy + +Destroy a sandbox twice. The first call succeeds; the second fails because +the sandbox is already gone. + +```bash +root sandbox create repeat-box +``` + +**Expected:** +- Sandbox created. +- Exit code 0. + +```bash +root sandbox destroy repeat-box +``` + +**Expected:** +- First destroy succeeds. +- Output: `Destroyed sandbox 'repeat-box'.` +- Exit code 0. + +```bash +root sandbox destroy repeat-box +``` + +**Expected:** +- Second destroy fails with `"not found"` or similar error. +- Error message clearly indicates the sandbox no longer exists. +- Exit code non-zero (1). + +--- + +## 8. Run Destroyed Sandbox + +Create a sandbox, destroy it, then attempt to run a command in it. The run +must fail with an invalid lifecycle transition error. + +```bash +root sandbox create run-destroy +``` + +**Expected:** +- Sandbox created. +- Exit code 0. + +```bash +root sandbox destroy run-destroy +``` + +**Expected:** +- Container removed. +- Exit code 0. + +```bash +root sandbox run run-destroy -- echo hello +``` + +**Expected:** +- Clear error: sandbox not found or container not running. +- Error message explains the sandbox does not exist. +- Exit code non-zero (1). + +--- + +## 9. List Sandboxes + +Create multiple sandboxes, verify they appear in `list`, then destroy them +and verify the list is empty. + +```bash +root sandbox create list-test-1 +root sandbox create list-test-2 +``` + +**Expected:** +- Both sandboxes created successfully. +- Exit code 0 for each. + +```bash +root sandbox list +``` + +**Expected:** +- Lists 2 sandboxes: `root-sandbox-list-test-1` and `root-sandbox-list-test-2`. +- Each entry shows name, id, status (`running`), and image (`ubuntu:latest`). +- Exit code 0. + +```bash +root sandbox destroy list-test-1 +root sandbox destroy list-test-2 +``` + +**Expected:** +- Both destroyed successfully. +- Exit code 0 for each. + +```bash +root sandbox list +``` + +**Expected:** +- Message: `No Root-managed sandboxes.` +- Exit code 0. + +--- + +## 10. Event Ledger for Sandbox Operations + +Run a sequence of sandbox operations and verify they are all recorded in the +event ledger with correct timestamps and results. + +```bash +# Run a representative sequence +root sandbox create ledger-test +root sandbox run ledger-test -- echo event-ledger-check +root sandbox destroy ledger-test + +# Now inspect the ledger +root history +``` + +**Expected (human-readable):** +- Three sandbox events listed: create, run, destroy. +- Each event has: + - Type: `Sandbox` + - Timestamp (ISO 8601 or RFC 3339) + - Status: `Completed` (or `Failed` for error cases in other tests) + - A detail message (e.g., "Created sandbox 'root-sandbox-ledger-test' (id: ...)") + +```bash +root history --json --limit 10 +``` + +**Expected (JSON):** +- Valid JSON array (or object wrapping an array). +- Each entry has `event_type`, `timestamp`, `status`, `details`. +- Sandbox events are interleaved with any other operation events in + chronological order. +- No missing or duplicate events. + +--- + +## 11. Docker Unavailable + +Simulate the condition where Docker is not running or not on PATH, then +verify the error message is clear and actionable. + +**Setup:** Temporarily disable Docker (e.g., quit Docker Desktop, or rename +the `docker` binary, or set `PATH` to exclude it): + +```bash +# Option A: quit Docker Desktop and verify +# Option B: run in a subshell with modified PATH +PATH=/usr/bin:/bin:/usr/sbin:/sbin root sandbox create +``` + +**Expected (when Docker is unavailable):** +- Clear error message: "No sandbox provider is available." +- Explanation that Root requires Docker to create sandboxes. +- Suggestion to install Docker Desktop and link to https://docker.com. +- Suggestion to verify with `docker info`. +- No panic, no crash. +- Exit code 1. + +```bash +PATH=/usr/bin:/bin:/usr/sbin:/sbin root sandbox list +``` + +**Expected:** +- Error indicating Docker is unavailable. +- Exit code non-zero. + +**Cleanup:** Restart Docker or restore PATH. Verify: + +```bash +docker info +``` + +**Expected:** Docker is available and `docker info` succeeds. + +--- + +## 12. JSON Output + +Add `--json` to every sandbox command and verify structured output. + +```bash +root sandbox create --json +``` + +**Expected (JSON):** +```json +{ + "success": true, + "id": "...", + "name": "root-sandbox-default", + "image": "ubuntu:latest", + "status": "running", + "created_at": "..." +} +``` + +```bash +# Note the id from the create output, then: +root sandbox run -- echo hello --json +``` + +**Expected (JSON):** +```json +{ + "success": true, + "sandbox_id": "...", + "command": "echo hello", + "exit_code": 0, + "stdout": "hello\n", + "stderr": "" +} +``` + +```bash +root sandbox list --json +``` + +**Expected (JSON):** +```json +{ + "success": true, + "sandboxes": [ + { + "id": "...", + "name": "root-sandbox-default", + "status": "running", + "created_at": "...", + "image": "ubuntu:latest" + } + ] +} +``` + +```bash +root sandbox destroy --json +``` + +**Expected (JSON):** +```json +{ + "success": true, + "id": "" +} +``` + +**Error JSON** (test on a known failure, e.g. destroy a nonexistent sandbox): + +```bash +root sandbox destroy nonexistent --json +``` + +**Expected (JSON):** +```json +{ + "success": false, + "message": "Sandbox 'nonexistent' not found" +} +``` + +- Exit code 1. + +```bash +root history --json --limit 5 +``` + +**Expected (JSON):** +- Valid JSON array or object. +- Includes sandbox events from the operations above. +- Each event has `event_type`, `timestamp`, `status`, `details`. + +--- + +## Validation Checklist + +| Test Path | Status | Notes | +|----------------------------------------------------|--------|-------| +| 1. Fresh sandbox create, run, destroy | ☐ | | +| 2. Named sandbox with `--image alpine:latest` | ☐ | | +| 3. Resource limits (`--memory`, `--cpus`) | ☐ | | +| 4. Timeout (`--timeout 5` on long-running command) | ☐ | | +| 5. Invalid command (nonexistent binary) | ☐ | | +| 6. Destroy non-existent sandbox | ☐ | | +| 7. Repeated destroy (first succeeds, second fails) | ☐ | | +| 8. Run destroyed sandbox (invalid lifecycle) | ☐ | | +| 9. List sandboxes (create two, list, destroy) | ☐ | | +| 10. Event ledger for sandbox operations | ☐ | | +| 11. Docker unavailable (clear error message) | ☐ | | +| 12. JSON output (create, run, list, destroy) | ☐ | | +| No panics or crashes on any error path | ☐ | | +| All sandbox containers use `root-sandbox-` prefix | ☐ | | +| History correctly records sandbox events | ☐ | | + +--- + +## Cleanup + +```bash +# Remove any remaining sandbox containers +root sandbox list --json | python3 -c " +import json, subprocess, sys +data = json.load(sys.stdin) +for sb in data.get('sandboxes', []): + subprocess.run(['root', 'sandbox', 'destroy', sb['name'].replace('root-sandbox-', '')]) +" + +# Or remove by force directly via Docker (if root CLI is unavailable) +docker rm -f $(docker ps -aq --filter name=root-sandbox-) 2>/dev/null || true +``` + +If you used a disposable Root directory via `ROOT_DIR`: + +```bash +rm -rf "$ROOT_DIR" +unset ROOT_DIR +``` diff --git a/Docs/Sandbox/V0_2_1_SANDBOX_AUDIT.md b/Docs/Sandbox/V0_2_1_SANDBOX_AUDIT.md new file mode 100644 index 0000000..ed77d23 --- /dev/null +++ b/Docs/Sandbox/V0_2_1_SANDBOX_AUDIT.md @@ -0,0 +1,516 @@ +# Sandbox Audit — Root v0.2.1 + +**Date:** 2026-06-23 +**Scope:** Full sandbox subsystem across `root-sandbox`, `root-core`, `root-cli`, and `policy` +**Auditor:** Automated codebase analysis +**Version:** 0.2.1 (workspace Cargo.toml) + +--- + +## Executive Summary + +- **Sandbox provider:** Docker-only (`RealSandboxProvider` in `crates/root-sandbox/src/lib.rs`) +- **CLI commands exposed:** `root sandbox create`, `root sandbox run`, `root sandbox list`, `root sandbox destroy` +- **Sandbox trait** (`SandboxProvider`): 5 methods — `check_availability`, `create`, `run_command`, `list`, `destroy` +- **Policy integration:** 3 `PolicyAction` variants for sandbox (`SandboxCreate`, `SandboxRun`, `SandboxDestroy`) +- **Event recording:** All sandbox operations record a `RootEventType::Sandbox` event +- **Total gaps identified: 12** (see Gap Summary below) + +--- + +## Gap Summary + +| # | Gap | Severity | Area | Category | +|---|-----|----------|------|----------| +| 1 | No sandbox lifecycle model — no state machine, no state tracking | Critical | Core | Lifecycle | +| 2 | No timeout enforcement for running commands | High | sandbox-provider | Resource Mgmt | +| 3 | `destroy` only verifies name prefix, not ownership metadata | Medium | sandbox-provider | Security | +| 4 | No orphan cleanup on partial failure — stale containers leak | High | sandbox-provider | Resource Mgmt | +| 5 | `list` does not populate `created_at` (empty string) | Low | sandbox-provider | Data Quality | +| 6 | No periodic or TTL-based expiry of sandboxes | High | System | Resource Mgmt | +| 7 | `run_command` uses raw `id` with no ownership check | Medium | sandbox-provider | Security | +| 8 | Event recording swallows errors (unused `let _`) | Low | Core | Reliability | +| 9 | No `started_at` / `duration_ms` in sandbox events | Medium | Core | Observability | +| 10 | No sandbox-Nix ecosystem unification | Low | Design | Architecture | +| 11 | No sandbox `exec` interactive mode / TTY support | Low | CLI | UX | +| 12 | `image` parameter accepts arbitrary images — no digest pinning | Medium | sandbox-provider | Security | + +--- + +## 1. Architecture Overview + +### Files Involved + +| File | Role | +|------|------| +| `crates/root-sandbox/src/lib.rs` | `SandboxProvider` trait + `RealSandboxProvider` (Docker) + `MockSandboxProvider` (tests) | +| `crates/root-core/src/lib.rs` (lines 2255–2284, 2846–2967) | Report structs + sandbox orchestration functions | +| `crates/root-core/src/policy.rs` (lines 73–90, 167–170) | `SandboxPolicy` struct + `PolicyAction::Sandbox*` | +| `crates/root-core/src/events.rs` | `RootEventType::Sandbox` + event recording | +| `crates/root-cli/src/main.rs` (lines 114–168, 310–317, 987–1055) | CLI subcommand parsing, provider instantiation, dispatch | + +### Dependency Graph + +``` +root-cli (bin) + ├── root-core (sandbox_create, sandbox_run, sandbox_list, sandbox_destroy) + │ ├── root-sandbox (SandboxProvider trait) + │ └── root-lockfile (init_root_dir) + └── root-sandbox (RealSandboxProvider) +``` + +--- + +## 2. Operation-by-Operation Audit + +### 2.1 `sandbox create` + +**CLI:** `root sandbox create [NAME] [--image IMAGE]` +**Rust function:** `sandbox_create()` at `crates/root-core/src/lib.rs:2846` +**Provider method:** `RealSandboxProvider::create()` at `crates/root-sandbox/src/lib.rs:79` + +#### Expected Behavior +1. Initializes `~/.root` directory via `init_root_dir()`. +2. Enforces policy (`SandboxCreate`) — denied => error exit. +3. Checks Docker availability via `docker info`. +4. Calls provider `create(name, image)`. +5. Provider: sanitizes name as `root-sandbox-{name}`, force-removes any existing container with that name (`docker rm -f`), runs `docker run -d --name sleep infinity`, inspects container ID. +6. Records a `Sandbox` / `Completed` event. +7. Returns `SandboxCreateReport`. + +#### Failure Modes +| Condition | Behavior | Exit Code | Gap? | +|-----------|----------|-----------|------| +| Docker not on PATH | `SandboxError::NotAvailable` → pretty-printed error advising Docker install | 1 | No | +| Docker daemon not running | `docker info` returns false → same as above | 1 | No | +| Policy denies `SandboxCreate` | `enforce_policy` returns `Err` → `Policy denied` → exit code 9 | 9 | No | +| `docker rm -f` on pre-existing fails | Error silently ignored | N/A | **Gap 8** | +| `docker run` fails (bad image, OOM, etc.) | `SandboxError::Generic` from run_docker | 1 | No | +| Image not found on Docker Hub | `docker run` fails → generic error "Unable to find image" | 1 | No | +| No `sleep infinity` available in image | `docker run` fails — container exits immediately | 1 | No | +| `docker run` succeeds but `docker inspect` fails | Container leaked — Root cannot manage it | 1 | **Gap 4** | + +#### Cleanup Behavior +- **On success:** None needed — container is running. +- **On failure:** The `docker rm -f` pre-clean only runs if an existing container with the same `root-sandbox-{name}` exists. If `docker run` succeeds but `docker inspect` fails, the container is **orphaned** — no rollback/deletion occurs. **(Gap 4)** +- The pre-existing container removal is silent (`let _ = Self::run_docker(...)`) — errors are swallowed. + +#### Current Gaps +- **Gap 4:** No cleanup on partial failure. If `docker run -d` creates the container but the subsequent `docker inspect` fails, the container is leaked. +- **Gap 2:** No timeout on `docker run`. A slow image pull blocks indefinitely. +- **Gap 12:** `image` defaults to `ubuntu:latest` (floating tag) — no digest pinning, no validation. + +--- + +### 2.2 `sandbox run` + +**CLI:** `root sandbox run [-- ...]` +**Rust function:** `sandbox_run()` at `crates/root-core/src/lib.rs:2892` +**Provider method:** `RealSandboxProvider::run_command()` at `crates/root-sandbox/src/lib.rs:108` + +#### Expected Behavior +1. Initializes `~/.root` directory. +2. Enforces policy (`SandboxRun`). +3. Calls `provider.run_command(id, command)` which executes `docker exec `. +4. Captures stdout, stderr, exit code. +5. Records event with status `Completed` (exit 0) or `Failed` (nonzero). +6. Returns `SandboxRunReport`. +7. CLI: if `!report.success`, exits with `report.exit_code.max(1)`. + +#### Failure Modes +| Condition | Behavior | Exit Code | Gap? | +|-----------|----------|-----------|------| +| Policy denies | Same as create — exit 9 | 9 | No | +| Sandbox ID not found | `docker exec` fails → `SandboxError::Generic` → exit 1 | 1 | No | +| Command not found inside container | `docker exec` returns exit 127 | propagated | No | +| Container stopped/paused | `docker exec` fails → generic error | 1 | No | +| Arbitrary container ID passed | `docker exec` still runs — no ownership check | 1 | **Gap 7** | +| Command hangs (infinite loop) | Process blocks indefinitely | N/A | **Gap 2** | + +#### Cleanup Behavior +- None. The command's side effects inside the container are intentional; no cleanup is expected. + +#### Current Gaps +- **Gap 2 (repeated):** `docker exec` has no timeout. Hanging commands block the Root CLI forever. +- **Gap 7 (new):** `run_command` passes the `id` directly to `docker exec` with no verification that the container is Root-owned (unlike `destroy` which checks `root-sandbox-` prefix). An attacker or user error can execute commands in *any* Docker container. +- **Gap 9:** Events record only `message` with exit code text; no `started_at`, `finished_at`, or `duration_ms` fields are populated. + +--- + +### 2.3 `sandbox list` + +**CLI:** `root sandbox list` +**Rust function:** `sandbox_list()` at `crates/root-core/src/lib.rs:2934` +**Provider method:** `RealSandboxProvider::list()` at `crates/root-sandbox/src/lib.rs:124` + +#### Expected Behavior +1. Calls `provider.list()` which runs `docker ps -a --filter name=root-sandbox- --format '{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}'`. +2. Parses tab-separated output into `Vec`. +3. Returns `SandboxListReport`. + +#### Failure Modes +| Condition | Behavior | Exit Code | Gap? | +|-----------|----------|-----------|------| +| Docker unavailable | Propagates error | 1 | No | +| No containers match filter | Returns empty list | 0 | No | +| Malformed `docker ps` output | Silently drops lines with <4 parts | 0 (partial) | **Gap 5** | + +#### Cleanup Behavior +- Read-only operation. No cleanup needed. + +#### Current Gaps +- **Gap 5:** `created_at` is always `String::new()` (empty string) because `docker ps --format` output does not include creation time in the template. The field is silently missing. +- **Gap 5b:** Lines with unexpected format are silently dropped. A corrupt Docker output could lead to an incomplete list. +- No policy enforcement for `list` — `sandbox_list()` does NOT call `enforce_policy`. This is arguably correct (list is informational), but inconsistent with the other three operations. + +--- + +### 2.4 `sandbox destroy` + +**CLI:** `root sandbox destroy ` +**Rust function:** `sandbox_destroy()` at `crates/root-core/src/lib.rs:2945` +**Provider method:** `RealSandboxProvider::destroy()` at `crates/root-sandbox/src/lib.rs:160` + +#### Expected Behavior +1. Initializes `~/.root` directory. +2. Enforces policy (`SandboxDestroy`). +3. Calls `provider.destroy(id)`. +4. Provider: inspects container (`docker inspect --format '{{.Name}}' `), verifies name starts with `root-sandbox-`, then force-removes (`docker rm -f`). +5. Records `Completed` event. +6. Returns `SandboxDestroyReport`. + +#### Failure Modes +| Condition | Behavior | Exit Code | Gap? | +|-----------|----------|-----------|------| +| Policy denies | Exit 9 | 9 | No | +| Container not found | `docker inspect` fails → `SandboxError::NotFound` → exit 1 | 1 | No | +| Container not Root-owned | Name does not start with `root-sandbox-` → `SandboxError::NotRootOwned` → exit 1 | 1 | No | +| `docker rm -f` fails (permission, etc.) | `SandboxError::Generic` | 1 | No | +| Container is from a different Root instance (same prefix) | Name check passes — destroys anyway | 0 | **Gap 3** | +| Container already removed | `docker inspect` fails → mapped to `NotFound` | 1 | Correct | + +#### Cleanup Behavior +- Force-removes the container. This is the cleanup operation itself — it is the intended cleanup. +- No cleanup if `destroy` itself fails partway. If `docker inspect` succeeds but `docker rm -f` fails, the container is left in an unknown state but still running. + +#### Current Gaps +- **Gap 3:** Ownership check is name-prefix based (`root-sandbox-`). This is a weak heuristic — any container prefixed with `root-sandbox-` from any source is considered Root-owned. There is no label-based ownership (e.g., Docker labels), no checksum, no signature. +- **Gap 3b:** The ownership check queries the container name via `docker inspect`, then strips the leading `/`, then checks the prefix. If Docker ever changes the output format of `{{.Name}}`, this check could silently pass or fail. +- If `destroy` is called on an already-destroyed container, the `docker inspect` fails with "No such object" which maps to `NotFound` — this is correct behavior. + +--- + +## 3. Lifecycle Model + +### Current State + +**There is no lifecycle model.** The system has a flat set of 4 operations (create, run, list, destroy) with no state machine: + +``` + ┌──────────┐ + │ absent │ + └────┬─────┘ + │ create + ▼ + ┌──────────┐ + │ running │ ← (from docker ps output) ←──┐ + └────┬─────┘ │ + │ destroy │ docker stop (external) + ▼ │ + ┌──────────┐ docker start (external) ──────┘ + │ absent │ + └──────────┘ + + No: paused, stopped, starting, error, expired states + No: explicit state transitions managed by Root + No: state persistence in events or lockfile +``` + +### Gap Analysis + +| Missing Concept | Impact | +|-----------------|--------| +| No state enum | `SandboxInstance.status` is a raw `String` from Docker (e.g., "Up 2 hours", "Exited (0)") — not normalized beyond the "running" / "Up" check in `list()`. | +| No sandbox registry | Sandboxes are not tracked in `~/.root/` — no lockfile entry, no fingerprint. Recovery after `docker system prune` is impossible. | +| No expiry / TTL | Sandboxes run forever until explicitly destroyed. Long-lived sandboxes accumulate resources. | +| No lifecycle hooks | No pre-create, post-create, pre-destroy, post-destroy hooks. | + +**Gap 1 (Critical):** The entire lifecycle is delegated to Docker with no Root-level abstraction. Root cannot answer "what sandboxes exist?" without querying Docker. If Docker metadata is lost (prune, reset, reinstall), Root has zero awareness of its sandboxes. + +--- + +## 4. Event Recording Quality + +### What is recorded + +All sandbox operations call `events::record_event()` with: +- `event_type`: `RootEventType::Sandbox` +- `status`: `Completed` or `Failed` +- `command`: `"root sandbox create {name}"`, `"root sandbox run {id}"`, `"root sandbox destroy {id}"` +- `message`: Descriptive text +- All other fields: `None` + +### What is NOT recorded + +| Field | Create | Run | Destroy | Gap? | +|-------|--------|-----|---------|------| +| `package` | None (N/A) | None | None | — | +| `snapshot_id` | None | None | None | — | +| `task_name` | None | None | None | #9 | +| `exit_code` | None | None | None | #9 | +| `started_at` | None | None | None | #9 | +| `finished_at` | None | None | None | #9 | +| `duration_ms` | None | None | None | #9 | +| `policy_decision` | None | None | None | #9 | + +**Gap 9 (Medium):** Sandbox events are low-fidelity compared to execution events (`record_execution_event`), which capture `exit_code`, `started_at`, `finished_at`, `duration_ms`, and `task_name`. Sandbox events use the generic `record_event` function and miss all timing and exit information. + +**Gap 8 (Low):** All `record_event` calls use `let _ = ...`, which silently ignores write failures (disk full, permission denied, etc.). If event recording fails, the sandbox operation still reports success to the user — a silent data loss. + +--- + +## 5. Error Handling Quality + +### Error Type Hierarchy + +``` +SandboxError (root-sandbox) + ├── NotAvailable(String) — Docker not on PATH / daemon not running + ├── NotFound(String) — Container ID not found + ├── NotRootOwned(String) — Container name doesn't start with root-sandbox- + └── Generic(String) — Any other Docker failure +``` + +### Mapping to CLI exit codes + +The `exit_code_for_error()` function in `root-cli/src/main.rs` (line 194) maps errors: +- `SandboxError` types are NOT explicitly matched — they fall through to the generic `1` exit code. +- Only `NixError` variants and string-pattern matching on error messages produce specific exit codes (3, 4, 5, 6, 7, 8, 9). +- Sandbox-specific error codes (e.g., "Docker not available" → 7, "sandbox not found" → 3) are not defined for sandbox errors. + +### Assessment + +| Strength/Weakness | Detail | +|-------------------|--------| +| Good error categorization | `NotAvailable`, `NotFound`, `NotRootOwned`, `Generic` cover expected cases | +| Good provider error wrapping | `sandbox_create/run/destroy` wrap `SandboxError` in `anyhow::Error` with context | +| No sandbox-specific exit codes | All sandbox failures map to exit 1 (generic). No way to distinguish "Docker unavailable" from "container not found" in scripts. | +| Inconsistent `destroy` error on missing container | Missing container → `docker inspect` fails. The `map_err` at line 163 maps all inspect failures to `NotFound` — even when the actual error is something else like a daemon error. | +| `run_command` doesn't map `docker exec` failures correctly | If Docker returns a non-zero exit, `Command::new().output()` still succeeds (the process ran). Only if `Command::new()` itself fails (e.g., Docker binary missing) is an error returned. Non-zero exits from the executed command are reported in `SandboxExecResult.exit_code`, not as provider errors. | + +--- + +## 6. Docker Dependency Assumptions + +### Hard Dependency + +The `RealSandboxProvider` has a hard dependency on the `docker` CLI being on `PATH`. There is: +- No fallback to Podman, containerd, or any other OCI runtime. +- No embedded Docker SDK (uses CLI subprocess). +- No graceful degradation — if Docker is absent, sandbox commands are completely non-functional. + +### Assumptions Made + +| Assumption | Risk | +|------------|------| +| `docker` binary is on PATH | If Docker Desktop is installed but CLI is not symlinked, `Command::new("docker")` fails. | +| `docker info` is sufficient availability check | Docker daemon could be running but resource-exhausted. | +| `docker exec` always attaches stdout/stderr correctly | Pseudo-TTY vs non-TTY differences (CLI does NOT use `-t` or `-i`). | +| `docker ps -a --filter name=root-sandbox-` returns all managed containers | If a user renames a container, it escapes management. | +| Tab-separated output parsing is stable | Docker output format changes could silently break parsing. | +| `docker inspect --format '{{.Name}}'` returns name starting with `/` | Brittle string manipulation (`trim_start_matches('/')`). | + +### Test Isolation + +- `MockSandboxProvider` in `crates/root-sandbox/src/lib.rs:173` provides full in-memory testing without Docker. +- The `SandboxProvider` trait enables swapping implementations, but the CLI always instantiates `RealSandboxProvider` (line 317 of `main.rs`). +- There are **no unit tests for `sandbox_create`, `sandbox_run`, `sandbox_list`, or `sandbox_destroy` in `root-core`** — only the `MockSandboxProvider` unit tests exist in `root-sandbox`. The orchestration layer (policy enforcement + event recording + provider call) in `root-core` is untested. + +--- + +## 7. Resource Management Issues + +### Identified Issues + +| Issue | Details | Severity | +|-------|---------|----------| +| **Orphaned containers** | If `docker run` succeeds but `docker inspect` fails, the container is leaked with no cleanup. (Gap 4) | High | +| **No TTL / expiry** | Sandboxes run `sleep infinity` and persist until `destroy` is called. No automatic garbage collection. (Gap 6) | High | +| **No resource limits** | `docker run` is called without `--memory`, `--cpus`, `--pids-limit`, or any resource constraint. A sandbox can consume all host resources. (Gap 2) | Medium | +| **No network isolation controls** | Sandbox has full network access by default. `ResourcePolicy` exists but is not enforced at the Docker level — only at the policy-evaluation level. | Medium | +| **No storage limits** | No `--storage-opt` limits. A sandbox can fill the host disk. | Medium | +| **Accumulation of stopped containers** | `root sandbox list` shows all Root-named containers (including stopped/exited ones). No pruning of exited containers. (Gap 6) | Low | +| **Event log growth** | `events.jsonl` grows unboundedly. No log rotation or retention policy for sandbox events. | Low | + +--- + +## 8. Security Analysis + +| Concern | Detail | Severity | +|---------|--------|----------| +| **Weak ownership guard** | `destroy` only checks name prefix (`root-sandbox-`). No labels, no UUID, no signature. (Gap 3) | Medium | +| **No ownership check on `run_command`** | `run_command` accepts any Docker container ID/name — no `root-sandbox-` prefix check. (Gap 7) | Medium | +| **Floating image tags** | Default `ubuntu:latest` updates silently. No digest pinning. (Gap 12) | Medium | +| **No sandbox user isolation** | `docker exec` runs as root inside the container by default. Commands have full container root access. | Low | +| **No capability dropping** | Container runs with Docker's default capabilities, not a hardened subset. | Low | +| **Policy bypass surface** | `SandboxPolicy` controls access at the Root CLI level, but Docker CLI is still directly available — a user can bypass Root policy by running `docker exec` directly. | Low (by design — Root is not a security boundary) | + +--- + +## 9. Detailed Gap Records + +### Gap 1: No Lifecycle Model (Critical) +- **File:** System-wide (no dedicated lifecycle code exists) +- **Description:** There is no formal state machine for sandboxes. `SandboxInstance.status` is a raw `String` from Docker output (parsed only for "running" vs everything else). Root cannot track transitions between states, cannot detect sandbox crashes, and has no persistence layer for sandbox metadata outside Docker. +- **Recommendation:** Introduce a `SandboxState` enum (`Created`, `Running`, `Stopped`, `Error`, `Destroyed`), persist sandbox metadata to `~/.root/sandboxes.json` or similar, and query Docker only as an implementation detail of the provider. + +### Gap 2: No Timeouts on Any Operation (High) +- **File:** `crates/root-sandbox/src/lib.rs` lines 49–62 (`run_docker`), line 112 (`run_command`) +- **Description:** `docker run`, `docker exec`, and all other Docker commands run with no timeout. A slow image pull, a hanging command, or a hung Docker daemon blocks Root indefinitely. There is no `std::process::Command` timeout mechanism. +- **Recommendation:** Add a configurable timeout (default e.g. 30s for exec, 120s for create) by spawning the command in a thread with a timeout or using a crate like `wait-timeout`. + +### Gap 3: Weak Ownership Verification (Medium) +- **File:** `crates/root-sandbox/src/lib.rs` lines 162–167 (`destroy`) +- **Description:** Ownership check is a single string prefix match on `root-sandbox-`. Any container whose name starts with this prefix is considered Root-owned. There is no label-based verification, no cryptographic proof, no stored fingerprint. +- **Recommendation:** (a) Add Docker labels (`root-managed=true`, `root-version=0.2.1`, `root-sandbox-id=`) at container creation time. (b) Verify labels on destroy. (c) Apply same check to `run_command`. + +### Gap 4: Orphan Container Leak on Partial Create Failure (High) +- **File:** `crates/root-sandbox/src/lib.rs` lines 87–98 (`create`) +- **Description:** The `create` method runs `docker rm -f` (pre-clean), then `docker run -d`, then `docker inspect`. If `docker run` succeeds but `docker inspect` fails, the running container is leaked — it exists in Docker but Root never records its ID and cannot manage it. +- **Recommendation:** Use a scoped cleanup guard (a `Drop`-based guard struct) to remove the container if any post-creation step fails. If `create()` returns an error, the guard tears down the container. + +### Gap 5: Missing `created_at` in `list` Output (Low) +- **File:** `crates/root-sandbox/src/lib.rs` lines 124–158 +- **Description:** `list()` uses `docker ps --format '{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}'`, which does not include a creation timestamp. `SandboxInstance.created_at` is always set to `String::new()`. This field is effectively useless. +- **Recommendation:** Add `{{.CreatedAt}}` to the format template and parse the timestamp. Alternatively, populate `created_at` from the provider's create return value (but `list()` doesn't have access to that — a registry/persistence layer would solve this). + +### Gap 6: No Automatic Cleanup / Garbage Collection (High) +- **File:** System-wide +- **Description:** Sandboxes run `sleep infinity` and live until explicitly destroyed. There is no TTL mechanism, no periodic cleanup, no automatic pruning of exited containers, and no cleanup on Root uninstall. Long-running or abandoned sandboxes consume disk, memory, and process slots indefinitely. +- **Recommendation:** (a) Add a `--ttl` flag to `sandbox create` that auto-destroys after a duration. (b) Add a `root sandbox prune` command to remove stopped/exited containers. (c) Consider a background worker (optional) for periodic cleanup. + +### Gap 7: `run_command` Skips Ownership Check (Medium) +- **File:** `crates/root-sandbox/src/lib.rs` lines 108–122 +- **Description:** `run_command()` passes the `id` directly to `docker exec` with no verification that the container is Root-managed. Unlike `destroy()` which checks `root-sandbox-` prefix, `run_command()` executes in any container ID/name provided. +- **Recommendation:** Add the same `root-sandbox-` prefix check (or, better, label-based verification) to `run_command()` before executing. Return `NotRootOwned` if the container is not managed by Root. + +### Gap 8: Event Recording Errors Are Silently Swallowed (Low) +- **File:** `crates/root-core/src/lib.rs` lines 2869, 2911, 2953 +- **Description:** All sandbox event recordings use `let _ = record_event(...)`. If the events file cannot be written (disk full, permissions, filesystem error), the error is silently discarded. The user sees a successful operation even though audit data was lost. +- **Recommendation:** Log the recording failure (eprintln or warn) but do not fail the operation. At minimum, the user should be aware that event recording failed. + +### Gap 9: Sandbox Events Lack Execution Metadata (Medium) +- **File:** `crates/root-core/src/lib.rs` lines 2869–2880, 2911–2922, 2953–2961 +- **Description:** Sandbox events use the generic `record_event()` function which only captures `event_type`, `status`, `command`, `package`, `snapshot_id`, `restored_snapshot_id`, and `message`. Execution events via `record_execution_event()` additionally capture `task_name`, `exit_code`, `started_at`, `finished_at`, and `duration_ms`. Sandbox events lack all of these fields. +- **Recommendation:** Create a dedicated sandbox event recording path (analogous to `record_execution_event()`) that captures timing, exit code, and sandbox metadata. + +### Gap 10: No Sandbox-Nix Ecosystem Unification (Low) +- **File:** System-wide architectural gap +- **Description:** Sandbox is a completely separate subsystem from the Nix-based package management. There is no way to install a Root-managed package *into* a sandbox. A user cannot run `root sandbox create build-env && root sandbox run build-env -- root install ffmpeg` because the `root` CLI inside the container is not available. +- **Recommendation:** This is a design choice for now, but it should be documented as a known limitation. Consider adding a `--nix` flag to sandbox create that pre-installs Nix inside the container, or a `sandbox provision` command. + +### Gap 11: No Interactive / TTY Mode for `sandbox run` (Low) +- **File:** `crates/root-cli/src/main.rs` lines 1000–1029, `crates/root-sandbox/src/lib.rs` lines 108–122 +- **Description:** `sandbox run` uses `docker exec` without `-it` flags. Commands that require a TTY (interactive shells, `top`, etc.) will fail or behave differently. There is no `--interactive` flag on the CLI. +- **Recommendation:** Add a `--interactive` / `-i` flag to `root sandbox run` that passes `-it` to `docker exec`. + +### Gap 12: Unpinned / Floating Docker Image Tags (Medium) +- **File:** `crates/root-sandbox/src/lib.rs` line 80 +- **Description:** The default image is `ubuntu:latest` (a floating tag that changes over time). The `--image` flag accepts any Docker image reference with no validation, no digest pinning, and no checksum verification. This means sandbox environments are non-deterministic — two `root sandbox create` calls at different times can produce different base systems. +- **Recommendation:** (a) Warn when a floating tag is used. (b) Optionally resolve the tag to a digest at creation time and store it. (c) Consider Root maintaining a pinned default image with a known digest. + +--- + +## 10. Test Coverage Analysis + +### Existing Tests (`crates/root-sandbox/src/lib.rs` lines 266–355) + +| Test | What It Covers | What It Misses | +|------|----------------|----------------| +| `test_mock_availability` | Mock returns correct availability | No test for real provider | +| `test_mock_create_list_destroy` | Full create/list/destroy cycle | No failure injection | +| `test_mock_run_command` | Run command in mock | No error cases (bad command, missing container) | +| `test_mock_destroy_not_found` | Destroy non-existent sandbox | — | +| `test_mock_destroy_root_owned_container` | Destroy by name works | — | +| `test_mock_destroy_rejects_non_root_container` | Rejects non-Root-owned | — | +| `test_mock_destroy_by_id_root_owned` | Destroy by ID works | — | +| `test_mock_unavailable_errors` | All ops fail when unavailable | — | + +### Missing Tests + +| Test Needed | Location | Reason | +|-------------|----------|--------| +| `sandbox_create` policy enforcement | `root-core` | Orchestration untested | +| `sandbox_run` policy enforcement | `root-core` | Orchestration untested | +| `sandbox_destroy` policy enforcement | `root-core` | Orchestration untested | +| `sandbox_create` event recording | `root-core` | Event side effects untested | +| `sandbox_run` exit code & event propagation | `root-core` | Status mapping untested | +| `RealSandboxProvider` integration tests | `root-sandbox` | Real Docker not exercised in CI | +| `sandbox_list` with no containers | `root-core` | Edge case | +| Concurrent sandbox operations | `root-core` | Race conditions (no mutex on sandbox path) | + +--- + +## 11. Recommendations by Priority + +### Immediate (Next Release) + +1. **[Gap 2]** Add timeouts to `run_docker()` and `run_command()` — prevents indefinite hangs. +2. **[Gap 7]** Add ownership check to `run_command()` — closes a security hole. +3. **[Gap 8]** Log event recording failures instead of swallowing — improves observability. + +### Short-Term (Next Two Releases) + +4. **[Gap 4]** Add cleanup guard to `create()` for partial failures — prevents resource leaks. +5. **[Gap 5]** Include `{{.CreatedAt}}` in `docker ps` format — fixes data quality. +6. **[Gap 9]** Add execution metadata fields to sandbox events — improves audit quality. +7. **[Gap 3]** Add Docker labels for ownership verification — strengthens security model. + +### Medium-Term + +8. **[Gap 1]** Design and implement a formal sandbox lifecycle with state persistence. +9. **[Gap 6]** Add TTL support, `prune` command, and automatic cleanup. +10. **[Gap 12]** Pin default image by digest; warn on floating tags. + +### Long-Term + +11. **[Gap 10]** Explore Nix-in-sandbox unification. +12. **[Gap 11]** Add interactive / TTY support for `sandbox run`. + +--- + +## 12. Appendix: Code Reference Map + +| Symbol | File | Line | +|--------|------|------| +| `SandboxProvider` trait | `crates/root-sandbox/src/lib.rs` | 34–40 | +| `RealSandboxProvider` struct | `crates/root-sandbox/src/lib.rs` | 42–43 | +| `RealSandboxProvider::create()` | `crates/root-sandbox/src/lib.rs` | 79–106 | +| `RealSandboxProvider::run_command()` | `crates/root-sandbox/src/lib.rs` | 108–122 | +| `RealSandboxProvider::list()` | `crates/root-sandbox/src/lib.rs` | 124–158 | +| `RealSandboxProvider::destroy()` | `crates/root-sandbox/src/lib.rs` | 160–170 | +| `RealSandboxProvider::run_docker()` | `crates/root-sandbox/src/lib.rs` | 49–62 | +| `MockSandboxProvider` struct | `crates/root-sandbox/src/lib.rs` | 173–176 | +| `SandboxError` enum | `crates/root-sandbox/src/lib.rs` | 6–16 | +| `SandboxInstance` struct | `crates/root-sandbox/src/lib.rs` | 18–25 | +| `SandboxExecResult` struct | `crates/root-sandbox/src/lib.rs` | 27–32 | +| `SandboxCreateReport` struct | `crates/root-core/src/lib.rs` | 2255–2262 | +| `SandboxRunReport` struct | `crates/root-core/src/lib.rs` | 2265–2272 | +| `SandboxListReport` struct | `crates/root-core/src/lib.rs` | 2274–2278 | +| `SandboxDestroyReport` struct | `crates/root-core/src/lib.rs` | 2281–2284 | +| `sandbox_create()` | `crates/root-core/src/lib.rs` | 2846–2890 | +| `sandbox_run()` | `crates/root-core/src/lib.rs` | 2892–2932 | +| `sandbox_list()` | `crates/root-core/src/lib.rs` | 2934–2943 | +| `sandbox_destroy()` | `crates/root-core/src/lib.rs` | 2945–2967 | +| `SandboxPolicy` struct | `crates/root-core/src/policy.rs` | 73–90 | +| `PolicyAction::SandboxCreate` | `crates/root-core/src/policy.rs` | 167 | +| `PolicyAction::SandboxRun` | `crates/root-core/src/policy.rs` | 168 | +| `PolicyAction::SandboxDestroy` | `crates/root-core/src/policy.rs` | 169 | +| `enforce_policy()` | `crates/root-core/src/lib.rs` | 830–855 | +| `SandboxSubcommands` enum | `crates/root-cli/src/main.rs` | 142–168 | +| `sandbox_provider` instantiation | `crates/root-cli/src/main.rs` | 317 | +| `handle_structured()` | `crates/root-cli/src/main.rs` | 282–306 | +| `exit_code_for_error()` | `crates/root-cli/src/main.rs` | 194–234 | +| `RootEventType::Sandbox` | `crates/root-core/src/events.rs` | 20 | +| `record_event()` | `crates/root-core/src/events.rs` | 213–233 | +| `record_execution_event()` | `crates/root-core/src/events.rs` | 170–191 | diff --git a/Docs/Sandbox/V0_2_3_SANDBOX_NOTES.md b/Docs/Sandbox/V0_2_3_SANDBOX_NOTES.md new file mode 100644 index 0000000..4a7e04e --- /dev/null +++ b/Docs/Sandbox/V0_2_3_SANDBOX_NOTES.md @@ -0,0 +1,616 @@ +# Root v0.2.3 Sandbox Notes + +Reference document for Root's Docker-backed sandbox subsystem: lifecycle, +cleanup guarantees, resource limits, timeout handling, failure recovery, +Docker requirements, event recording, validation, and error messages. + +--- + +## Table of Contents + +1. [Lifecycle Model](#1-lifecycle-model) +2. [Cleanup Guarantees](#2-cleanup-guarantees) +3. [Resource Limits](#3-resource-limits) +4. [Timeout Behavior](#4-timeout-behavior) +5. [Failure Recovery](#5-failure-recovery) +6. [Docker Requirements](#6-docker-requirements) +7. [Event Recording](#7-event-recording) +8. [Validation](#8-validation) +9. [Error Messages](#9-error-messages) +10. [Implementation Reference](#10-implementation-reference) + +--- + +## 1. Lifecycle Model + +Sandboxes follow a strict state machine defined by the `SandboxState` enum. +Each state transition is validated before execution. Invalid transitions are +rejected with a `LifecycleViolation` error. + +### States + +| State | Meaning | +|-------------|------------------------------------------------------------| +| `Created` | Sandbox instance has been created but no command has run. | +| `Running` | A command is currently executing (or has been executed). | +| `Completed` | The sandbox ran to completion (container exited normally). | +| `Failed` | The sandbox command failed (non-zero exit or error). | +| `Destroyed` | The sandbox has been destroyed and is no longer usable. | + +### Valid Transitions + +``` +Created ──► Running +Created ──► Completed +Created ──► Failed +Created ──► Destroyed + +Running ──► Completed +Running ──► Failed +Running ──► Running (self-transition is allowed) + +Completed ──► Destroyed + +Failed ──► Destroyed +``` + +### Invalid Transitions (Examples) + +| Attempted Transition | Error | +|-------------------------------|--------------------------------------------------------| +| Destroyed → Running | `LifecycleViolation("Invalid state transition: Destroyed -> Running")` | +| Destroyed → Failed | `LifecycleViolation("Invalid state transition: Destroyed -> Failed")` | +| Destroyed → Completed | `LifecycleViolation("Invalid state transition: Destroyed -> Completed")` | +| Destroyed → Destroyed (repeat)| `LifecycleViolation("Sandbox '...' is already destroyed")` (Real provider) or `NotFound` (Mock provider) | + +> **Note:** The `can_transition_to` method in `SandboxState` explicitly returns +> `false` for all transitions departing from `Destroyed` (except self-transition +> for `Destroyed` itself in the real provider, but destroy is idempotent at the +> API level). + +### Enforcement Points + +- **`MockSandboxProvider`** — `validate_transition` is called before every state + change in `run_command` and `destroy`. Returns `LifecycleViolation` for + invalid transitions. +- **`RealSandboxProvider`** — `run_command` checks the in-memory state map and + rejects commands on destroyed sandboxes. `destroy` checks for already-destroyed + state and returns a `LifecycleViolation` if the container is already gone. +- **CLI (`root sandbox run`)** — passes through the provider's error to the user. + +--- + +## 2. Cleanup Guarantees + +Root guarantees that cleanup is attempted in every scenario where a sandbox +can leave an orphaned container. The cleanup mechanisms differ between the +mock provider (which tracks state in-memory) and the real provider (which +manages Docker containers). + +### On Destroy (Explicit) + +- **Real provider**: `docker rm -f ` is called. If the container is not + Root-owned (name does not start with `root-sandbox-`), the operation is + rejected with `NotRootOwned`. If the container does not exist, returns + `NotFound`. +- **Mock provider**: The sandbox is removed from the in-memory list and its + state is set to `Destroyed`. A cleanup counter is incremented. +- **Both**: State is set to `Destroyed` regardless of cleanup outcome. + +### On Failed Run + +- **Real provider**: When a run command returns a non-zero exit code (and + the exit code is not 124/timeout), `provider.destroy(id)` is called from + `root-core::sandbox_run`. +- **Mock provider**: Destroy is called, which sets state to `Destroyed` and + removes the sandbox from the list. +- A cleanup event is recorded in the event ledger. + +### On Timeout + +- **Behavior is identical to failed run**: `provider.destroy(id)` is called + from `root-core::sandbox_run` when `result.exit_code == 124`. +- A cleanup event with the message "Cleanup attempted after timeout for sandbox + '...'" is recorded. + +### On Validation Failure (Post-Create) + +- If a sandbox is created but post-create validation (exists + reachable) fails, + `provider.destroy(&instance.id)` is called before returning the error. +- The user receives an error explaining that the sandbox was destroyed due to + validation failure. + +### On Stale Detection + +- **`root sandbox list`** queries `docker ps -a --filter name=root-sandbox-` and + reports all matching containers, including stopped ones. This allows users to + discover containers that were left behind (e.g., due to a crash). +- The mock provider tracks all created sandboxes in an in-memory list that can + be inspected via `list()`. + +### Cleanup Guarantee Summary + +| Scenario | Cleanup Attempted | Recorded in Events | +|---------------------------------------------|-------------------|--------------------| +| Explicit `root sandbox destroy ` | Yes | Yes | +| Run command fails (non-zero exit) | Yes | Yes (cleanup + run) | +| Run command times out (exit 124) | Yes | Yes (cleanup + timeout) | +| Post-create validation fails | Yes | No (not persisted) | +| Provider unavailable during destroy | Depends* | Yes (failure event) | +| Root crashes mid-operation | No (manual) | No | + +> \* The real provider attempts `docker rm -f ` as a final fallback even +> after a failed destroy attempt. + +--- + +## 3. Resource Limits + +Sandbox containers can be created with configurable memory and CPU limits. +These are passed through to Docker's `--memory` and `--cpus` flags. + +### Default Values + +| Resource | Default | Flag | +|----------|---------|--------------------| +| Memory | `2g` | `--memory ` | +| CPUs | `2.0` | `--cpus ` | + +### CLI Usage + +```bash +# Create with custom limits +root sandbox create my-sandbox --memory 4g --cpus 4.0 + +# Create with defaults +root sandbox create my-sandbox + +# Create with partial overrides +root sandbox create my-sandbox --memory 512m +root sandbox create my-sandbox --cpus 1 +``` + +### How Limits Are Applied + +1. The `SandboxProvider::create()` trait method accepts `memory: Option<&str>` + and `cpus: Option<&str>`. +2. `RealSandboxProvider::create()` passes these to Docker: + ``` + docker run -d --name root-sandbox- --memory --cpus sleep infinity + ``` +3. If `memory` is `None`, the default `"2g"` is used. If `cpus` is `None`, the + default `"2.0"` is used. +4. The `SandboxInstance` struct stores `memory` and `cpus` as `Option` + and serializes them into JSON output (`root sandbox list --json`). + +### Docker Resource Flag Reference + +| Flag | Accepts | Example | +|-------------|-----------------------|----------------| +| `--memory` | Number + unit (b, k, m, g) | `512m`, `2g` | +| `--cpus` | Decimal number | `1.0`, `2.5` | + +### Resource Limit Errors + +If Docker rejects the resource limits (e.g., too much memory requested, invalid +format, or OOM occurs), the error is normalized into `ResourceLimitExceeded`: + +``` +Error: Resource limit exceeded: +``` + +--- + +## 4. Timeout Behavior + +Every sandbox run command has a configurable timeout that controls how long the +command is allowed to execute before being killed. + +### Default + +- **Default timeout**: 300 seconds (5 minutes) +- Applied when no `--timeout` flag is provided. + +### How It Works + +1. The `root sandbox run` command accepts `--timeout `. +2. In `RealSandboxProvider::run_command()`: + - If `timeout > 0`, the Docker exec wraps the command with `timeout `: + ``` + docker exec timeout + ``` + - If `timeout` is `None`, the default `300` is used. + - If `timeout` is `Some(0)`, no timeout is applied (command runs without + Docker's `timeout` wrapper). +3. Docker's `timeout` utility sends SIGTERM after the specified seconds, then + SIGKILL if the process does not stop. +4. The process exit code is captured. If the exit code is `124` (the standard + timeout exit code), the sandbox run report marks `timed_out: true`. + +### What Happens on Timeout + +1. Command is killed by Docker's `timeout` utility. +2. Exit code 124 is returned. +3. `root-core::sandbox_run` detects `exit_code == 124`. +4. A cleanup is triggered: `provider.destroy(id)` is called. +5. Two events are recorded: + - **Timeout event**: status `Timeout` with message "Command timed out in + sandbox '...' after ms" + - **Cleanup event**: status `Completed` with message "Cleanup attempted after + timeout for sandbox '...'" +6. The `SandboxRunReport` includes: + - `timed_out: true` + - `cleanup_attempted: true` + - `exit_code: 124` + - `stderr` includes "Command timed out after seconds" + +### Output + +```bash +$ root sandbox run my-sandbox --timeout 5 -- sleep 30 +Error: Command timed out in sandbox 'my-sandbox' after 5003ms +``` + +### Mock Provider Simulation + +The mock provider has a `simulate_timeout` flag. When set to `true`, +`run_command` returns `SandboxExecResult` with `exit_code: 124` and +`stderr: "Command timed out"`. + +--- + +## 5. Failure Recovery + +### When a Sandbox Is Stuck + +A sandbox may become stuck or unusable in the following scenarios: + +| Scenario | Symptoms | +|----------------------------------------|--------------------------------------------| +| Docker daemon crash | `docker exec` fails; sandbox unreachable | +| Container entered bad state | `docker exec` hangs or returns errors | +| Root crashed mid-operation | Container may be orphaned | +| Resource exhaustion (OOM) | Container killed by Docker | + +### Recovery Steps + +1. **Detect stale sandboxes**: + ```bash + root sandbox list + ``` + This shows all Root-managed containers (both running and stopped). + +2. **Inspect a specific sandbox**: + ```bash + docker inspect root-sandbox- + ``` + +3. **Destroy a stuck sandbox**: + ```bash + root sandbox destroy + ``` + If the CLI fails, use Docker directly: + ```bash + docker rm -f root-sandbox- + ``` + +4. **Force cleanup all Root sandboxes**: + ```bash + docker rm -f $(docker ps -aq --filter name=root-sandbox-) 2>/dev/null || true + ``` + +### Detecting Stale Sandboxes + +- `root sandbox list --json` outputs all known sandboxes with their state. + Sandboxes in `Completed`, `Failed`, or unknown states that are no longer + tracked by Root's in-memory state may be stale. +- The real provider lists all Docker containers matching the `root-sandbox-` + prefix, so any orphaned container is discoverable. +- There is no background daemon or periodic stale-checker. Detection is + on-demand via `list`. + +### Preventing Stale Sandboxes + +- All failure paths in `root-core::sandbox_run` (non-zero exit, timeout) + trigger automatic cleanup (destroy). +- Post-create validation failure destroys the container immediately. +- Destroy always attempts `docker rm -f` — even on error, an additional + `docker rm -f` is attempted. + +--- + +## 6. Docker Requirements + +Root uses Docker to create and manage sandbox containers. It shells out to the +`docker` CLI binary. + +### Requirements + +| Requirement | Detail | +|--------------------|-----------------------------------------------------------| +| Docker CLI | `docker` must be on `PATH` | +| Docker Daemon | Must be running (`docker info` must succeed) | +| Platform | macOS (Apple Silicon + Intel) and Linux | +| Min. Docker CLI | No specific minimum — any version with `run`, `exec`, `ps`, `rm`, `inspect` | +| Image | Default `ubuntu:latest` (pulled from Docker Hub if absent)| + +### How Availability Is Checked + +`RealSandboxProvider::check_availability()` runs: +```bash +docker info +``` +Returns `true` if the command exits with status 0, `false` otherwise. + +### What Happens When Docker Is Unavailable + +If `check_availability()` returns `false`, the CLI returns: + +``` +Error: No sandbox provider is available. + +Root requires Docker to create sandboxes. +Install Docker Desktop from https://docker.com +Then verify with: docker info +``` + +Exit code: 1 + +### Platform Notes + +- **macOS (Apple Silicon)**: Docker Desktop for Apple Silicon is required. + Ensure Rosetta 2 is installed if running x86_64 images. +- **macOS (Intel)**: Docker Desktop for Mac (Intel) works with default settings. +- **Linux**: Docker Engine (CE) is sufficient. `docker` must be on `PATH` and + the user must have permissions (typically via the `docker` group or `sudo`). + +### Container Tagging Convention + +All Root-managed containers are created with the naming convention: +``` +root-sandbox- +``` + +This prefix is used to: +- Filter containers in `root sandbox list` (`--filter name=root-sandbox-`) +- Validate ownership in `root sandbox destroy` (rejects non-`root-sandbox-` containers) +- Identify orphaned containers during manual cleanup + +--- + +## 7. Event Recording + +Every sandbox operation is recorded in the event ledger at +`~/.root/events.jsonl`. The event schema is defined in `root-core::events`. + +### Event Fields + +| Field | Type | Description | +|--------------------|----------|------------------------------------------------| +| `event_type` | String | Always `"Sandbox"` for sandbox operations | +| `status` | String | `Completed`, `Failed`, or `Timeout` | +| `command` | String | CLI command that triggered the event | +| `timestamp` | String | ISO 8601 / RFC 3339 timestamp | +| `sandbox_id` | String | The sandbox ID (container ID or mock ID) | +| `exit_code` | Integer | Exit code of the sandbox command (run only) | +| `details` | String | Human-readable description | +| `started_at` | String | ISO 8601 start time (run only) | +| `finished_at` | String | ISO 8601 finish time (run only) | +| `duration_ms` | Integer | Duration in milliseconds (run only) | + +### Events Recorded Per Operation + +| CLI Command | Event Type | Status | Details | +|--------------------------------------|------------|------------|----------------------------------------| +| `root sandbox create` | Sandbox | Completed | "Created sandbox 'root-sandbox-...' (id: ...)" | +| `root sandbox run` (success) | Sandbox | Completed | "Executed in sandbox '...': exit code 0" | +| `root sandbox run` (failure) | Sandbox | Failed | "Executed in sandbox '...': exit code " | +| `root sandbox run` (timeout) | Sandbox | Timeout | "Command timed out in sandbox '...' after ms" | +| `root sandbox run` → cleanup | Sandbox | Completed | "Cleanup attempted after timeout for sandbox '...'" | +| `root sandbox run` → cleanup (fail) | Sandbox | Completed | "Cleanup attempted after failed run for sandbox '...'" | +| `root sandbox destroy` (success) | Sandbox | Completed | "Destroyed sandbox '...'" | +| `root sandbox destroy` (failure) | Sandbox | Failed | "Sandbox destroy failed: " | +| `root sandbox destroy` (post-check warning) | Sandbox | Failed | "Sandbox '...' may still exist after destroy attempt" | + +### Viewing Events + +```bash +# View all events (human-readable) +root history + +# View last N events +root history --limit 10 + +# View events as JSON +root history --json +``` + +Events are appended to `events.jsonl` in append-only mode. Each line is a +JSON object. Malformed lines are gracefully skipped during read (see v0.2.1). + +### Event Flow for a Typical Sandbox Lifecycle + +``` +create ─► event(Completed, "Created sandbox 'root-sandbox-demo' (id: abc123)") +run ─► event(Completed, "Executed in sandbox 'abc123': exit code 0") +destroy ─► event(Completed, "Destroyed sandbox 'abc123'") +``` + +### Event Flow for a Timeout + +``` +create ─► event(Completed, "Created sandbox 'root-sandbox-demo' (id: abc123)") +run ─► event(Timeout, "Command timed out in sandbox 'abc123' after 5003ms") + ─► event(Completed, "Cleanup attempted after timeout for sandbox 'abc123'") +``` + +--- + +## 8. Validation + +Root performs validation at two points in the sandbox lifecycle: immediately +after creation and immediately after destruction. + +### Post-Create Validation + +After `provider.create()` returns successfully, `root-core::sandbox_create` +runs two checks: + +1. **`provider.check_exists(id)`** — Verifies the container exists in Docker. + - Real provider: runs `docker inspect --format '{{.Id}}' ` + - Returns `true` if the inspect succeeds. +2. **`provider.check_reachable(id)`** — Verifies the container is reachable. + - Real provider: runs `docker exec echo reachable` + - Returns `true` if the exec succeeds. + +If either check fails, the sandbox is immediately destroyed and an error is +returned: + +``` +Error: Sandbox '' was created but post-create validation failed +(exists: false, reachable: true). The sandbox has been destroyed. +``` + +### Post-Destroy Validation + +After `provider.destroy()` completes (success or failure), +`root-core::sandbox_destroy` runs: + +1. **`provider.check_exists(id)`** — Checks if the container still exists. + - If `true`: a warning event is recorded ("Sandbox '...' may still exist + after destroy attempt") and merged into the error message if destroy + also failed. + +### Validation Method Signatures + +```rust +fn check_exists(&self, id: &str) -> Result; +fn check_reachable(&self, id: &str) -> Result; +``` + +### Mock Provider Validation Behavior + +- `check_exists`: Returns `true` if the sandbox is tracked and not destroyed. +- `check_reachable`: Returns `true` if the sandbox is in `Running` or `Created` + state. + +--- + +## 9. Error Messages + +All sandbox errors are defined in the `SandboxError` enum and normalized into +user-friendly messages before reaching the CLI. + +### Error Catalog + +| Error Variant | Trigger | User-Facing Message Pattern | Exit Code | +|--------------------------|---------------------------------------------|-------------------------------------------|-----------| +| `NotAvailable` | Docker not on PATH / daemon not running | "No sandbox provider is available. ..." | 1 | +| `NotFound` | Sandbox ID does not exist | "Sandbox '' not found" | 3 | +| `NotRootOwned` | Destroy attempted on non-Root container | "Container '' is not a Root-managed sandbox" | 1 | +| `DockerUnavailable` | `docker` binary not found | "Docker is not available on PATH. Install Docker Desktop or the Docker CLI." | 7 | +| `ImagePullFailed` | Docker image cannot be pulled/found | "Failed to pull/start image '':
" | 1 | +| `ContainerStartupFailed` | Container fails to start | "Container failed to start:
" | 1 | +| `TimeoutExceeded` | Command exceeded timeout | "Sandbox command timed out after seconds" | 1 | +| `ResourceLimitExceeded` | OOM or invalid resource spec | "Resource limit exceeded:
" | 1 | +| `PermissionDenied` | Docker permission error | "Permission denied:
" | 1 | +| `CleanupFailed` | Docker `rm -f` fails | "Cleanup failed:
" | 1 | +| `LifecycleViolation` | Invalid state transition attempted | "Invalid state transition: -> for sandbox ''" | 6 | +| `Generic` | Unclassified Docker error | "Sandbox operation failed:
" | 1 | + +### Error Normalization Flow + +Docker stderr is normalized by `normalize_docker_error()` in +`RealSandboxProvider`: + +``` +Docker stderr + │ + ▼ +normalize_docker_error(stderr) + │ + ├── "permission denied" or "permission_denied" ──► PermissionDenied + ├── "image" + ("pull" or "not found") ──► ImagePullFailed + ├── "oom" or "memory" or "cpuset" ──► ResourceLimitExceeded + └── otherwise ──► Generic +``` + +Additional normalization in `RealSandboxProvider::create()` converts +`Generic` errors containing "image" or "pull" into `ImagePullFailed`, +and errors containing "cannot start" or "startup" into +`ContainerStartupFailed`. + +### Error Examples + +```bash +# Docker not available +$ root sandbox create +Error: No sandbox provider is available. + +Root requires Docker to create sandboxes. +Install Docker Desktop from https://docker.com +Then verify with: docker info + +# Sandbox not found +$ root sandbox destroy nonexistent +Error: Sandbox destroy failed: Sandbox 'nonexistent' not found + +# Invalid lifecycle (run destroyed sandbox) +$ root sandbox run destroyed-sb -- echo hi +Error: Sandbox exec failed: Invalid state transition: Destroyed -> Running for sandbox 'abc123' + +# Timeout +$ root sandbox run my-sb --timeout 3 -- sleep 30 +Error: Command timed out in sandbox 'my-sb' after 3002ms + +# Not a Root-managed container +$ root sandbox destroy my-own-container +Error: Sandbox destroy failed: Container 'my-own-container' is not a Root-managed sandbox +``` + +--- + +## 10. Implementation Reference + +### Key Files + +| File | Role | +|------|------| +| `crates/root-sandbox/src/lib.rs` | `SandboxState`, `SandboxError`, `SandboxInstance`, `SandboxProvider` trait, `RealSandboxProvider`, `MockSandboxProvider`, unit tests | +| `crates/root-core/src/lib.rs` (lines 2857–3067) | `sandbox_create`, `sandbox_run`, `sandbox_list`, `sandbox_destroy` orchestration | +| `crates/root-core/src/events.rs` | Event recording for sandbox operations | +| `crates/root-core/src/policy.rs` | Policy enforcement for sandbox actions | +| `crates/root-cli/src/main.rs` (lines 996–1081) | CLI `sandbox` subcommand parsing and dispatch | + +### Trait Reference + +```rust +pub trait SandboxProvider { + fn check_availability(&self) -> Result; + fn create(&self, name: &str, image: Option<&str>, + memory: Option<&str>, cpus: Option<&str>) + -> Result; + fn run_command(&self, id: &str, command: &[&str], + timeout_secs: Option) + -> Result; + fn list(&self) -> Result, SandboxError>; + fn destroy(&self, id: &str) -> Result<(), SandboxError>; + fn check_exists(&self, id: &str) -> Result; + fn check_reachable(&self, id: &str) -> Result; +} +``` + +### Implementations + +- **`RealSandboxProvider`**: Shells out to the `docker` CLI. Maintains an + in-memory `HashMap` to track lifecycle state. +- **`MockSandboxProvider`**: In-memory provider used for unit tests. + Supports simulation flags (`simulate_timeout`, `simulate_cleanup_failure`, + `simulate_destroy_unavailable`). + +### Related Documents + +- `Docs/Release/V0_2_3_SANDBOX_SMOKE_TEST.md` — Manual smoke tests for v0.2.3 + sandbox subsystem +- `Docs/Core/` — Full design specification chain (PRD, TECH_SPEC, ARCHITECTURE, + UX_FLOWS, IMPLEMENTATION_PLAN) diff --git a/crates/root-cli/src/main.rs b/crates/root-cli/src/main.rs index 14289b2..428e3ed 100644 --- a/crates/root-cli/src/main.rs +++ b/crates/root-cli/src/main.rs @@ -27,6 +27,9 @@ enum Commands { /// Install Nix automatically if not detected #[arg(long)] install_nix: bool, + /// Skip confirmation prompts (use with --install-nix) + #[arg(long)] + yes: bool, }, /// Show the curated package catalog Catalog, @@ -145,12 +148,21 @@ enum SandboxSubcommands { /// Container image to use (default: ubuntu:latest) #[arg(long, value_name = "IMAGE")] image: Option, + /// Memory limit (default: 2g) + #[arg(long, value_name = "MEMORY")] + memory: Option, + /// CPU limit (default: 2.0) + #[arg(long, value_name = "CPUS")] + cpus: Option, }, /// Run a command inside a sandbox Run { /// Sandbox ID or name #[arg(value_name = "ID")] id: String, + /// Timeout in seconds (default: 300) + #[arg(long, value_name = "SECONDS")] + timeout: Option, #[arg(last = true, num_args = 1.., value_name = "COMMAND")] command: Vec, }, @@ -168,19 +180,41 @@ enum SandboxSubcommands { struct GenericOutput { success: bool, message: String, + #[serde(skip_serializing_if = "Option::is_none")] + raw_stderr: Option, } fn print_json(output: &T) { println!("{}", serde_json::to_string_pretty(output).unwrap()); } +fn json_error_output(e: &anyhow::Error) -> GenericOutput { + let raw_stderr = e + .downcast_ref::() + .and_then(|ne| ne.raw_stderr()) + .map(|s| s.to_string()); + GenericOutput { + success: false, + message: format!("{}", e), + raw_stderr, + } +} + fn exit_code_for_error(e: &anyhow::Error) -> i32 { if let Some(nix_err) = e.downcast_ref::() { return match nix_err { root_nix::NixError::NotInstalled => 7, - root_nix::NixError::NotFound(_) => 3, + root_nix::NixError::NotFound(_) | root_nix::NixError::AttributeMissing(_) => 3, root_nix::NixError::PlatformMissing(_) => 8, - root_nix::NixError::Generic(_) => 1, + root_nix::NixError::Generic(_) | root_nix::NixError::Internal(_) => 1, + root_nix::NixError::FlakesDisabled + | root_nix::NixError::NixCommandDisabled + | root_nix::NixError::NixpkgsUnavailable + | root_nix::NixError::ProfileLocked + | root_nix::NixError::ProfileSymlinkConflict + | root_nix::NixError::PermissionDenied + | root_nix::NixError::StorePathNotRealized(_) + | root_nix::NixError::DerivationPathAsOutput(_) => 1, }; } let msg = format!("{:?}", e); @@ -210,36 +244,7 @@ fn exit_code_for_error(e: &anyhow::Error) -> i32 { fn format_user_error(e: &anyhow::Error) -> String { if let Some(nix_err) = e.downcast_ref::() { - return match nix_err { - root_nix::NixError::NotInstalled => { - "Nix is not installed or not available on PATH.\n\n\ - To install Nix, run: root init --install-nix\n\ - Or visit: https://nixos.org/download/" - .to_string() - } - root_nix::NixError::NotFound(pkg) => { - format!( - "Package '{}' was not found in nixpkgs.\n\n\ - This may mean the package name is incorrect or the attribute\n\ - does not exist on your platform.", - pkg - ) - } - root_nix::NixError::PlatformMissing(pkg) => { - format!( - "Package '{}' is not available for your platform.\n\n\ - Try a different package or check nixpkgs for alternatives:\n nix search nixpkgs {}", - pkg, pkg - ) - } - root_nix::NixError::Generic(msg) => { - if msg.contains("error:") || msg.contains("warning:") || msg.contains("access") { - format!("Nix operation failed.\n\nDetails: {}", msg) - } else { - format!("Nix operation failed: {}", msg) - } - } - }; + return format!("{}", nix_err); } let msg = format!("{}", e); if msg.contains("Policy denied") { @@ -300,10 +305,7 @@ fn handle_structured( Err(e) => { let code = exit_code_for_error(&e); if json { - print_json(&GenericOutput { - success: false, - message: format!("{}", e), - }); + print_json(&json_error_output(&e)); } else { eprintln!("Error: {}", format_user_error(&e)); } @@ -324,7 +326,7 @@ fn main() { let sandbox_provider = RealSandboxProvider::new(); match cli.command { - Commands::Init { install_nix } => { + Commands::Init { install_nix, yes } => { let report = handle_structured(cli.json, root_core::init(&adapter), |r| { let mut msg = String::from("Root initialized.\n"); msg.push_str(&format!("\n✓ Root directory created at {}", r.root_dir)); @@ -359,7 +361,7 @@ fn main() { if !cli.json { println!("Installing Nix..."); } - match root_core::install_nix() { + match root_core::install_nix(yes) { Ok(()) => { report.nix_detected = true; if !cli.json { @@ -372,6 +374,7 @@ fn main() { print_json(&GenericOutput { success: false, message: format!("Failed to install Nix: {:?}", e), + raw_stderr: None, }); } else { eprintln!("Error installing Nix: {}", e); @@ -528,10 +531,7 @@ fn main() { Err(e) => { let code = exit_code_for_error(&e); if cli.json { - print_json(&GenericOutput { - success: false, - message: format!("{:?}", e), - }); + print_json(&json_error_output(&e)); } else { eprintln!("Error: {}", format_user_error(&e)); } @@ -634,10 +634,7 @@ fn main() { Err(e) => { let code = exit_code_for_error(&e); if cli.json { - print_json(&GenericOutput { - success: false, - message: format!("{:?}", e), - }); + print_json(&json_error_output(&e)); } else { eprintln!("Error: {}", format_user_error(&e)); } @@ -661,6 +658,7 @@ fn main() { print_json(&GenericOutput { success: false, message: "Currently only `root rollback --last` is supported".into(), + raw_stderr: None, }); } else { eprintln!("Error: Currently only `root rollback --last` is supported"); @@ -711,10 +709,7 @@ fn main() { Err(e) => { let code = exit_code_for_error(&e); if cli.json { - print_json(&GenericOutput { - success: false, - message: format!("{:?}", e), - }); + print_json(&json_error_output(&e)); } else { eprintln!("Error running doctor: {}", format_user_error(&e)); } @@ -774,10 +769,7 @@ fn main() { Err(e) => { let code = exit_code_for_error(&e); if cli.json { - print_json(&GenericOutput { - success: false, - message: format!("{:?}", e), - }); + print_json(&json_error_output(&e)); } else { eprintln!("Error running verification: {}", format_user_error(&e)); } @@ -791,6 +783,7 @@ fn main() { print_json(&GenericOutput { success: false, message: format!("Unsupported import source: {}", source), + raw_stderr: None, }); } else { eprintln!("Error: Only 'brew' is supported as an import source currently."); @@ -833,10 +826,7 @@ fn main() { Err(e) => { let code = exit_code_for_error(&e); if cli.json { - print_json(&GenericOutput { - success: false, - message: format!("{:?}", e), - }); + print_json(&json_error_output(&e)); } else { eprintln!("Error running brew import: {}", format_user_error(&e)); } @@ -1004,26 +994,48 @@ fn main() { }); } Commands::Sandbox { subcommand } => match subcommand { - SandboxSubcommands::Create { name, image } => { + SandboxSubcommands::Create { + name, + image, + memory, + cpus, + } => { let _ = handle_structured( cli.json, - root_core::sandbox_create(&sandbox_provider, name.as_deref(), image.as_deref()), + root_core::sandbox_create( + &sandbox_provider, + name.as_deref(), + image.as_deref(), + memory.as_deref(), + cpus.as_deref(), + ), |r| { - format!( - "Created sandbox '{}' (id: {})\n Image: {}\n Status: {}", - r.name, r.id, r.image, r.status - ) + let mut msg = format!( + "Created sandbox '{}' (id: {})\n Image: {}\n State: {}", + r.name, r.id, r.image, r.state + ); + if let Some(ref mem) = r.memory { + msg.push_str(&format!("\n Memory: {}", mem)); + } + if let Some(ref cpus) = r.cpus { + msg.push_str(&format!("\n CPUs: {}", cpus)); + } + msg }, ); } - SandboxSubcommands::Run { id, command } => { + SandboxSubcommands::Run { + id, + timeout, + command, + } => { let cmd_strings: Vec = command .iter() .map(|arg| arg.to_string_lossy().to_string()) .collect(); if let Some(report) = handle_structured( cli.json, - root_core::sandbox_run(&sandbox_provider, &id, &cmd_strings), + root_core::sandbox_run(&sandbox_provider, &id, &cmd_strings, timeout), |r| { let mut output = String::new(); if !r.stdout.is_empty() { @@ -1038,7 +1050,17 @@ fn main() { output.push('\n'); } } - output.push_str(&format!("Command exited with code {}.", r.exit_code)); + if r.timed_out == Some(true) { + output.push_str(&format!( + "Command timed out (exit code {}).", + r.exit_code + )); + } else { + output.push_str(&format!("Command exited with code {}.", r.exit_code)); + } + if r.cleanup_attempted == Some(true) { + output.push_str(" Cleanup was attempted."); + } output }, ) { @@ -1056,8 +1078,8 @@ fn main() { let mut msg = format!("Root sandboxes ({}):\n", r.sandboxes.len()); for sb in &r.sandboxes { msg.push_str(&format!( - " {} (id: {}) [{}] image: {}\n", - sb.name, sb.id, sb.status, sb.image + " {} (id: {}) [{:?}] image: {}\n", + sb.name, sb.id, sb.state, sb.image )); } msg @@ -1146,7 +1168,7 @@ mod tests { let sb_create = Cli::try_parse_from(["root", "sandbox", "create", "test-sb"]).unwrap(); match sb_create.command { Commands::Sandbox { - subcommand: SandboxSubcommands::Create { name, image }, + subcommand: SandboxSubcommands::Create { name, image, .. }, } => { assert_eq!(name.as_deref(), Some("test-sb")); assert!(image.is_none()); @@ -1154,11 +1176,40 @@ mod tests { other => panic!("expected sandbox create, got {:?}", other), } + let sb_create_with_resources = Cli::try_parse_from([ + "root", + "sandbox", + "create", + "test-sb-2", + "--memory", + "4g", + "--cpus", + "4.0", + ]) + .unwrap(); + match sb_create_with_resources.command { + Commands::Sandbox { + subcommand: + SandboxSubcommands::Create { + name, + image, + memory, + cpus, + }, + } => { + assert_eq!(name.as_deref(), Some("test-sb-2")); + assert!(image.is_none()); + assert_eq!(memory.as_deref(), Some("4g")); + assert_eq!(cpus.as_deref(), Some("4.0")); + } + other => panic!("expected sandbox create with resources, got {:?}", other), + } + let sb_run = Cli::try_parse_from(["root", "sandbox", "run", "my-sb", "--", "echo", "hi"]).unwrap(); match sb_run.command { Commands::Sandbox { - subcommand: SandboxSubcommands::Run { id, command }, + subcommand: SandboxSubcommands::Run { id, command, .. }, } => { assert_eq!(id, "my-sb"); assert_eq!(command.len(), 2); @@ -1166,6 +1217,34 @@ mod tests { other => panic!("expected sandbox run, got {:?}", other), } + let sb_run_with_timeout = Cli::try_parse_from([ + "root", + "sandbox", + "run", + "my-sb", + "--timeout", + "30", + "--", + "sleep", + "10", + ]) + .unwrap(); + match sb_run_with_timeout.command { + Commands::Sandbox { + subcommand: + SandboxSubcommands::Run { + id, + timeout, + command, + }, + } => { + assert_eq!(id, "my-sb"); + assert_eq!(timeout, Some(30)); + assert_eq!(command.len(), 2); + } + other => panic!("expected sandbox run with timeout, got {:?}", other), + } + let sb_list = Cli::try_parse_from(["root", "sandbox", "list"]).unwrap(); match sb_list.command { Commands::Sandbox { diff --git a/crates/root-core/src/events.rs b/crates/root-core/src/events.rs index 3a9755f..2682c3b 100644 --- a/crates/root-core/src/events.rs +++ b/crates/root-core/src/events.rs @@ -26,6 +26,7 @@ pub enum RootEventStatus { Completed, Failed, Verified, + Timeout, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -51,6 +52,8 @@ pub struct RootEvent { pub duration_ms: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub policy_decision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -71,7 +74,7 @@ fn generate_event_id() -> String { format!("evt_{}_{:06}", datetime, micros) } -fn now_iso() -> String { +pub fn now_iso_for_event() -> String { use std::time::SystemTime; let now = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) @@ -141,7 +144,7 @@ pub fn create_event( ) -> RootEvent { RootEvent { id: generate_event_id(), - timestamp: now_iso(), + timestamp: now_iso_for_event(), event_type, command: command.to_string(), status, @@ -155,6 +158,7 @@ pub fn create_event( finished_at: None, duration_ms: None, policy_decision: None, + sandbox_id: None, } } diff --git a/crates/root-core/src/lib.rs b/crates/root-core/src/lib.rs index 0e5b733..10cdaa3 100644 --- a/crates/root-core/src/lib.rs +++ b/crates/root-core/src/lib.rs @@ -10,7 +10,7 @@ use root_sandbox::SandboxProvider; use root_snapshot::{list_snapshot_summaries, list_snapshots, Snapshot, SnapshotSummary}; use serde::Serialize; use std::collections::BTreeMap; -use std::io::{Read, Write}; +use std::io::{IsTerminal, Read, Write}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -859,21 +859,185 @@ pub(crate) fn enforce_policy(action: policy::PolicyAction, subject: Option<&str> } } +/// Platform detected for Nix automatic installation. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum NixInstallPlatform { + MacOs, + Linux, +} + +fn detect_nix_install_platform_inner(os: &str) -> Result { + match os { + "macos" => Ok(NixInstallPlatform::MacOs), + "linux" => Ok(NixInstallPlatform::Linux), + other => Err(anyhow::anyhow!( + "Unsupported platform: '{}'. Nix installation via `root init --install-nix` \ + is only supported on macOS and Linux.\n\n\ + Please install Nix manually from:\n https://nixos.org/download/\n\n\ + After installing, run:\n root doctor", + other + )), + } +} + +/// Detect whether the current platform supports automatic Nix installation. +pub fn detect_nix_install_platform() -> Result { + detect_nix_install_platform_inner(std::env::consts::OS) +} + +/// Build the shell command and arguments used to install Nix. +pub fn build_nix_installer_command() -> (String, Vec) { + let script = "curl -L https://nixos.org/nix/install | sh".to_string(); + ( + script.clone(), + vec!["sh".to_string(), "-c".to_string(), script], + ) +} + +/// Format the recovery message shown after a failed Nix installation. +pub fn format_nix_installer_failure_message() -> String { + "Nix installation failed.\n\n\ + You can install Nix manually:\n\ + curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install\n\n\ + Then run:\n root doctor" + .to_string() +} + +/// Format the message shown when running non-interactively without --yes. +pub fn format_non_interactive_message() -> String { + "Nix installation requires confirmation, but this session is not interactive.\n\n\ + To install Nix, run:\n root init --install-nix --yes\n\n\ + Or install Nix manually:\n\ + curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install\n\n\ + Then run:\n root doctor" + .to_string() +} + +/// Format the recovery message shown when the post-install `nix --version` probe fails. +pub fn format_post_install_version_failure(stderr: &str) -> String { + let detail = if stderr.is_empty() { + "unknown error".to_string() + } else { + stderr.trim().to_string() + }; + format!( + "Nix was installed but 'nix --version' failed: {}.\n\n\ + Try restarting your shell, then run:\n root doctor", + detail + ) +} + /// Install Nix using the official multi-user installer. -pub fn install_nix() -> Result<()> { - let status = std::process::Command::new("sh") - .args(["-c", "curl -L https://nixos.org/nix/install | sh"]) +/// +/// # Safety note +/// +/// Checksum verification of the installer script is not currently implemented. +/// The installer is downloaded via HTTPS, which provides transport-layer security, +/// but the script contents are not independently verified before execution. +/// This is future work — the script should be downloaded separately, its SHA-256 +/// checksum verified against a published value, and only then executed. +/// +/// # Arguments +/// +/// * `yes` — If `true`, skip the interactive confirmation prompt. +pub fn install_nix(yes: bool) -> Result<()> { + let platform = detect_nix_install_platform()?; + + // Explanation + println!("Root needs Nix to manage packages. Root will now download and run the"); + println!("Nix installer from https://nixos.org/nix/install. This may modify your"); + println!("shell profile and create /nix."); + match platform { + NixInstallPlatform::MacOs => { + println!("On macOS, the installer will set up a multi-user Nix installation"); + println!("under /nix and may request administrator privileges."); + } + NixInstallPlatform::Linux => { + println!("On Linux, the installer will set up a multi-user Nix installation"); + println!("under /nix and may request sudo access."); + } + } + println!(); + + // Confirmation + if !yes { + if !std::io::stdin().is_terminal() { + anyhow::bail!(format_non_interactive_message()); + } + print!("Continue? [Y/n] "); + std::io::stdout().flush()?; + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let trimmed = input.trim(); + if !trimmed.is_empty() + && !trimmed.eq_ignore_ascii_case("y") + && !trimmed.eq_ignore_ascii_case("yes") + { + anyhow::bail!("Nix installation cancelled by user."); + } + } + + // Run installer + let (_, args) = build_nix_installer_command(); + let program = &args[0]; + let program_args: Vec<&str> = args[1..].iter().map(|s| s.as_str()).collect(); + + let status = std::process::Command::new(program) + .args(&program_args) .status() .context("Failed to run Nix installer")?; if !status.success() { - Err(anyhow::anyhow!( - "Nix installer exited with code {:?}", - status.code() - )) + anyhow::bail!(format_nix_installer_failure_message()); + } + + // Post-install probe + println!("\nVerifying Nix installation..."); + + let version_output = std::process::Command::new("nix") + .arg("--version") + .output() + .map_err(|e| { + anyhow::anyhow!( + "Nix was installed but could not be executed: {}.\n\n\ + Try restarting your shell, or add Nix to your PATH:\n\ + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh\n\n\ + Then run:\n root doctor", + e + ) + })?; + + if version_output.status.success() { + let version = String::from_utf8_lossy(&version_output.stdout) + .trim() + .to_string(); + println!("✓ Nix {} installed and working", version); } else { - Ok(()) + let stderr = String::from_utf8_lossy(&version_output.stderr); + anyhow::bail!(format_post_install_version_failure(&stderr)); } + + let features_output = std::process::Command::new("nix") + .args([ + "--extra-experimental-features", + "nix-command flakes", + "--version", + ]) + .output(); + + match features_output { + Ok(output) if output.status.success() => { + println!("✓ Nix experimental features (nix-command, flakes) available"); + } + _ => { + println!("⚠ Nix experimental features (nix-command, flakes) are not enabled."); + println!(" To enable them, add to ~/.config/nix/nix.conf:"); + println!(" experimental-features = nix-command flakes"); + println!(" Or edit /etc/nix/nix.conf to include the same line."); + } + } + + Ok(()) } pub fn init(adapter: &impl NixAdapter) -> Result { @@ -935,8 +1099,13 @@ fn get_or_create_lock_v2() -> Result { let dir = get_root_dir()?; let path = dir.join("root.lock"); if path.exists() { - RootLockV2::read_from_file(&path) - .or_else(|_| RootLock::read_from_file(&path).map(|lock| lock.to_v2())) + let lock = RootLockV2::read_from_file(&path) + .or_else(|_| RootLock::read_from_file(&path).map(|lock| lock.to_v2()))?; + root_lockfile::validate_store_paths(&lock).context(format!( + "Existing lockfile at {} failed validation", + path.display() + ))?; + Ok(lock) } else { Ok(get_or_create_lock()?.to_v2()) } @@ -1141,6 +1310,49 @@ fn verify_profile_contains_outputs( Ok(()) } +fn validate_mutation_result( + adapter: &impl NixAdapter, + before_generation: Option, + packages: &[&str], + binaries: &[&str], + store_paths: &[&str], +) -> Result<()> { + let validation = adapter + .validate_profile_mutation(before_generation, packages, binaries, store_paths) + .map_err(|e| anyhow::anyhow!("Profile validation framework error: {}", e))?; + + // Hard error: missing store paths or .drv paths in outputs + if !validation.expected_packages_present || !validation.drv_paths_found.is_empty() { + let mut msgs = Vec::new(); + if !validation.missing_output_paths.is_empty() { + msgs.push(format!( + "Missing output paths: {}", + validation.missing_output_paths.join(", ") + )); + } + if !validation.drv_paths_found.is_empty() { + msgs.push(format!( + ".drv paths found in outputs: {}", + validation.drv_paths_found.join(", ") + )); + } + return Err(anyhow::anyhow!( + "Profile mutation validation failed: {}", + msgs.join("; ") + )); + } + + // Hard error: generation did not change when it should have + if !validation.generation_changed && (!packages.is_empty() || !store_paths.is_empty()) { + return Err(anyhow::anyhow!( + "Profile generation did not change after mutation, \ + which suggests the profile was not actually updated" + )); + } + + Ok(()) +} + fn parse_attributes(search_output: &str) -> Vec { search_output .lines() @@ -1352,10 +1564,25 @@ pub fn install(adapter: &impl NixAdapter, pkg: &str) -> Result { let snapshot = Snapshot::create_from_v2(&format!("before install {}", canonical), &lock)?; let snapshot_id = snapshot.id.clone(); + let before_gen = adapter + .profile_generation() + .map_err(|e| anyhow::anyhow!(e))?; + adapter .install_installable(canonical, &installable) .map_err(|e| anyhow::anyhow!(e))?; verify_profile_contains_outputs(adapter, &locked_package.store_paths)?; + validate_mutation_result( + adapter, + before_gen, + &[canonical], + spec.binaries, + &locked_package + .store_paths + .values() + .map(|s| s.as_str()) + .collect::>(), + )?; let mut rootfile = get_or_create_rootfile()?; rootfile @@ -1371,6 +1598,8 @@ pub fn install(adapter: &impl NixAdapter, pkg: &str) -> Result { .collect(); v2_packages.push(locked_package.clone()); let v2_lock = build_v2_lock(&lock, &flake, v2_packages)?; + root_lockfile::validate_store_paths(&v2_lock) + .context("Newly created lockfile failed validation after install")?; save_lock_v2(&v2_lock)?; let _ = events::record_event( @@ -1512,11 +1741,25 @@ pub fn update(adapter: &impl NixAdapter, pkg: Option<&str>) -> Result>(), + )?; updated.push(canonical.to_string()); } else { unchanged.push(canonical.to_string()); @@ -1543,6 +1786,8 @@ pub fn update(adapter: &impl NixAdapter, pkg: Option<&str>) -> Result Result { let last_snap = &snaps[0]; let current_lock = get_or_create_lock_v2()?; let target_lock = last_snap.restored_lock(); + root_lockfile::validate_store_paths(&target_lock).context(format!( + "Snapshot '{}' lock failed validation before rollback", + last_snap.id + ))?; // Step 1: Compute rollback plan let mut packages_to_remove = Vec::new(); @@ -1727,6 +1976,18 @@ pub fn rollback_last(adapter: &impl NixAdapter) -> Result { .ok_or_else(|| { anyhow::anyhow!("Snapshot package '{}' is missing lock metadata", pkg) })?; + let before_gen = adapter.profile_generation().map_err(|e| { + let _ = events::record_event( + events::RootEventType::Rollback, + events::RootEventStatus::Failed, + "root rollback --last", + None, + Some(pre_rollback_snap.id.clone()), + Some(last_snap.id.clone()), + Some(format!("Failed to check generation: {}", e)), + ); + anyhow::anyhow!("Rollback failed to check generation: {}", e) + })?; let install_result = if let Some(installable) = locked_pkg.installable.as_deref() { adapter.install_installable(pkg, installable) } else { @@ -1758,6 +2019,33 @@ pub fn rollback_last(adapter: &impl NixAdapter) -> Result { ); anyhow::anyhow!("Rollback verification failed for '{}': {}", pkg, e) })?; + + let store_path_strs: Vec<&str> = locked_pkg + .store_paths + .values() + .map(|s| s.as_str()) + .collect(); + let spec = resolve_package(pkg); + let binaries = spec.map(|s| s.binaries).unwrap_or(&[]); + validate_mutation_result( + adapter, + before_gen, + &[pkg.as_str()], + binaries, + &store_path_strs, + ) + .map_err(|e| { + let _ = events::record_event( + events::RootEventType::Rollback, + events::RootEventStatus::Failed, + "root rollback --last", + None, + Some(pre_rollback_snap.id.clone()), + Some(last_snap.id.clone()), + Some(format!("Rollback validation failed for '{}': {}", pkg, e)), + ); + anyhow::anyhow!("Rollback validation failed for '{}': {}", pkg, e) + })?; } // Step 4: ONLY NOW update Rootfile and root.lock (after Nix succeeded) @@ -1942,6 +2230,8 @@ pub fn lock(adapter: &impl NixAdapter) -> Result { .map_err(|e| anyhow::anyhow!(e))?, }; let new_lock = build_v2_lock(¤t_v2_lock, &flake, v2_packages)?; + root_lockfile::validate_store_paths(&new_lock) + .context("Newly created lockfile failed validation after lock")?; save_lock_v2(&new_lock)?; Ok(LockReport { @@ -1968,7 +2258,12 @@ pub struct SandboxCreateReport { pub name: String, pub image: String, pub status: String, + pub state: String, pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub memory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cpus: Option, } #[derive(Debug, Serialize)] @@ -1979,6 +2274,12 @@ pub struct SandboxRunReport { pub exit_code: i32, pub stdout: String, pub stderr: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timed_out: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cleanup_attempted: Option, } #[derive(Debug, Serialize)] @@ -2266,6 +2567,27 @@ fn reconcile_profile_to_lock( continue; } + let before_gen = adapter.profile_generation().map_err(|e| { + let _ = events::record_event( + event_type.clone(), + events::RootEventStatus::Failed, + command, + Some(package.name.clone()), + Some(snapshot_id.clone()), + None, + Some(format!( + "Failed to check generation before installing '{}': {}", + package.name, e + )), + ); + anyhow::anyhow!( + "{} failed to check generation for '{}': {}", + command, + package.name, + e + ) + })?; + let install_result = if let Some(installable) = package.installable.as_deref() { adapter.install_installable(&package.name, installable) } else { @@ -2307,6 +2629,38 @@ fn reconcile_profile_to_lock( e ) })?; + + let store_path_strs: Vec<&str> = package.store_paths.values().map(|s| s.as_str()).collect(); + let spec = resolve_package(&package.name); + let binaries = spec.map(|s| s.binaries).unwrap_or(&[]); + validate_mutation_result( + adapter, + before_gen, + &[package.name.as_str()], + binaries, + &store_path_strs, + ) + .map_err(|e| { + let _ = events::record_event( + event_type.clone(), + events::RootEventStatus::Failed, + command, + Some(package.name.clone()), + Some(snapshot_id.clone()), + None, + Some(format!( + "Profile mutation validation failed for '{}': {}", + package.name, e + )), + ); + anyhow::anyhow!( + "{} mutation validation failed for '{}': {}", + command, + package.name, + e + ) + })?; + installed.push(package.name.clone()); } @@ -2378,6 +2732,7 @@ pub fn sync(adapter: &impl NixAdapter) -> Result { } let lock = get_or_create_lock_v2()?; + root_lockfile::validate_store_paths(&lock).context("Lockfile failed validation before sync")?; let report = reconcile_profile_to_lock( adapter, &lock, @@ -2469,6 +2824,10 @@ pub fn restore(adapter: &impl NixAdapter, lock_path: Option<&Path>) -> Result, image: Option<&str>, + memory: Option<&str>, + cpus: Option<&str>, ) -> Result { root_lockfile::init_root_dir()?; let sandbox_name = name.unwrap_or("default"); @@ -2515,9 +2876,24 @@ pub fn sandbox_create( } let instance = provider - .create(sandbox_name, image) + .create(sandbox_name, image, memory, cpus) .map_err(|e| anyhow::anyhow!("Sandbox create failed: {}", e))?; + // Post-create validation + let exists = provider.check_exists(&instance.id).unwrap_or(false); + let reachable = provider.check_reachable(&instance.id).unwrap_or(false); + + if !exists || !reachable { + let _ = provider.destroy(&instance.id); + return Err(anyhow::anyhow!( + "Sandbox '{}' was created but post-create validation failed \ + (exists: {}, reachable: {}). The sandbox has been destroyed.", + sandbox_name, + exists, + reachable + )); + } + let _ = events::record_event( events::RootEventType::Sandbox, events::RootEventStatus::Completed, @@ -2536,8 +2912,11 @@ pub fn sandbox_create( id: instance.id, name: instance.name, image: instance.image, - status: instance.status, + status: format!("{:?}", instance.state), + state: format!("{:?}", instance.state), created_at: instance.created_at, + memory: instance.memory, + cpus: instance.cpus, }) } @@ -2545,33 +2924,84 @@ pub fn sandbox_run( provider: &impl SandboxProvider, id: &str, command: &[String], + timeout: Option, ) -> Result { root_lockfile::init_root_dir()?; enforce_policy(policy::PolicyAction::SandboxRun, Some(id))?; + let started_at = events::now_iso_for_event(); + let start_instant = std::time::Instant::now(); + let cmd_str: Vec<&str> = command.iter().map(String::as_str).collect(); let result = provider - .run_command(id, &cmd_str) + .run_command(id, &cmd_str, timeout) .map_err(|e| anyhow::anyhow!("Sandbox exec failed: {}", e))?; - let status = if result.exit_code == 0 { - events::RootEventStatus::Completed + let duration_ms = start_instant.elapsed().as_millis() as u64; + let finished_at = events::now_iso_for_event(); + let is_timeout = result.exit_code == 124; + + let (status, message) = if is_timeout { + ( + events::RootEventStatus::Timeout, + format!( + "Command timed out in sandbox '{}' after {}ms", + id, duration_ms + ), + ) + } else if result.exit_code == 0 { + ( + events::RootEventStatus::Completed, + format!( + "Executed in sandbox '{}': exit code {}", + id, result.exit_code + ), + ) } else { - events::RootEventStatus::Failed + ( + events::RootEventStatus::Failed, + format!( + "Executed in sandbox '{}': exit code {}", + id, result.exit_code + ), + ) }; - let _ = events::record_event( + let mut cleanup_attempted = false; + if result.exit_code != 0 || is_timeout { + let _ = provider.destroy(id); + cleanup_attempted = true; + let cleanup_msg = if is_timeout { + format!("Cleanup attempted after timeout for sandbox '{}'", id) + } else { + format!("Cleanup attempted after failed run for sandbox '{}'", id) + }; + let _ = events::record_event( + events::RootEventType::Sandbox, + events::RootEventStatus::Completed, + &format!("root sandbox cleanup {}", id), + None, + None, + None, + Some(cleanup_msg), + ); + } + + let mut event = events::create_event( events::RootEventType::Sandbox, status, &format!("root sandbox run {}", id), None, None, None, - Some(format!( - "Executed in sandbox '{}': exit code {}", - id, result.exit_code - )), + Some(message), ); + event.sandbox_id = Some(id.to_string()); + event.exit_code = Some(result.exit_code); + event.started_at = Some(started_at); + event.finished_at = Some(finished_at); + event.duration_ms = Some(duration_ms); + let _ = events::append_event(&event); Ok(SandboxRunReport { success: result.exit_code == 0, @@ -2580,6 +3010,9 @@ pub fn sandbox_run( exit_code: result.exit_code, stdout: result.stdout, stderr: result.stderr, + duration_ms: Some(duration_ms), + timed_out: Some(is_timeout), + cleanup_attempted: Some(cleanup_attempted), }) } @@ -2598,24 +3031,60 @@ pub fn sandbox_destroy(provider: &impl SandboxProvider, id: &str) -> Result { + let _ = events::record_event( + events::RootEventType::Sandbox, + events::RootEventStatus::Completed, + &format!("root sandbox destroy {}", id), + None, + None, + None, + Some(format!("Destroyed sandbox '{}'", id)), + ); + Ok(SandboxDestroyReport { + success: true, + id: id.to_string(), + }) + } + Err(e) => { + let msg = if let Some(ref w) = warning { + format!("{}. {}", e, w) + } else { + format!("{}", e) + }; + let _ = events::record_event( + events::RootEventType::Sandbox, + events::RootEventStatus::Failed, + &format!("root sandbox destroy {}", id), + None, + None, + None, + Some(msg.clone()), + ); + Err(anyhow::anyhow!("Sandbox destroy failed: {}", msg)) + } + } } pub fn status(adapter: &impl root_nix::NixAdapter) -> Result { @@ -4337,7 +4806,7 @@ mod tests { .unwrap(); let provider = root_sandbox::MockSandboxProvider::new(true); - let error = sandbox_create(&provider, Some("blocked"), None).unwrap_err(); + let error = sandbox_create(&provider, Some("blocked"), None, None, None).unwrap_err(); assert!(error.to_string().contains("Policy denied sandbox-create")); assert!(provider.list().unwrap().is_empty()); @@ -4778,4 +5247,472 @@ mod tests { err ); } + + // ── Phase 4: Store Path Validation ──────────────────────────────────── + + fn make_invalid_lockfile(lock_dir: &std::path::Path) { + // Write a lockfile where an output store_path ends in .drv + let mut pkg = root_lockfile::LockedPackageV2 { + name: "ffmpeg".into(), + requested: "ffmpeg".into(), + version: "8.1".into(), + attribute: "ffmpeg".into(), + store_path: "/nix/store/abc-ffmpeg-8.1".into(), + binaries: vec!["ffmpeg".into()], + installable: Some("nixpkgs#ffmpeg".into()), + drv_path: Some("/nix/store/drv-ffmpeg-8.1.drv".into()), + ..Default::default() + }; + pkg.outputs.insert( + "out".into(), + root_lockfile::LockedPackageOutput { + store_path: "/nix/store/abc-ffmpeg-8.1.drv".into(), + content_hash: None, + nar_hash: None, + references: vec![], + }, + ); + pkg.store_paths + .insert("out".into(), "/nix/store/abc-ffmpeg-8.1.drv".into()); + + let lock = RootLockV2 { + platform: root_lockfile::detect_platform().unwrap_or_else(|_| "aarch64-darwin".into()), + packages: vec![pkg], + ..RootLockV2::default() + }; + lock.write_to_file(&lock_dir.join("root.lock")).unwrap(); + } + + fn make_invalid_snapshot(snapshot_dir: &std::path::Path, snapshot_id: &str) { + let mut pkg = root_lockfile::LockedPackageV2 { + name: "ffmpeg".into(), + requested: "ffmpeg".into(), + version: "8.1".into(), + attribute: "ffmpeg".into(), + store_path: "/nix/store/abc-ffmpeg-8.1".into(), + binaries: vec!["ffmpeg".into()], + installable: Some("nixpkgs#ffmpeg".into()), + drv_path: Some("/nix/store/drv-ffmpeg-8.1.drv".into()), + ..Default::default() + }; + pkg.outputs.insert( + "out".into(), + root_lockfile::LockedPackageOutput { + store_path: "/nix/store/abc-ffmpeg-8.1.drv".into(), + content_hash: None, + nar_hash: None, + references: vec![], + }, + ); + pkg.store_paths + .insert("out".into(), "/nix/store/abc-ffmpeg-8.1.drv".into()); + + let lock = RootLockV2 { + version: ROOT_LOCK_SCHEMA_VERSION, + platform: root_lockfile::detect_platform().unwrap_or_else(|_| "aarch64-darwin".into()), + packages: vec![pkg], + ..RootLockV2::default() + }; + + let snapshot = root_snapshot::Snapshot { + id: snapshot_id.to_string(), + created_at: chrono::Utc::now(), + reason: "test invalid snapshot".into(), + schema_version: ROOT_LOCK_SCHEMA_VERSION, + package_count: 1, + lock_content_hash: root_lockfile::compute_sha256(&serde_json::to_vec(&lock).unwrap()), + lock: lock.clone(), + packages: vec![], + }; + let content = serde_json::to_string_pretty(&snapshot).unwrap(); + let snap_path = snapshot_dir.join(format!("{}.json", snapshot_id)); + std::fs::write(&snap_path, content).unwrap(); + } + + #[test] + fn test_restore_rejects_invalid_lockfile_before_mutation() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("restore_rejects_invalid"); + let _ = std::fs::remove_dir_all(&tmp); + std::env::set_var("ROOT_DIR", &tmp); + root_lockfile::init_root_dir().unwrap(); + let adapter = MockNixAdapter::new(true); + + make_invalid_lockfile(&tmp); + + let lock_path = tmp.join("root.lock"); + let err = restore(&adapter, Some(&lock_path)).unwrap_err(); + let err_msg = err.to_string(); + assert!( + err_msg.contains("failed validation before restore"), + "Expected store path validation error, got: {}", + err_msg + ); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_rollback_rejects_invalid_snapshot_before_mutation() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("rollback_rejects_invalid"); + let _ = std::fs::remove_dir_all(&tmp); + std::env::set_var("ROOT_DIR", &tmp); + let root_dir = root_lockfile::init_root_dir().unwrap(); + let adapter = MockNixAdapter::new(true); + + // Install a valid package first so there's something to roll back + install(&adapter, "ffmpeg").unwrap(); + + // Manually inject an invalid snapshot + let snapshot_dir = root_dir.join("snapshots"); + let snapshot_id = "snap_invalid_00000000_000000_000000"; + make_invalid_snapshot(&snapshot_dir, snapshot_id); + + let err = rollback_last(&adapter).unwrap_err(); + let err_msg = err.to_string(); + assert!( + err_msg.contains("derivation path where an output path was expected") + || err_msg.contains("failed validation"), + "Expected store path validation error, got: {}", + err_msg + ); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_sync_rejects_invalid_lockfile() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("sync_rejects_invalid"); + let _ = std::fs::remove_dir_all(&tmp); + std::env::set_var("ROOT_DIR", &tmp); + root_lockfile::init_root_dir().unwrap(); + let adapter = MockNixAdapter::new(true); + + // Install a package via mock to get valid profile state + adapter.install("ffmpeg").unwrap(); + + // Write an invalid lockfile + make_invalid_lockfile(&tmp); + + let err = sync(&adapter).unwrap_err(); + let err_msg = err.to_string(); + assert!( + err_msg.contains("derivation path") || err_msg.contains("failed validation"), + "Expected store path validation error, got: {}", + err_msg + ); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_lock_rejects_invalid_lockfile_on_read() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("lock_rejects_invalid"); + let _ = std::fs::remove_dir_all(&tmp); + std::env::set_var("ROOT_DIR", &tmp); + root_lockfile::init_root_dir().unwrap(); + let adapter = MockNixAdapter::new(true); + + // Install a valid package to create initial state + adapter.install("ripgrep").unwrap(); + let mut rf = get_or_create_rootfile().unwrap(); + rf.packages.insert("ripgrep".into(), "latest".into()); + save_rootfile(&rf).unwrap(); + lock(&adapter).unwrap(); + + // Now replace with invalid lockfile + make_invalid_lockfile(&tmp); + + // Reading the lockfile should fail validation + let err = get_or_create_lock_v2().unwrap_err(); + let err_msg = err.to_string(); + assert!( + err_msg.contains("validation"), + "Expected validation error, got: {}", + err_msg + ); + + let _ = std::fs::remove_dir_all(&tmp); + } + + // Phase 6: Installer validation + + #[test] + fn test_detect_platform_macos() { + assert_eq!( + detect_nix_install_platform_inner("macos").unwrap(), + NixInstallPlatform::MacOs + ); + } + + #[test] + fn test_detect_platform_linux() { + assert_eq!( + detect_nix_install_platform_inner("linux").unwrap(), + NixInstallPlatform::Linux + ); + } + + #[test] + fn test_detect_platform_unsupported() { + let err = detect_nix_install_platform_inner("windows").unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("Unsupported platform") && msg.contains("windows"), + "Expected unsupported platform error, got: {}", + msg + ); + } + + #[test] + fn test_detect_platform_unsupported_other() { + let err = detect_nix_install_platform_inner("freebsd").unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("Unsupported platform") && msg.contains("freebsd"), + "Expected unsupported platform error, got: {}", + msg + ); + } + + #[test] + fn test_build_nix_installer_command_structure() { + let (script, args) = build_nix_installer_command(); + assert!( + script.contains("curl") && script.contains("nixos.org"), + "Script should contain curl command with nixos.org URL" + ); + assert_eq!(args[0], "sh"); + assert_eq!(args[1], "-c"); + assert_eq!(args[2], script); + } + + #[test] + fn test_build_nix_installer_command_uses_https() { + let (script, _) = build_nix_installer_command(); + assert!( + script.contains("https://nixos.org/nix/install"), + "Installer URL should use HTTPS" + ); + assert!( + !script.contains("http://"), + "Installer URL should not use plain HTTP" + ); + } + + #[test] + fn test_installer_failure_message_format() { + let msg = format_nix_installer_failure_message(); + assert!(msg.contains("Nix installation failed")); + assert!(msg.contains("install Nix manually")); + assert!(msg.contains("install.determinate.systems")); + assert!(msg.contains("root doctor")); + } + + #[test] + fn test_installer_failure_message_includes_curl_command() { + let msg = format_nix_installer_failure_message(); + assert!(msg.contains("curl --proto '=https' --tlsv1.2 -sSf -L")); + assert!(msg.contains("install.determinate.systems/nix")); + } + + #[test] + fn test_non_interactive_message_format() { + let msg = format_non_interactive_message(); + assert!(msg.contains("not interactive")); + assert!(msg.contains("root init --install-nix --yes")); + assert!(msg.contains("root doctor")); + } + + #[test] + fn test_post_install_version_failure_format() { + let msg = format_post_install_version_failure("nix: command not found"); + assert!(msg.contains("nix --version' failed")); + assert!(msg.contains("nix: command not found")); + assert!(msg.contains("restarting your shell")); + assert!(msg.contains("root doctor")); + } + + #[test] + fn test_post_install_version_failure_empty_stderr() { + let msg = format_post_install_version_failure(""); + assert!(msg.contains("nix --version' failed")); + assert!(msg.contains("unknown error")); + assert!(msg.contains("root doctor")); + } + + #[test] + fn test_platform_detect_returns_os_on_this_machine() { + // This test verifies that the platform detection function works + // on whatever OS this test is run on (macOS or Linux). + // It should not fail on supported platforms. + if cfg!(target_os = "macos") || cfg!(target_os = "linux") { + let result = detect_nix_install_platform(); + assert!(result.is_ok(), "Platform should be detected on macOS/Linux"); + } + } + + // ─── Phase 3: Profile Validation Integration Tests ──────────────────── + + #[test] + fn test_install_validates_profile_generation() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("install_validate_gen"); + let _ = std::fs::remove_dir_all(&tmp); + std::env::set_var("ROOT_DIR", &tmp); + let _ = root_lockfile::init_root_dir(); + let adapter = MockNixAdapter::new(true); + + install(&adapter, "ripgrep").unwrap(); + + let gen = adapter.profile_generation().unwrap(); + assert_eq!( + gen, + Some(2), + "Generation should have incremented after install" + ); + } + + #[test] + fn test_install_fails_cleanly_when_profile_missing_path() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("install_fail_missing_path"); + let _ = std::fs::remove_dir_all(&tmp); + std::env::set_var("ROOT_DIR", &tmp); + let _ = root_lockfile::init_root_dir(); + let adapter = MockNixAdapter::new(true); + + // Override profile list to return JSON that doesn't include the + // expected store path for ripgrep — simulating a failed install. + let bad_json = r#"[{"index":0,"attrPath":"other-pkg","originalUrl":"flake:nixpkgs","installable":"nixpkgs#other-pkg","storePaths":["/nix/store/abc-other-pkg"]}]"#.to_string(); + adapter.set_profile_list_json_override(Some(bad_json)); + + let err = install(&adapter, "ripgrep").unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("validation") || msg.contains("store path") || msg.contains("profile"), + "Expected validation error mentioning profile, got: {}", + msg + ); + } + + #[test] + fn test_update_validates_profile() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("update_validates_profile"); + let _ = std::fs::remove_dir_all(&tmp); + std::env::set_var("ROOT_DIR", &tmp); + let _ = root_lockfile::init_root_dir(); + let adapter = MockNixAdapter::new(true); + + // Install via raw adapter + set up rootfile + adapter.install("ripgrep").unwrap(); + let mut lock = get_or_create_lock().unwrap(); + lock.packages.push(LockedPackage { + name: "ripgrep".into(), + requested: "ripgrep".into(), + version: "latest".into(), + attribute: "ripgrep".into(), + store_path: root_lockfile::derive_store_path("ripgrep", "latest"), + binaries: vec!["rg".into()], + }); + save_lock(&lock).unwrap(); + let mut rootfile = get_or_create_rootfile().unwrap(); + rootfile.packages.insert("ripgrep".into(), "latest".into()); + save_rootfile(&rootfile).unwrap(); + + let report = update(&adapter, Some("ripgrep")).unwrap(); + assert!(report.success); + assert!(report.updated.contains(&"ripgrep".to_string())); + } + + #[test] + fn test_rollback_validates_profile() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("rollback_validates_profile"); + let _ = std::fs::remove_dir_all(&tmp); + std::env::set_var("ROOT_DIR", &tmp); + let _ = root_lockfile::init_root_dir(); + let adapter = MockNixAdapter::new(true); + + // Set up initial package + adapter.install("ripgrep").unwrap(); + let mut lock = get_or_create_lock().unwrap(); + lock.packages.push(LockedPackage { + name: "ripgrep".into(), + requested: "ripgrep".into(), + version: "latest".into(), + attribute: "ripgrep".into(), + store_path: root_lockfile::derive_store_path("ripgrep", "latest"), + binaries: vec!["rg".into()], + }); + save_lock(&lock).unwrap(); + let mut rootfile = get_or_create_rootfile().unwrap(); + rootfile.packages.insert("ripgrep".into(), "latest".into()); + save_rootfile(&rootfile).unwrap(); + + // Install another package to create a rollback point + install(&adapter, "ffmpeg").unwrap(); + + // Reset profile JSON override to ensure normal behavior + adapter.set_profile_list_json_override(None); + + let report = rollback_last(&adapter).unwrap(); + assert!(report.success); + assert!(report.packages_removed.contains(&"ffmpeg".to_string())); + } + + #[test] + fn test_validate_mutation_reports_generation_unchanged_error() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("validate_gen_unchanged"); + let _ = std::fs::remove_dir_all(&tmp); + std::env::set_var("ROOT_DIR", &tmp); + let _ = root_lockfile::init_root_dir(); + let adapter = MockNixAdapter::new(true); + + // Manually configure: don't do any mutation so generation doesn't change + let before = adapter.profile_generation().unwrap(); + + // Set up a mock profile that already has the path + let mock_path = { + let token = format!("{:032x}", { + "ripgrep".bytes().fold(0xcbf29ce484222325u64, |h, b| { + (h ^ u64::from(b)).wrapping_mul(0x100000001b3) + }) + }); + let n = "ripgrep".bytes().fold(0xcbf29ce484222325u64, |h, b| { + (h ^ u64::from(b)).wrapping_mul(0x100000001b3) + }); + format!("/nix/store/{}-ripgrep-0.1.{}", token, n % 1000) + }; + adapter.set_profile_list_json_override(Some( + format!( + r#"[{{"index":0,"attrPath":"ripgrep","installable":"nixpkgs#ripgrep","storePaths":["{}"]}}]"#, + mock_path + ) + )); + + // validate_mutation_result should detect generation didn't change + let result = + validate_mutation_result(&adapter, before, &["ripgrep"], &["rg"], &[&mock_path]); + + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("generation did not change"), + "Expected generation-not-changed error, got: {}", + msg + ); + } + + #[test] + fn test_validate_mutation_empty_profile_handled() { + let adapter = MockNixAdapter::new(false); + let result = validate_mutation_result(&adapter, None, &[], &[], &[]); + assert!(result.is_ok(), "Empty state should not fail: {:?}", result); + } } diff --git a/crates/root-doctor/src/lib.rs b/crates/root-doctor/src/lib.rs index 4de4ea9..c6b2967 100644 --- a/crates/root-doctor/src/lib.rs +++ b/crates/root-doctor/src/lib.rs @@ -78,6 +78,58 @@ pub fn run_diagnostics(adapter: &impl NixAdapter) -> Result { } } + // 1b. Probe experimental features (only if Nix is installed) + if report.nix_installed { + match adapter.probe_experimental_features() { + Ok(root_nix::ExperimentalFeatureStatus::AllAvailable) => {} + Ok(root_nix::ExperimentalFeatureStatus::NixNotAvailable) => { + report.nix_installed = false; + report.issues.push(DoctorIssue { + severity: IssueSeverity::Error, + category: "Nix".to_string(), + description: "Nix is not installed or not available on PATH.\n\nRoot uses Nix to build packages from source in isolated environments.\nNix provides reproducible, deterministic builds that Root pins in its lockfile." + .to_string(), + suggestion: + "Install Nix with: root init --install-nix\nOr install manually from:\n https://nixos.org/download/\n\nAfter installing, run: root doctor" + .to_string(), + }); + } + Ok(root_nix::ExperimentalFeatureStatus::NixCommandMissing) + | Ok(root_nix::ExperimentalFeatureStatus::FlakesMissing) + | Ok(root_nix::ExperimentalFeatureStatus::BothMissing) => { + report.issues.push(DoctorIssue { + severity: IssueSeverity::Error, + category: "Nix".to_string(), + description: + "Nix is installed, but required experimental features are not enabled.\n\nRoot requires:\n nix-command\n flakes\n\nYou can enable them by adding this to ~/.config/nix/nix.conf:\n\n experimental-features = nix-command flakes\n\nThen run:\n root doctor".to_string(), + suggestion: + "Add this to ~/.config/nix/nix.conf:\n experimental-features = nix-command flakes\n\nThen run: root doctor".to_string(), + }); + } + Ok(root_nix::ExperimentalFeatureStatus::NixpkgsResolutionFailed) => { + report.issues.push(DoctorIssue { + severity: IssueSeverity::Error, + category: "Nix".to_string(), + description: + "Nix and experimental features are available, but nixpkgs could not be resolved.\n\nThis may indicate a network issue, a missing nixpkgs channel, or a problem with your Nix installation." + .to_string(), + suggestion: + "Run 'nix-channel --update' and try 'root doctor' again, or check your network connection.\nIf the issue persists, try: nix eval nixpkgs#hello".to_string(), + }); + } + Err(e) => { + report.issues.push(DoctorIssue { + severity: IssueSeverity::Error, + category: "Nix".to_string(), + description: format!("Failed to probe Nix experimental features: {}", e), + suggestion: + "Run `nix eval nixpkgs#hello` to diagnose the issue, then run `root doctor` again." + .to_string(), + }); + } + } + } + // 2. Check Root Directory Status let root_dir_res = get_root_dir(); let mut has_root_dir = false; @@ -831,4 +883,70 @@ mod tests { && issue.description.contains("not a concrete pinned revision") })); } + + #[test] + fn test_probe_all_available() { + let (_temp_home, _guard) = setup_test_home("test_probe_all_available"); + let mut adapter = MockNixAdapter::new(true); + adapter.nix_command_enabled = true; + adapter.flakes_enabled = true; + adapter.nixpkgs_accessible = true; + let report = run_diagnostics(&adapter).unwrap(); + assert!(report.nix_installed); + assert!(!report + .issues + .iter() + .any(|i| i.category == "Nix" && i.description.contains("experimental features"))); + } + + #[test] + fn test_probe_nix_command_missing() { + let (_temp_home, _guard) = setup_test_home("test_probe_nix_command_missing"); + let mut adapter = MockNixAdapter::new(true); + adapter.nix_command_enabled = false; + adapter.flakes_enabled = true; + let report = run_diagnostics(&adapter).unwrap(); + assert!(report.nix_installed); + let nix_issue = report.issues.iter().find(|i| i.category == "Nix").unwrap(); + assert!(nix_issue.description.contains("experimental features")); + } + + #[test] + fn test_probe_flakes_missing() { + let (_temp_home, _guard) = setup_test_home("test_probe_flakes_missing"); + let mut adapter = MockNixAdapter::new(true); + adapter.nix_command_enabled = true; + adapter.flakes_enabled = false; + let report = run_diagnostics(&adapter).unwrap(); + assert!(report.nix_installed); + let nix_issue = report.issues.iter().find(|i| i.category == "Nix").unwrap(); + assert!(nix_issue.description.contains("experimental features")); + } + + #[test] + fn test_probe_both_missing() { + let (_temp_home, _guard) = setup_test_home("test_probe_both_missing"); + let mut adapter = MockNixAdapter::new(true); + adapter.nix_command_enabled = false; + adapter.flakes_enabled = false; + let report = run_diagnostics(&adapter).unwrap(); + assert!(report.nix_installed); + let nix_issue = report.issues.iter().find(|i| i.category == "Nix").unwrap(); + assert!(nix_issue.description.contains("experimental features")); + } + + #[test] + fn test_probe_nixpkgs_resolution_failed() { + let (_temp_home, _guard) = setup_test_home("test_probe_nixpkgs_resolution_failed"); + let mut adapter = MockNixAdapter::new(true); + adapter.nix_command_enabled = true; + adapter.flakes_enabled = true; + adapter.nixpkgs_accessible = false; + let report = run_diagnostics(&adapter).unwrap(); + assert!(report.nix_installed); + let nix_issue = report.issues.iter().find(|i| i.category == "Nix").unwrap(); + assert!(nix_issue + .description + .contains("nixpkgs could not be resolved")); + } } diff --git a/crates/root-lockfile/Cargo.toml b/crates/root-lockfile/Cargo.toml index d78f820..81970fa 100644 --- a/crates/root-lockfile/Cargo.toml +++ b/crates/root-lockfile/Cargo.toml @@ -12,3 +12,4 @@ anyhow = "1.0" dirs = "5.0" sha2 = "0.10" hex = "0.4" +thiserror = "1.0" diff --git a/crates/root-lockfile/src/lib.rs b/crates/root-lockfile/src/lib.rs index 27c7fe9..ff8417f 100644 --- a/crates/root-lockfile/src/lib.rs +++ b/crates/root-lockfile/src/lib.rs @@ -5,6 +5,7 @@ use std::collections::BTreeMap; use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; +use thiserror::Error; /// The Rootfile TOML format. #[derive(Debug, Serialize, Deserialize, PartialEq, Default)] @@ -592,8 +593,118 @@ pub fn compute_lock_content_hash(lock: &RootLock) -> String { hex::encode(hasher.finalize()) } +/// Error type for store path validation failures in Root lockfiles. +#[derive(Error, Debug, Clone, PartialEq)] +pub enum StorePathError { + #[error( + "Invalid Root lockfile: package {package} has a derivation path where an output path was expected.\n\nExpected output path:\n /nix/store/...-{package_short}\n\nFound derivation path:\n {found}" + )] + DrvInOutputField { + package: String, + package_short: String, + found: String, + }, + + #[error( + "Invalid Root lockfile: package {package} store path does not start with /nix/store/.\n\nFound:\n {found}" + )] + OutputNotInStore { package: String, found: String }, +} + +fn shorten_package_name(name: &str) -> String { + // Strip output suffix like ".out", ".dev", etc. for display + name.rsplit_once('.') + .map(|(pkg, _)| pkg.to_string()) + .unwrap_or_else(|| name.to_string()) +} + +/// Validate that all store paths in a `RootLockV2` follow the expected conventions. +/// +/// Rules: +/// - `drv_path` field must end in `.drv` or be `None`/empty +/// - All `outputs` values must NOT end in `.drv` +/// - All `store_paths` values must NOT end in `.drv` +/// - `store_path` field must NOT end in `.drv` +/// - Output paths must start with `/nix/store/` +pub fn validate_store_paths(lock: &RootLockV2) -> Result<()> { + for package in &lock.packages { + // drv_path: must end in .drv or be None/empty + if let Some(ref drv_path) = package.drv_path { + if !drv_path.is_empty() && !drv_path.ends_with(".drv") { + anyhow::bail!(StorePathError::OutputNotInStore { + package: format!("{}.drv_path", package.name), + found: drv_path.clone(), + }); + } + } + + // outputs.*.store_path: must NOT end in .drv + for (output_name, output) in &package.outputs { + let path = &output.store_path; + if path.is_empty() { + continue; + } + if path.ends_with(".drv") { + anyhow::bail!(StorePathError::DrvInOutputField { + package: format!("{}.{}", package.name, output_name), + package_short: shorten_package_name(&package.name), + found: path.clone(), + }); + } + if !path.starts_with("/nix/store/") { + anyhow::bail!(StorePathError::OutputNotInStore { + package: format!("{}.{}.store_path", package.name, output_name), + found: path.clone(), + }); + } + } + + // store_paths.*: must NOT end in .drv + for (output_name, path) in &package.store_paths { + if path.is_empty() { + continue; + } + if path.ends_with(".drv") { + anyhow::bail!(StorePathError::DrvInOutputField { + package: format!("{}.{}", package.name, output_name), + package_short: shorten_package_name(&package.name), + found: path.clone(), + }); + } + if !path.starts_with("/nix/store/") { + anyhow::bail!(StorePathError::OutputNotInStore { + package: format!("{}.store_paths.{}", package.name, output_name), + found: path.clone(), + }); + } + } + + // store_path: must NOT end in .drv + if !package.store_path.is_empty() { + if package.store_path.ends_with(".drv") { + anyhow::bail!(StorePathError::DrvInOutputField { + package: package.name.clone(), + package_short: package.name.clone(), + found: package.store_path.clone(), + }); + } + if !package.store_path.starts_with("/nix/store/") { + anyhow::bail!(StorePathError::OutputNotInStore { + package: format!("{}.store_path", package.name), + found: package.store_path.clone(), + }); + } + } + + // binaries: no path validation needed, they're just binary names + // meta, content_hash, etc: not paths, skip + } + Ok(()) +} + #[cfg(test)] mod tests { + #[allow(unused_imports)] use super::*; #[test] @@ -913,4 +1024,177 @@ mod tests { "Rootfile serialization must be deterministic" ); } + + // ── Store path validation tests ───────────────────────────────────── + + fn make_valid_package() -> LockedPackageV2 { + LockedPackageV2 { + name: "ffmpeg".into(), + requested: "ffmpeg".into(), + version: "8.1".into(), + attribute: "ffmpeg".into(), + store_path: "/nix/store/abc123ffmpeg-ffmpeg-8.1".into(), + binaries: vec!["ffmpeg".into()], + installable: Some("nixpkgs#ffmpeg".into()), + drv_path: Some("/nix/store/drv456ffmpeg-ffmpeg-8.1.drv".into()), + outputs: { + let mut m = BTreeMap::new(); + m.insert( + "out".into(), + LockedPackageOutput { + store_path: "/nix/store/abc123ffmpeg-ffmpeg-8.1".into(), + content_hash: None, + nar_hash: None, + references: vec![], + }, + ); + m + }, + store_paths: { + let mut m = BTreeMap::new(); + m.insert("out".into(), "/nix/store/abc123ffmpeg-ffmpeg-8.1".into()); + m + }, + ..Default::default() + } + } + + #[test] + fn test_validate_store_paths_accepts_valid_lock() { + let lock = RootLockV2 { + packages: vec![make_valid_package()], + ..RootLockV2::default() + }; + assert!(validate_store_paths(&lock).is_ok()); + } + + #[test] + fn test_validate_store_paths_rejects_drv_in_outputs() { + let mut pkg = make_valid_package(); + pkg.outputs.insert( + "out".into(), + LockedPackageOutput { + store_path: "/nix/store/abc-ffmpeg-8.1.drv".into(), + content_hash: None, + nar_hash: None, + references: vec![], + }, + ); + let lock = RootLockV2 { + packages: vec![pkg], + ..RootLockV2::default() + }; + let err = validate_store_paths(&lock).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("derivation path where an output path was expected")); + assert!(msg.contains("ffmpeg")); + assert!(msg.contains(".drv")); + } + + #[test] + fn test_validate_store_paths_rejects_drv_in_store_paths() { + let mut pkg = make_valid_package(); + pkg.store_paths + .insert("dev".into(), "/nix/store/abc-ffmpeg-dev.drv".into()); + let lock = RootLockV2 { + packages: vec![pkg], + ..RootLockV2::default() + }; + let err = validate_store_paths(&lock).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("derivation path")); + } + + #[test] + fn test_validate_store_paths_rejects_drv_in_store_path() { + let mut pkg = make_valid_package(); + pkg.store_path = "/nix/store/abc-ffmpeg-8.1.drv".into(); + let lock = RootLockV2 { + packages: vec![pkg], + ..RootLockV2::default() + }; + let err = validate_store_paths(&lock).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("derivation path where an output path was expected")); + } + + #[test] + fn test_validate_store_paths_accepts_drv_in_drv_path() { + let mut pkg = make_valid_package(); + pkg.drv_path = Some("/nix/store/abc-ffmpeg-8.1.drv".into()); + let lock = RootLockV2 { + packages: vec![pkg], + ..RootLockV2::default() + }; + assert!(validate_store_paths(&lock).is_ok()); + } + + #[test] + fn test_validate_store_paths_accepts_empty_drv_path() { + let mut pkg = make_valid_package(); + pkg.drv_path = None; + let lock = RootLockV2 { + packages: vec![pkg], + ..RootLockV2::default() + }; + assert!(validate_store_paths(&lock).is_ok()); + } + + #[test] + fn test_validate_store_paths_rejects_non_store_path() { + let mut pkg = make_valid_package(); + pkg.outputs.insert( + "out".into(), + LockedPackageOutput { + store_path: "/tmp/some-local-path".into(), + content_hash: None, + nar_hash: None, + references: vec![], + }, + ); + let lock = RootLockV2 { + packages: vec![pkg], + ..RootLockV2::default() + }; + let err = validate_store_paths(&lock).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("does not start with /nix/store/")); + } + + #[test] + fn test_validate_store_paths_skips_empty_paths() { + let mut pkg = make_valid_package(); + pkg.outputs.insert( + "dev".into(), + LockedPackageOutput { + store_path: "".into(), + content_hash: None, + nar_hash: None, + references: vec![], + }, + ); + pkg.store_paths.insert("dev".into(), "".into()); + let lock = RootLockV2 { + packages: vec![pkg], + ..RootLockV2::default() + }; + assert!(validate_store_paths(&lock).is_ok()); + } + + #[test] + fn test_validate_store_paths_multiple_packages() { + let mut pkg1 = make_valid_package(); + pkg1.name = "good-pkg".into(); + let mut pkg2 = make_valid_package(); + pkg2.name = "bad-pkg".into(); + pkg2.store_path = "/nix/store/bad-pkg.drv".into(); + let lock = RootLockV2 { + packages: vec![pkg1, pkg2], + ..RootLockV2::default() + }; + let err = validate_store_paths(&lock).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("bad-pkg")); + assert!(msg.contains("derivation path where an output path was expected")); + } } diff --git a/crates/root-nix/src/lib.rs b/crates/root-nix/src/lib.rs index a308fed..744184b 100644 --- a/crates/root-nix/src/lib.rs +++ b/crates/root-nix/src/lib.rs @@ -5,14 +5,96 @@ use thiserror::Error; #[derive(Error, Debug, PartialEq)] pub enum NixError { - #[error("Nix is not installed or not available on PATH")] + #[error( + "Nix is not installed or not found on PATH.\n\n\ + Install Nix using the official installer:\n\ + curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install\n\n\ + Then run:\n root doctor" + )] NotInstalled, - #[error("This package is not available for your Mac architecture.\nTry `root search {0}` to find alternatives.")] + #[error("The package '{0}' is not available for your system architecture.")] PlatformMissing(String), - #[error("Package '{0}' not found in nixpkgs")] + #[error( + "Package '{0}' was not found in the nixpkgs repository.\n\n\ + The package may have been removed or renamed." + )] NotFound(String), - #[error("Nix command failed: {0}")] + #[error( + "Nix is installed but the 'flakes' experimental feature is not enabled.\n\n\ + Root requires flakes to resolve packages.\n\n\ + Enable it in ~/.config/nix/nix.conf:\n experimental-features = nix-command flakes" + )] + FlakesDisabled, + #[error( + "Nix is installed but the 'nix-command' experimental feature is not enabled.\n\n\ + Root requires the nix-command feature to manage profiles.\n\n\ + Enable it in ~/.config/nix/nix.conf:\n experimental-features = nix-command flakes" + )] + NixCommandDisabled, + #[error( + "Could not reach the Nix package repository (nixpkgs).\n\n\ + Check your internet connection and try again." + )] + NixpkgsUnavailable, + #[error( + "Package '{0}' was not found in the nixpkgs repository.\n\n\ + The package may have been removed or renamed." + )] + AttributeMissing(String), + #[error( + "The Nix profile is currently locked by another process.\n\n\ + Wait a moment and try again." + )] + ProfileLocked, + #[error( + "Nix could not update the profile due to a symlink conflict.\n\n\ + This usually happens when the profile path was pre-created as a directory.\n\ + Run 'root doctor' to fix this." + )] + ProfileSymlinkConflict, + #[error( + "Root does not have permission to write to the Nix profile.\n\n\ + You may need to run with appropriate permissions or check\n\ + ~/.root/profiles/default ownership." + )] + PermissionDenied, + #[error( + "The Nix store path for '{0}' has not been built yet.\n\n\ + Run 'root install {0}' again." + )] + StorePathNotRealized(String), + #[error( + "Internal error: a derivation path (.drv) was found where an output path was expected.\n\n\ + This indicates a lockfile or snapshot is corrupted.\n\ + Run 'root doctor' for recovery steps." + )] + DerivationPathAsOutput(String), + #[error( + "Nix returned an unexpected error.\n\n\ + Run with the --json flag to see technical details." + )] Generic(String), + #[error("{0}")] + Internal(String), +} + +impl NixError { + pub fn raw_stderr(&self) -> Option<&str> { + match self { + NixError::Generic(s) => Some(s.as_str()), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ExperimentalFeatureStatus { + AllAvailable, + NixCommandMissing, + FlakesMissing, + BothMissing, + NixpkgsResolutionFailed, + NixNotAvailable, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -78,6 +160,25 @@ pub struct LockedPackageResolution { pub path_info: Vec, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BinaryCheck { + pub name: String, + pub exists: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProfileValidation { + pub profile_exists: bool, + pub generation_before: Option, + pub generation_after: Option, + pub generation_changed: bool, + pub expected_packages_present: bool, + pub missing_output_paths: Vec, + pub binaries: Vec, + pub drv_paths_found: Vec, + pub errors: Vec, +} + pub trait NixAdapter { fn check_availability(&self) -> Result; fn search(&self, package: &str) -> Result; @@ -103,6 +204,116 @@ pub trait NixAdapter { pinned_installable: Option<&str>, ) -> Result; fn path_info(&self, path_or_installable: &str) -> Result; + fn probe_experimental_features(&self) -> Result; + + fn profile_generation(&self) -> Result, NixError>; + fn profile_exists(&self) -> bool; + fn profile_path(&self) -> Result; + + fn validate_profile_mutation( + &self, + before_generation: Option, + expected_packages: &[&str], + expected_binaries: &[&str], + expected_store_paths: &[&str], + ) -> Result { + let mut validation = ProfileValidation { + profile_exists: self.profile_exists(), + generation_before: before_generation, + generation_after: None, + generation_changed: false, + expected_packages_present: true, + missing_output_paths: Vec::new(), + binaries: Vec::new(), + drv_paths_found: Vec::new(), + errors: Vec::new(), + }; + + match self.profile_generation() { + Ok(Some(gen)) => { + validation.generation_after = Some(gen); + validation.generation_changed = before_generation + .map(|before| before != gen) + .unwrap_or(true); + } + Ok(None) => { + validation.generation_changed = false; + } + Err(e) => { + validation + .errors + .push(format!("Failed to check profile generation: {}", e)); + } + } + + if !expected_packages.is_empty() || !expected_store_paths.is_empty() { + match self.profile_list_json() { + Ok(profile_json) => { + for package in expected_packages { + if !profile_json.contains(&format!("\"{}\"", json_escape(package))) { + validation.expected_packages_present = false; + validation.errors.push(format!( + "Profile does not contain expected package: {}", + package + )); + } + } + for path in expected_store_paths { + if path.ends_with(".drv") { + validation.drv_paths_found.push(path.to_string()); + validation + .errors + .push(format!("Expected output path is a .drv path: {}", path)); + validation.expected_packages_present = false; + } else if !profile_json.contains(path) { + validation.expected_packages_present = false; + validation.missing_output_paths.push(path.to_string()); + validation.errors.push(format!( + "Profile does not contain expected store path: {}", + path + )); + } + } + } + Err(e) => { + validation + .errors + .push(format!("Failed to list profile: {}", e)); + } + } + } + + if !expected_binaries.is_empty() { + match self.profile_path() { + Ok(profile_path) => { + let bin_dir = profile_path.join("bin"); + if bin_dir.exists() { + for binary in expected_binaries { + let bin_path = bin_dir.join(binary); + let exists = bin_path.exists(); + validation.binaries.push(BinaryCheck { + name: binary.to_string(), + exists, + }); + if !exists { + validation.errors.push(format!( + "Expected binary '{}' not found in profile bin dir", + binary + )); + } + } + } + } + Err(e) => { + validation + .errors + .push(format!("Cannot determine profile path: {}", e)); + } + } + } + + Ok(validation) + } fn resolve_locked_package( &self, @@ -154,7 +365,7 @@ impl RealNixAdapter { fn profile_path_str(&self) -> Result<&str, NixError> { self.profile_path .to_str() - .ok_or_else(|| NixError::Generic("Profile path is not valid UTF-8".to_string())) + .ok_or_else(|| NixError::Internal("Profile path is not valid UTF-8".to_string())) } fn run_command( @@ -181,33 +392,94 @@ impl RealNixAdapter { } fn normalize_error(stderr: &str, package_context: &str) -> Result { - if stderr.contains("attribute") && stderr.contains("missing from derivation") { - // E.g. "attribute 'aarch64-darwin' missing from derivation" + let trimmed = stderr.trim(); + let lower = stderr.to_lowercase(); + + // Unsupported platform (e.g. "attribute 'aarch64-darwin' missing from derivation") + if lower.contains("attribute") && lower.contains("missing from derivation") { return Err(NixError::PlatformMissing(package_context.to_string())); } - if stderr.contains("error: no outputs found") { + + // Package not found (e.g. "error: no outputs found") + if lower.contains("error: no outputs found") { return Err(NixError::NotFound(package_context.to_string())); } - if stderr.contains("experimental feature") && stderr.contains("is not enabled") { - return Err(NixError::Generic( - "Nix experimental features 'nix-command' and 'flakes' are required.\n\ - To enable them, add this to ~/.config/nix/nix.conf:\n\ - experimental-features = nix-command flakes\n\n\ - Or edit /etc/nix/nix.conf to include the same line." - .to_string(), - )); + + // Flakes disabled + if lower.contains("experimental feature") + && lower.contains("flakes") + && lower.contains("is not enabled") + { + return Err(NixError::FlakesDisabled); + } + + // nix-command disabled + if lower.contains("experimental feature") + && lower.contains("nix-command") + && lower.contains("is not enabled") + { + return Err(NixError::NixCommandDisabled); + } + + // Network / nixpkgs unreachable + if lower.contains("cannot connect") + || lower.contains("could not resolve") + || lower.contains("connection refused") + || lower.contains("temporary failure in name resolution") + || lower.contains("network is unreachable") + || (lower.contains("network") && lower.contains("unreachable")) + { + return Err(NixError::NixpkgsUnavailable); + } + + // Attribute not found in nixpkgs (different from platform-missing attribute above) + if lower.contains("attribute") + && !lower.contains("missing from derivation") + && (lower.contains("not found") + || lower.contains("does not exist") + || lower.contains("in selection path")) + { + return Err(NixError::AttributeMissing(package_context.to_string())); + } + + // Profile locked / busy + if (lower.contains("lock") || lower.contains("busy")) + && (lower.contains("profile") + || lower.contains("lock file") + || lower.contains("acquiring")) + { + return Err(NixError::ProfileLocked); } - if stderr.contains("error: reading symbolic link") || stderr.contains("Invalid argument") { - return Err(NixError::Generic( - "Nix profile path issue detected.\n\ - This can happen when Root's profile path (~/.root/profiles/default)\n\ - conflicts with Nix's symlink management.\n\n\ - Run: root doctor\n\ - To repair, try: rm -rf ~/.root/profiles/default && root init" - .to_string(), + + // Profile symlink conflict + if (lower.contains("reading symbolic link") || lower.contains("invalid argument")) + || (lower.contains("symlink") && lower.contains("conflict")) + { + return Err(NixError::ProfileSymlinkConflict); + } + + // Permission denied + if lower.contains("permission denied") + || lower.contains("not writable") + || (lower.contains("could not open") && lower.contains("profile")) + { + return Err(NixError::PermissionDenied); + } + + // Store path not realized (not built yet) + if lower.contains("store path") && lower.contains("does not exist") { + return Err(NixError::StorePathNotRealized(package_context.to_string())); + } + + // Derivation path mixed with output path + if lower.contains(".drv") && lower.contains("output path") { + return Err(NixError::DerivationPathAsOutput( + package_context.to_string(), )); } - Err(NixError::Generic(stderr.trim().to_string())) + + // Fallback: store raw stderr for --json mode + Err(NixError::Generic(trimmed.to_string())) } fn eval_json_attr( @@ -218,13 +490,41 @@ impl RealNixAdapter { let expr = format!("{}.{}", installable, attr); match Self::run_command(&["eval", "--json", &expr], &[], Some(package)) { Ok(stdout) => Ok(json_string_value(stdout.trim())), - Err(NixError::Generic(_)) => Ok(None), + Err(NixError::Generic(_) | NixError::AttributeMissing(_)) => Ok(None), Err(err) => Err(err), } } } impl NixAdapter for RealNixAdapter { + fn profile_generation(&self) -> Result, NixError> { + if !self.profile_path.exists() { + return Ok(None); + } + let target = std::fs::read_link(&self.profile_path) + .map_err(|e| NixError::Generic(format!("Cannot read profile symlink: {}", e)))?; + let filename = target + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + if let Some(rest) = filename.strip_suffix("-link") { + if let Some((_, gen_str)) = rest.rsplit_once('-') { + if let Ok(gen) = gen_str.parse::() { + return Ok(Some(gen)); + } + } + } + Ok(None) + } + + fn profile_exists(&self) -> bool { + self.profile_path.exists() + } + + fn profile_path(&self) -> Result { + Ok(self.profile_path.clone()) + } + fn check_availability(&self) -> Result { match Self::run_command(&["--version"], &[], None) { Ok(_) => Ok(true), @@ -233,6 +533,37 @@ impl NixAdapter for RealNixAdapter { } } + fn probe_experimental_features(&self) -> Result { + let output = Command::new("nix") + .arg("eval") + .arg("nixpkgs#hello") + .output() + .map_err(|_| NixError::NotInstalled)?; + + if output.status.success() { + return Ok(ExperimentalFeatureStatus::AllAvailable); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + + let nix_cmd_missing = stderr.contains("experimental feature 'nix-command'") + || stderr.contains("'nix-command' is not enabled"); + let flakes_missing = stderr.contains("experimental feature 'flakes'") + || stderr.contains("'flakes' is not enabled"); + + if nix_cmd_missing && flakes_missing { + Ok(ExperimentalFeatureStatus::BothMissing) + } else if nix_cmd_missing { + Ok(ExperimentalFeatureStatus::NixCommandMissing) + } else if flakes_missing { + Ok(ExperimentalFeatureStatus::FlakesMissing) + } else if stderr.contains("cannot find attribute") || stderr.contains("does not exist") { + Ok(ExperimentalFeatureStatus::NixpkgsResolutionFailed) + } else { + Err(NixError::Generic(stderr.trim().to_string())) + } + } + fn search(&self, package: &str) -> Result { Self::run_command(&["search", "nixpkgs", package], &[], Some(package)) } @@ -334,8 +665,25 @@ impl NixAdapter for RealNixAdapter { &[], Some(package), )?; + let store_paths = json_store_paths(&raw_json); + if store_paths.is_empty() { + let all_strings = extract_json_strings(&raw_json); + let has_drv = all_strings.iter().any(|s| s.ends_with(".drv")); + if has_drv { + return Err(NixError::Generic(format!( + "Nix build returned only derivation paths for '{}'. \ + Expected at least one realized output path, but all paths ended in .drv.", + package + ))); + } + return Err(NixError::Generic(format!( + "Nix build returned no output paths for '{}'. \ + Expected at least one realized output store path.", + package + ))); + } let mut outputs = Vec::new(); - for path in json_store_paths(&raw_json) { + for path in store_paths { outputs.push(BuildOutputPath { output_name: if outputs.is_empty() { "out".to_string() @@ -394,6 +742,11 @@ impl NixAdapter for RealNixAdapter { pub struct MockNixAdapter { pub installed: bool, pub installed_packages: std::sync::Mutex>, + pub nix_command_enabled: bool, + pub flakes_enabled: bool, + pub nixpkgs_accessible: bool, + pub generation_counter: std::sync::atomic::AtomicU64, + pub profile_list_json_override: std::sync::Mutex>, } impl MockNixAdapter { @@ -401,9 +754,24 @@ impl MockNixAdapter { Self { installed, installed_packages: std::sync::Mutex::new(Vec::new()), + nix_command_enabled: true, + flakes_enabled: true, + nixpkgs_accessible: true, + generation_counter: std::sync::atomic::AtomicU64::new(1), + profile_list_json_override: std::sync::Mutex::new(None), } } + pub fn increment_generation(&self) -> u64 { + self.generation_counter + .fetch_add(1, std::sync::atomic::Ordering::SeqCst) + + 1 + } + + pub fn set_profile_list_json_override(&self, json: Option) { + *self.profile_list_json_override.lock().unwrap() = json; + } + fn ensure_available_package(&self, package: &str) -> Result<(), NixError> { if !self.installed { return Err(NixError::NotInstalled); @@ -419,6 +787,26 @@ impl MockNixAdapter { } impl NixAdapter for MockNixAdapter { + fn profile_generation(&self) -> Result, NixError> { + if !self.installed { + return Ok(None); + } + Ok(Some( + self.generation_counter + .load(std::sync::atomic::Ordering::Relaxed), + )) + } + + fn profile_exists(&self) -> bool { + self.installed + } + + fn profile_path(&self) -> Result { + if !self.installed { + return Err(NixError::NotInstalled); + } + Ok(PathBuf::from("/tmp/root-mock-profile")) + } fn check_availability(&self) -> Result { if self.installed { Ok(true) @@ -427,6 +815,25 @@ impl NixAdapter for MockNixAdapter { } } + fn probe_experimental_features(&self) -> Result { + if !self.installed { + return Ok(ExperimentalFeatureStatus::NixNotAvailable); + } + if !self.nix_command_enabled && !self.flakes_enabled { + return Ok(ExperimentalFeatureStatus::BothMissing); + } + if !self.nix_command_enabled { + return Ok(ExperimentalFeatureStatus::NixCommandMissing); + } + if !self.flakes_enabled { + return Ok(ExperimentalFeatureStatus::FlakesMissing); + } + if !self.nixpkgs_accessible { + return Ok(ExperimentalFeatureStatus::NixpkgsResolutionFailed); + } + Ok(ExperimentalFeatureStatus::AllAvailable) + } + fn search(&self, package: &str) -> Result { self.ensure_available_package(package)?; Ok(format!("* nixpkgs#{0} (1.0)\n {0} description", package)) @@ -438,6 +845,7 @@ impl NixAdapter for MockNixAdapter { .lock() .unwrap() .push(package.to_string()); + self.increment_generation(); Ok(()) } @@ -447,6 +855,7 @@ impl NixAdapter for MockNixAdapter { .lock() .unwrap() .push(installable.to_string()); + self.increment_generation(); Ok(()) } @@ -472,6 +881,7 @@ impl NixAdapter for MockNixAdapter { } let mut pkgs = self.installed_packages.lock().unwrap(); pkgs.retain(|p| p != package_or_index && package_from_installable(p) != package_or_index); + self.increment_generation(); Ok(()) } @@ -479,6 +889,12 @@ impl NixAdapter for MockNixAdapter { if !self.installed { return Err(NixError::NotInstalled); } + { + let override_guard = self.profile_list_json_override.lock().unwrap(); + if let Some(ref override_json) = *override_guard { + return Ok(override_json.clone()); + } + } let pkgs = self.installed_packages.lock().unwrap(); let entries = pkgs .iter() @@ -782,18 +1198,232 @@ fn sanitize_store_name(value: &str) -> String { mod tests { use super::*; + // ─── Error Normalization Tests ─────────────────────────────────────── + #[test] - fn test_error_normalization() { + fn test_normalize_platform_missing() { let err = RealNixAdapter::normalize_error( - "error: attribute 'aarch64-darwin' missing from derivation", + "error: attribute 'aarch64-darwin' missing from derivation 'xxx'", "poppler", ) .unwrap_err(); assert_eq!(err, NixError::PlatformMissing("poppler".to_string())); + } - let err2 = + #[test] + fn test_normalize_not_found() { + let err = RealNixAdapter::normalize_error("error: no outputs found", "missing_pkg").unwrap_err(); - assert_eq!(err2, NixError::NotFound("missing_pkg".to_string())); + assert_eq!(err, NixError::NotFound("missing_pkg".to_string())); + } + + #[test] + fn test_normalize_flakes_disabled() { + let err = RealNixAdapter::normalize_error( + "error: experimental feature 'flakes' is not enabled\n\ + add '--extra-experimental-features flakes' if you want to use it", + "ffmpeg", + ) + .unwrap_err(); + assert_eq!(err, NixError::FlakesDisabled); + } + + #[test] + fn test_normalize_nix_command_disabled() { + let err = RealNixAdapter::normalize_error( + "error: experimental feature 'nix-command' is not enabled", + "ffmpeg", + ) + .unwrap_err(); + assert_eq!(err, NixError::NixCommandDisabled); + } + + #[test] + fn test_normalize_nix_command_preferred_over_flakes() { + // When both features are mentioned, nix-command check runs second + let err = RealNixAdapter::normalize_error( + "error: experimental feature 'nix-command' is not enabled", + "ffmpeg", + ) + .unwrap_err(); + assert_eq!(err, NixError::NixCommandDisabled); + } + + #[test] + fn test_normalize_nixpkgs_unreachable_connection_refused() { + let err = RealNixAdapter::normalize_error( + "error: cannot connect to daemon at socket\nConnection refused", + "ffmpeg", + ) + .unwrap_err(); + assert_eq!(err, NixError::NixpkgsUnavailable); + } + + #[test] + fn test_normalize_nixpkgs_unreachable_dns_failure() { + let err = RealNixAdapter::normalize_error( + "error: Temporary failure in name resolution", + "ffmpeg", + ) + .unwrap_err(); + assert_eq!(err, NixError::NixpkgsUnavailable); + } + + #[test] + fn test_normalize_nixpkgs_unreachable_network_unreachable() { + let err = + RealNixAdapter::normalize_error("error: Network is unreachable", "ffmpeg").unwrap_err(); + assert_eq!(err, NixError::NixpkgsUnavailable); + } + + #[test] + fn test_normalize_attribute_missing() { + let err = RealNixAdapter::normalize_error( + "error: attribute 'ffmpeg' in selection path 'nixpkgs#ffmpeg' not found", + "ffmpeg", + ) + .unwrap_err(); + assert_eq!(err, NixError::AttributeMissing("ffmpeg".to_string())); + } + + #[test] + fn test_normalize_attribute_does_not_exist() { + let err = RealNixAdapter::normalize_error("error: attribute 'foo' does not exist", "foo") + .unwrap_err(); + assert_eq!(err, NixError::AttributeMissing("foo".to_string())); + } + + #[test] + fn test_normalize_profile_locked() { + let err = RealNixAdapter::normalize_error( + "error: opening lock file '/nix/var/nix/profiles/default.lock'", + "ffmpeg", + ) + .unwrap_err(); + assert_eq!(err, NixError::ProfileLocked); + } + + #[test] + fn test_normalize_profile_busy() { + let err = RealNixAdapter::normalize_error( + "error: profile '/nix/var/nix/profiles/per-user/root/profile' is busy", + "ffmpeg", + ) + .unwrap_err(); + assert_eq!(err, NixError::ProfileLocked); + } + + #[test] + fn test_normalize_symlink_conflict_reading_symlink() { + let err = RealNixAdapter::normalize_error( + "error: reading symbolic link '/nix/var/nix/profiles/default': No such file or directory", + "ffmpeg", + ) + .unwrap_err(); + assert_eq!(err, NixError::ProfileSymlinkConflict); + } + + #[test] + fn test_normalize_symlink_conflict_invalid_argument() { + let err = RealNixAdapter::normalize_error("error: Invalid argument", "ffmpeg").unwrap_err(); + assert_eq!(err, NixError::ProfileSymlinkConflict); + } + + #[test] + fn test_normalize_symlink_conflict_explicit() { + let err = RealNixAdapter::normalize_error("error: symlink conflict in profile", "ffmpeg") + .unwrap_err(); + assert_eq!(err, NixError::ProfileSymlinkConflict); + } + + #[test] + fn test_normalize_permission_denied() { + let err = + RealNixAdapter::normalize_error("error: Permission denied", "ffmpeg").unwrap_err(); + assert_eq!(err, NixError::PermissionDenied); + } + + #[test] + fn test_normalize_permission_denied_not_writable() { + let err = + RealNixAdapter::normalize_error("error: path '/nix/store' is not writable", "ffmpeg") + .unwrap_err(); + assert_eq!(err, NixError::PermissionDenied); + } + + #[test] + fn test_normalize_permission_denied_could_not_open() { + let err = RealNixAdapter::normalize_error( + "error: could not open profile '/nix/var/nix/profiles/default'", + "ffmpeg", + ) + .unwrap_err(); + assert_eq!(err, NixError::PermissionDenied); + } + + #[test] + fn test_normalize_store_path_not_realized() { + let err = RealNixAdapter::normalize_error( + "error: store path '/nix/store/abc-ffmpeg-8.1' does not exist", + "ffmpeg", + ) + .unwrap_err(); + assert_eq!(err, NixError::StorePathNotRealized("ffmpeg".to_string())); + } + + #[test] + fn test_normalize_derivation_path_as_output() { + let err = RealNixAdapter::normalize_error( + "error: path '/nix/store/abc-ffmpeg-8.1.drv' is not an output path", + "ffmpeg", + ) + .unwrap_err(); + assert_eq!(err, NixError::DerivationPathAsOutput("ffmpeg".to_string())); + } + + #[test] + fn test_normalize_fallback_generic() { + let err = RealNixAdapter::normalize_error( + "error: some completely unexpected error happened", + "ffmpeg", + ) + .unwrap_err(); + assert_eq!( + err, + NixError::Generic("error: some completely unexpected error happened".to_string()) + ); + } + + #[test] + fn test_normalize_fallback_preserves_raw_stderr() { + let err = RealNixAdapter::normalize_error( + "error: some random nix failure\nwith multiple lines", + "ffmpeg", + ) + .unwrap_err(); + assert_eq!( + err.raw_stderr(), + Some("error: some random nix failure\nwith multiple lines") + ); + } + + #[test] + fn test_normalize_typed_variants_have_no_raw_stderr() { + let err = RealNixAdapter::normalize_error( + "error: attribute 'aarch64-darwin' missing from derivation", + "poppler", + ) + .unwrap_err(); + assert_eq!(err.raw_stderr(), None); + + let err2 = RealNixAdapter::normalize_error("error: no outputs found", "pkg").unwrap_err(); + assert_eq!(err2.raw_stderr(), None); + } + + #[test] + fn test_normalize_empty_stderr() { + let err = RealNixAdapter::normalize_error("", "ffmpeg").unwrap_err(); + assert_eq!(err, NixError::Generic(String::new())); } #[test] @@ -946,4 +1576,259 @@ mod tests { assert!(!p.ends_with(".drv")); } } + + #[test] + fn test_json_store_paths_returns_empty_when_all_are_drv() { + let json = r#"/nix/store/abc-ffmpeg-8.1.drv"#; + let paths = json_store_paths(json); + assert!( + paths.is_empty(), + "expected no output paths, got {:?}", + paths + ); + } + + #[test] + fn test_json_store_paths_only_drv_in_json_objects() { + let json = r#"{"drvPath":"/nix/store/abc-pkg.drv","outputs":{}}"#; + let paths = json_store_paths(json); + assert!( + paths.is_empty(), + "expected no output paths, got {:?}", + paths + ); + } + + #[test] + fn test_mock_probe_nix_not_installed() { + let mock = MockNixAdapter::new(false); + assert_eq!( + mock.probe_experimental_features().unwrap(), + ExperimentalFeatureStatus::NixNotAvailable + ); + } + + #[test] + fn test_mock_probe_all_available() { + let mock = MockNixAdapter::new(true); + assert_eq!( + mock.probe_experimental_features().unwrap(), + ExperimentalFeatureStatus::AllAvailable + ); + } + + #[test] + fn test_mock_probe_nix_command_missing() { + let mut mock = MockNixAdapter::new(true); + mock.nix_command_enabled = false; + mock.flakes_enabled = true; + assert_eq!( + mock.probe_experimental_features().unwrap(), + ExperimentalFeatureStatus::NixCommandMissing + ); + } + + #[test] + fn test_mock_probe_flakes_missing() { + let mut mock = MockNixAdapter::new(true); + mock.nix_command_enabled = true; + mock.flakes_enabled = false; + assert_eq!( + mock.probe_experimental_features().unwrap(), + ExperimentalFeatureStatus::FlakesMissing + ); + } + + #[test] + fn test_mock_probe_both_missing() { + let mut mock = MockNixAdapter::new(true); + mock.nix_command_enabled = false; + mock.flakes_enabled = false; + assert_eq!( + mock.probe_experimental_features().unwrap(), + ExperimentalFeatureStatus::BothMissing + ); + } + + #[test] + fn test_mock_probe_nixpkgs_resolution_failed() { + let mut mock = MockNixAdapter::new(true); + mock.nix_command_enabled = true; + mock.flakes_enabled = true; + mock.nixpkgs_accessible = false; + assert_eq!( + mock.probe_experimental_features().unwrap(), + ExperimentalFeatureStatus::NixpkgsResolutionFailed + ); + } + + // ─── Profile Validation Tests ─────────────────────────────────────── + + #[test] + fn test_mock_profile_generation_tracking() { + let mock = MockNixAdapter::new(true); + assert_eq!(mock.profile_generation().unwrap(), Some(1)); + + mock.install("ripgrep").unwrap(); + assert_eq!(mock.profile_generation().unwrap(), Some(2)); + + mock.remove("ripgrep").unwrap(); + assert_eq!(mock.profile_generation().unwrap(), Some(3)); + } + + #[test] + fn test_mock_profile_generation_not_installed() { + let mock = MockNixAdapter::new(false); + assert_eq!(mock.profile_generation().unwrap(), None); + } + + #[test] + fn test_mock_profile_exists() { + let mock = MockNixAdapter::new(true); + assert!(mock.profile_exists()); + + let mock2 = MockNixAdapter::new(false); + assert!(!mock2.profile_exists()); + } + + #[test] + fn test_mock_validate_mutation_success() { + let mock = MockNixAdapter::new(true); + let before = mock.profile_generation().unwrap(); + mock.install("ripgrep").unwrap(); + + // Create mock binary so binary check passes + std::fs::create_dir_all("/tmp/root-mock-profile/bin").ok(); + let _ = std::fs::write("/tmp/root-mock-profile/bin/rg", "mock binary content"); + + let mock_path = mock_store_path("ripgrep"); + let result = mock + .validate_profile_mutation(before, &["ripgrep"], &["rg"], &[&mock_path]) + .unwrap(); + + assert!(result.profile_exists); + assert!(result.generation_changed); + assert_eq!(result.generation_before, Some(1)); + assert_eq!(result.generation_after, Some(2)); + assert!(result.expected_packages_present); + assert!(result.missing_output_paths.is_empty()); + assert!(result.errors.is_empty()); + } + + #[test] + fn test_mock_validate_mutation_fails_on_missing_output_path() { + let mock = MockNixAdapter::new(true); + let before = mock.profile_generation().unwrap(); + mock.install("ripgrep").unwrap(); + + // Override profile list json to remove the expected path + mock.set_profile_list_json_override(Some(r#"[{"index":0,"attrPath":"ripgrep","originalUrl":"flake:nixpkgs","installable":"nixpkgs#ripgrep","storePaths":["/nix/store/abc-other-pkg"]}]"#.to_string())); + + let result = mock + .validate_profile_mutation( + before, + &["ripgrep"], + &[], + &["/nix/store/expected-missing-path"], + ) + .unwrap(); + + assert!(result.profile_exists); + assert!(!result.expected_packages_present); + assert!(!result.missing_output_paths.is_empty()); + assert!(result + .errors + .iter() + .any(|e| e.contains("does not contain expected store path"))); + } + + #[test] + fn test_mock_validate_mutation_rejects_drv_path() { + let mock = MockNixAdapter::new(true); + let before = mock.profile_generation().unwrap(); + mock.install("ripgrep").unwrap(); + + let result = mock + .validate_profile_mutation(before, &["ripgrep"], &[], &["/nix/store/abc-ripgrep.drv"]) + .unwrap(); + + assert!(!result.drv_paths_found.is_empty()); + assert!(!result.expected_packages_present); + assert!(result.errors.iter().any(|e| e.contains(".drv path"))); + } + + #[test] + fn test_mock_validate_mutation_no_profile() { + let mock = MockNixAdapter::new(false); + let result = mock.validate_profile_mutation(None, &[], &[], &[]).unwrap(); + + assert!(!result.profile_exists); + assert_eq!(result.generation_before, None); + assert_eq!(result.generation_after, None); + assert!(!result.generation_changed); + assert!(result.expected_packages_present); + assert!(result.errors.is_empty()); + } + + #[test] + fn test_mock_validate_uninstalled_fails_gracefully() { + let mock = MockNixAdapter::new(false); + let result = mock + .validate_profile_mutation( + Some(5), + &["missing-pkg"], + &["binary"], + &["/nix/store/missing-path"], + ) + .unwrap(); + + assert!(!result.profile_exists); + assert_eq!(result.generation_before, Some(5)); + assert_eq!(result.generation_after, None); + assert!(!result.generation_changed); + assert!(result + .errors + .iter() + .any(|e| e.contains("Failed to list profile"))); + assert!(result + .errors + .iter() + .any(|e| e.contains("Cannot determine profile path"))); + } + + #[test] + fn test_mock_validate_generation_unchanged() { + let mock = MockNixAdapter::new(true); + // Don't mutate -- generation stays the same + let before = mock.profile_generation().unwrap(); + + let mock_path = mock_store_path("ripgrep"); + // Override profile list json so it contains the path + mock.set_profile_list_json_override(Some( + format!( + r#"[{{"index":0,"attrPath":"ripgrep","originalUrl":"flake:nixpkgs","installable":"nixpkgs#ripgrep","storePaths":["{}"]}}]"#, + mock_path + ) + )); + + let result = mock + .validate_profile_mutation(before, &["ripgrep"], &[], &[&mock_path]) + .unwrap(); + + assert!(result.profile_exists); + assert!(!result.generation_changed); + assert!(result.expected_packages_present); + } + + #[test] + fn test_mock_validate_generation_incremented_on_install() { + let mock = MockNixAdapter::new(true); + let before = mock.profile_generation().unwrap(); + assert_eq!(before, Some(1)); + mock.install("ripgrep").unwrap(); + + let after = mock.profile_generation().unwrap(); + assert_eq!(after, Some(2)); + assert_ne!(before, after); + } } diff --git a/crates/root-sandbox/Cargo.toml b/crates/root-sandbox/Cargo.toml index 00cc28c..1aa5325 100644 --- a/crates/root-sandbox/Cargo.toml +++ b/crates/root-sandbox/Cargo.toml @@ -10,3 +10,4 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" chrono = { version = "0.4", features = ["serde"] } thiserror = "1.0" +uuid = { version = "1.0", features = ["v4"] } diff --git a/crates/root-sandbox/src/lib.rs b/crates/root-sandbox/src/lib.rs index c115401..9f2caee 100644 --- a/crates/root-sandbox/src/lib.rs +++ b/crates/root-sandbox/src/lib.rs @@ -1,8 +1,36 @@ -use anyhow::Result; +use std::collections::HashMap; use std::process::Command; use std::sync::Mutex; use thiserror::Error; +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum SandboxState { + Created, + Running, + Completed, + Failed, + Destroyed, +} + +impl SandboxState { + fn can_transition_to(&self, target: &SandboxState) -> bool { + matches!( + (self, target), + (SandboxState::Created, SandboxState::Running) + | (SandboxState::Created, SandboxState::Completed) + | (SandboxState::Created, SandboxState::Destroyed) + | (SandboxState::Created, SandboxState::Failed) + | (SandboxState::Created, SandboxState::Created) + | (SandboxState::Running, SandboxState::Completed) + | (SandboxState::Running, SandboxState::Failed) + | (SandboxState::Running, SandboxState::Destroyed) + | (SandboxState::Running, SandboxState::Running) + | (SandboxState::Completed, SandboxState::Destroyed) + | (SandboxState::Failed, SandboxState::Destroyed) + ) + } +} + #[derive(Error, Debug, PartialEq)] pub enum SandboxError { #[error("Sandbox provider is not available: {0}")] @@ -13,6 +41,22 @@ pub enum SandboxError { NotRootOwned(String), #[error("Sandbox operation failed: {0}")] Generic(String), + #[error("Docker is not available or the daemon is not running: {0}")] + DockerUnavailable(String), + #[error("Failed to pull Docker image: {0}")] + ImagePullFailed(String), + #[error("Container failed to start: {0}")] + ContainerStartupFailed(String), + #[error("Sandbox command timed out after {0} seconds")] + TimeoutExceeded(String), + #[error("Resource limit exceeded: {0}")] + ResourceLimitExceeded(String), + #[error("Permission denied: {0}")] + PermissionDenied(String), + #[error("Cleanup failed: {0}")] + CleanupFailed(String), + #[error("Invalid state transition: {0}")] + LifecycleViolation(String), } #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] @@ -20,8 +64,13 @@ pub struct SandboxInstance { pub id: String, pub name: String, pub status: String, + pub state: SandboxState, pub created_at: String, pub image: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cpus: Option, } #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] @@ -33,35 +82,75 @@ pub struct SandboxExecResult { pub trait SandboxProvider { fn check_availability(&self) -> Result; - fn create(&self, name: &str, image: Option<&str>) -> Result; - fn run_command(&self, id: &str, command: &[&str]) -> Result; + fn create( + &self, + name: &str, + image: Option<&str>, + memory: Option<&str>, + cpus: Option<&str>, + ) -> Result; + fn run_command( + &self, + id: &str, + command: &[&str], + timeout_secs: Option, + ) -> Result; fn list(&self) -> Result, SandboxError>; fn destroy(&self, id: &str) -> Result<(), SandboxError>; + fn check_exists(&self, id: &str) -> Result; + fn check_reachable(&self, id: &str) -> Result; } -pub struct RealSandboxProvider; +pub struct RealSandboxProvider { + state: Mutex>, +} impl RealSandboxProvider { pub fn new() -> Self { - Self + Self { + state: Mutex::new(HashMap::new()), + } } fn run_docker(args: &[&str]) -> Result { - let output = Command::new("docker").args(args).output().map_err(|_| { - SandboxError::NotAvailable( - "Docker is not available on PATH. Install Docker Desktop or the Docker CLI.".into(), - ) + let output = Command::new("docker").args(args).output().map_err(|e| { + let msg = format!("Docker command failed: {}", e); + if msg.contains("No such file or directory") + || msg.contains("program not found") + || msg.contains("not found") + { + SandboxError::DockerUnavailable( + "Docker is not available on PATH. Install Docker Desktop or the Docker CLI." + .into(), + ) + } else { + SandboxError::NotAvailable(msg) + } })?; if output.status.success() { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } else { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - Err(SandboxError::Generic(stderr)) + Err(normalize_docker_error(&stderr)) } } } +fn normalize_docker_error(stderr: &str) -> SandboxError { + let lower = stderr.to_lowercase(); + if lower.contains("permission denied") || lower.contains("permission_denied") { + return SandboxError::PermissionDenied(stderr.to_string()); + } + if lower.contains("image") && (lower.contains("pull") || lower.contains("not found")) { + return SandboxError::ImagePullFailed(stderr.to_string()); + } + if lower.contains("oom") || lower.contains("memory") || lower.contains("cpuset") { + return SandboxError::ResourceLimitExceeded(stderr.to_string()); + } + SandboxError::Generic(stderr.to_string()) +} + impl Default for RealSandboxProvider { fn default() -> Self { Self::new() @@ -76,49 +165,123 @@ impl SandboxProvider for RealSandboxProvider { } } - fn create(&self, name: &str, image: Option<&str>) -> Result { + fn create( + &self, + name: &str, + image: Option<&str>, + memory: Option<&str>, + cpus: Option<&str>, + ) -> Result { let image = image.unwrap_or("ubuntu:latest"); let now = chrono::Utc::now().to_rfc3339(); let container_name = format!("root-sandbox-{}", name); + let mem = memory.unwrap_or("2g"); + let cpu = cpus.unwrap_or("2.0"); - // Remove any existing container with same name let _ = Self::run_docker(&["rm", "-f", &container_name]); - Self::run_docker(&[ + let args = vec![ "run", "-d", "--name", &container_name, + "--memory", + mem, + "--cpus", + cpu, image, "sleep", "infinity", - ])?; + ]; + Self::run_docker(&args).map_err(|e| match &e { + SandboxError::Generic(msg) if msg.contains("image") || msg.contains("pull") => { + SandboxError::ImagePullFailed(format!( + "Failed to pull/start image '{}': {}", + image, msg + )) + } + SandboxError::Generic(msg) + if msg.contains("cannot start") || msg.contains("startup") => + { + SandboxError::ContainerStartupFailed(format!("Container failed to start: {}", msg)) + } + _ => e, + })?; let inspect = Self::run_docker(&["inspect", "--format", "{{.Id}}", &container_name])?; - Ok(SandboxInstance { - id: inspect, + let instance = SandboxInstance { + id: inspect.clone(), name: container_name, status: "running".to_string(), + state: SandboxState::Running, created_at: now, image: image.to_string(), - }) + memory: Some(mem.to_string()), + cpus: Some(cpu.to_string()), + }; + + self.state + .lock() + .unwrap() + .insert(inspect, SandboxState::Running); + Ok(instance) } - fn run_command(&self, id: &str, command: &[&str]) -> Result { - let mut args = vec!["exec", id]; - args.extend(command); + fn run_command( + &self, + id: &str, + command: &[&str], + timeout_secs: Option, + ) -> Result { + { + let state_map = self.state.lock().unwrap(); + if let Some(st) = state_map.get(id) { + if *st == SandboxState::Destroyed { + return Err(SandboxError::LifecycleViolation(format!( + "Cannot run command in destroyed sandbox '{}'", + id + ))); + } + } + } + + let timeout = timeout_secs.unwrap_or(300); + let timeout_str = timeout.to_string(); + let mut all_args: Vec<&str> = vec!["exec"]; + + if timeout > 0 { + all_args.push(id); + all_args.push("timeout"); + all_args.push(&timeout_str); + } else { + all_args.push(id); + } + + all_args.extend(command.iter()); let output = Command::new("docker") - .args(&args) + .args(&all_args) .output() - .map_err(|e| SandboxError::Generic(format!("Failed to execute in sandbox: {}", e)))?; - - Ok(SandboxExecResult { - exit_code: output.status.code().unwrap_or(128), - stdout: String::from_utf8_lossy(&output.stdout).to_string(), - stderr: String::from_utf8_lossy(&output.stderr).to_string(), - }) + .map_err(|e| SandboxError::Generic(format!("Failed to exec in container: {}", e)))?; + + let exit_code = output.status.code().unwrap_or(128); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if exit_code == 124 { + Ok(SandboxExecResult { + exit_code: 124, + stdout, + stderr: format!("Command timed out after {} seconds\n{}", timeout, stderr), + }) + } else { + Ok(SandboxExecResult { + exit_code, + stdout, + stderr, + }) + } } fn list(&self) -> Result, SandboxError> { @@ -139,17 +302,26 @@ impl SandboxProvider for RealSandboxProvider { for line in output.lines() { let parts: Vec<&str> = line.split('\t').collect(); if parts.len() >= 4 { - let status = if parts[2].starts_with("Up") { + let is_running = parts[2].starts_with("Up"); + let status = if is_running { "running".to_string() } else { parts[2].to_string() }; + let state = if is_running { + SandboxState::Running + } else { + SandboxState::Completed + }; instances.push(SandboxInstance { id: parts[0].to_string(), name: parts[1].to_string(), status, + state, created_at: String::new(), image: parts[3].to_string(), + memory: None, + cpus: None, }); } } @@ -158,21 +330,71 @@ impl SandboxProvider for RealSandboxProvider { } fn destroy(&self, id: &str) -> Result<(), SandboxError> { - // Inspect the container to verify it is Root-owned - let inspect_output = Self::run_docker(&["inspect", "--format", "{{.Name}}", id]) - .map_err(|_| SandboxError::NotFound(id.to_string()))?; - let container_name = inspect_output.trim_start_matches('/'); - if !container_name.starts_with("root-sandbox-") { - return Err(SandboxError::NotRootOwned(id.to_string())); - } - Self::run_docker(&["rm", "-f", id])?; - Ok(()) + let state_before = { + let state_map = self.state.lock().unwrap(); + state_map.get(id).cloned() + }; + + if let Some(ref st) = state_before { + if *st == SandboxState::Destroyed { + let _ = Self::run_docker(&["rm", "-f", id]); + return Err(SandboxError::LifecycleViolation(format!( + "Sandbox '{}' is already destroyed", + id + ))); + } + } + + let destroy_result = (|| -> Result<(), SandboxError> { + let inspect_output = Self::run_docker(&["inspect", "--format", "{{.Name}}", id]) + .map_err(|_| SandboxError::NotFound(id.to_string()))?; + let container_name = inspect_output.trim_start_matches('/'); + if !container_name.starts_with("root-sandbox-") { + return Err(SandboxError::NotRootOwned(id.to_string())); + } + Self::run_docker(&["rm", "-f", id])?; + Ok(()) + })(); + + { + let mut state_map = self.state.lock().unwrap(); + state_map.insert(id.to_string(), SandboxState::Destroyed); + } + + match destroy_result { + Ok(()) => Ok(()), + Err(e) => { + let _ = Self::run_docker(&["rm", "-f", id]); + Err(e) + } + } + } + + fn check_exists(&self, id: &str) -> Result { + match Self::run_docker(&["inspect", "--format", "{{.Id}}", id]) { + Ok(_) => Ok(true), + Err(SandboxError::NotFound(_)) => Ok(false), + Err(SandboxError::Generic(msg)) if msg.contains("No such object") => Ok(false), + Err(e) => Err(e), + } + } + + fn check_reachable(&self, id: &str) -> Result { + match Self::run_docker(&["exec", id, "echo", "reachable"]) { + Ok(_) => Ok(true), + Err(_) => Ok(false), + } } } pub struct MockSandboxProvider { pub available: bool, pub sandboxes: Mutex>, + pub states: Mutex>, + pub cleanup_attempts: Mutex, + pub simulate_cleanup_failure: Mutex, + pub simulate_timeout: Mutex, + pub simulate_destroy_unavailable: Mutex, } impl MockSandboxProvider { @@ -180,7 +402,30 @@ impl MockSandboxProvider { Self { available, sandboxes: Mutex::new(Vec::new()), + states: Mutex::new(HashMap::new()), + cleanup_attempts: Mutex::new(0), + simulate_cleanup_failure: Mutex::new(false), + simulate_timeout: Mutex::new(false), + simulate_destroy_unavailable: Mutex::new(false), + } + } + + fn validate_transition(&self, id: &str, target: &SandboxState) -> Result<(), SandboxError> { + let states = self.states.lock().unwrap(); + if let Some(current) = states.get(id) { + if !current.can_transition_to(target) { + return Err(SandboxError::LifecycleViolation(format!( + "Invalid state transition: {:?} -> {:?} for sandbox '{}'", + current, target, id + ))); + } } + Ok(()) + } + + fn set_state(&self, id: &str, target: SandboxState) { + let mut states = self.states.lock().unwrap(); + states.insert(id.to_string(), target); } } @@ -189,7 +434,13 @@ impl SandboxProvider for MockSandboxProvider { Ok(self.available) } - fn create(&self, name: &str, image: Option<&str>) -> Result { + fn create( + &self, + name: &str, + image: Option<&str>, + memory: Option<&str>, + cpus: Option<&str>, + ) -> Result { if !self.available { return Err(SandboxError::NotAvailable( "Mock provider not available".into(), @@ -200,25 +451,60 @@ impl SandboxProvider for MockSandboxProvider { let instance = SandboxInstance { id: format!("mock-sandbox-{}", name), name: format!("root-sandbox-{}", name), - status: "running".to_string(), + status: "created".to_string(), + state: SandboxState::Created, created_at: now, image: image.unwrap_or("ubuntu:latest").to_string(), + memory: memory.map(|s| s.to_string()), + cpus: cpus.map(|s| s.to_string()), }; + self.set_state(&instance.id, SandboxState::Created); + self.set_state(&instance.name, SandboxState::Created); self.sandboxes.lock().unwrap().push(instance.clone()); Ok(instance) } - fn run_command(&self, id: &str, command: &[&str]) -> Result { + fn run_command( + &self, + id: &str, + command: &[&str], + _timeout_secs: Option, + ) -> Result { if !self.available { return Err(SandboxError::NotAvailable( "Mock provider not available".into(), )); } + let is_destroyed = { + let states = self.states.lock().unwrap(); + matches!(states.get(id), Some(SandboxState::Destroyed)) + }; + if is_destroyed { + return Err(SandboxError::LifecycleViolation(format!( + "Cannot run command in destroyed sandbox '{}'", + id + ))); + } + let sandboxes = self.sandboxes.lock().unwrap(); - if !sandboxes.iter().any(|s| s.id == id || s.name == id) { - return Err(SandboxError::NotFound(id.to_string())); + let matching = sandboxes.iter().find(|s| s.id == id || s.name == id); + match matching { + None => return Err(SandboxError::NotFound(id.to_string())), + Some(s) => { + self.validate_transition(&s.id, &SandboxState::Running)?; + self.set_state(&s.id, SandboxState::Running); + self.set_state(&s.name, SandboxState::Running); + } + } + + if *self.simulate_timeout.lock().unwrap() { + return Ok(SandboxExecResult { + exit_code: 124, + stdout: String::new(), + stderr: "Command timed out".to_string(), + }); } Ok(SandboxExecResult { @@ -240,117 +526,498 @@ impl SandboxProvider for MockSandboxProvider { } fn destroy(&self, id: &str) -> Result<(), SandboxError> { - if !self.available { + if !self.available && *self.simulate_destroy_unavailable.lock().unwrap() { return Err(SandboxError::NotAvailable( "Mock provider not available".into(), )); } let mut sandboxes = self.sandboxes.lock().unwrap(); - // Find the sandbox first to verify it is Root-owned - let matching = sandboxes.iter().find(|s| s.id == id || s.name == id); - match matching { - None => return Err(SandboxError::NotFound(id.to_string())), - Some(s) => { + let matching_idx = sandboxes.iter().position(|s| s.id == id || s.name == id); + + match matching_idx { + None => { + self.set_state(id, SandboxState::Destroyed); + return Err(SandboxError::NotFound(id.to_string())); + } + Some(idx) => { + let s = &sandboxes[idx]; if !s.name.starts_with("root-sandbox-") { return Err(SandboxError::NotRootOwned(id.to_string())); } + self.validate_transition(&s.id, &SandboxState::Destroyed)?; + self.set_state(&s.id, SandboxState::Destroyed); + self.set_state(&s.name, SandboxState::Destroyed); + sandboxes.remove(idx); } } - sandboxes.retain(|s| s.id != id && s.name != id); + + { + let mut attempts = self.cleanup_attempts.lock().unwrap(); + *attempts += 1; + } + Ok(()) } + + fn check_exists(&self, id: &str) -> Result { + let states = self.states.lock().unwrap(); + Ok(states.contains_key(id) && states.get(id) != Some(&SandboxState::Destroyed)) + } + + fn check_reachable(&self, id: &str) -> Result { + let states = self.states.lock().unwrap(); + Ok(matches!( + states.get(id), + Some(&SandboxState::Running) | Some(&SandboxState::Created) + )) + } } #[cfg(test)] mod tests { use super::*; - #[test] - fn test_mock_availability() { - let mock = MockSandboxProvider::new(true); - assert!(mock.check_availability().unwrap()); - - let mock_unavailable = MockSandboxProvider::new(false); - assert!(!mock_unavailable.check_availability().unwrap()); + fn assert_state(mock: &MockSandboxProvider, id: &str, expected: &SandboxState) { + let states = mock.states.lock().unwrap(); + assert_eq!( + states.get(id), + Some(expected), + "Expected sandbox '{}' to be in state {:?}, but got {:?}", + id, + expected, + states.get(id) + ); } - #[test] - fn test_mock_create_list_destroy() { - let mock = MockSandboxProvider::new(true); + mod phase2_lifecycle { + use super::*; + + #[test] + fn test_state_enum_serialization() { + let state = SandboxState::Running; + let json = serde_json::to_string(&state).unwrap(); + assert_eq!(json, "\"Running\""); + let deserialized: SandboxState = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, SandboxState::Running); + } + + #[test] + fn test_sandbox_instance_uses_state() { + let inst = SandboxInstance { + id: "test-id".into(), + name: "root-sandbox-test".into(), + status: "created".into(), + state: SandboxState::Created, + created_at: "now".into(), + image: "ubuntu:latest".into(), + memory: None, + cpus: None, + }; + assert_eq!(inst.state, SandboxState::Created); + let json = serde_json::to_string(&inst).unwrap(); + assert!(json.contains("\"Created\"")); + } + + #[test] + fn test_valid_transitions() { + let mock = MockSandboxProvider::new(true); + let instance = mock.create("test-transitions", None, None, None).unwrap(); + let id = &instance.id; + + assert_state(&mock, id, &SandboxState::Created); + + mock.run_command(id, &["echo", "hi"], None).unwrap(); + assert_state(&mock, id, &SandboxState::Running); + + mock.destroy(id).unwrap(); + assert_state(&mock, id, &SandboxState::Destroyed); + } + + #[test] + fn test_invalid_transition_destroyed_to_anything() { + let mock = MockSandboxProvider::new(true); + let instance = mock.create("test-invalid", None, None, None).unwrap(); + let id = &instance.id; + + mock.destroy(id).unwrap(); + assert_state(&mock, id, &SandboxState::Destroyed); - let instance = mock.create("test-1", None).unwrap(); - assert_eq!(instance.name, "root-sandbox-test-1"); - assert_eq!(instance.status, "running"); + let err = mock.run_command(id, &["echo", "hi"], None).unwrap_err(); + assert!(matches!(err, SandboxError::LifecycleViolation(_))); + assert!(err.to_string().contains("Invalid state transition")); + } + + #[test] + fn test_invalid_transition_completed_to_running() { + let mock = MockSandboxProvider::new(true); + let instance = mock.create("test-completed", None, None, None).unwrap(); + let id = &instance.id; + + mock.set_state(id, SandboxState::Completed); + + let err = mock.run_command(id, &["echo", "hi"], None).unwrap_err(); + assert!(matches!(err, SandboxError::LifecycleViolation(_))); + } + + #[test] + fn test_repeated_destroy() { + let mock = MockSandboxProvider::new(true); + let instance = mock.create("test-repeated", None, None, None).unwrap(); + let id = &instance.id; - let instances = mock.list().unwrap(); - assert_eq!(instances.len(), 1); + mock.destroy(id).unwrap(); + let err = mock.destroy(id).unwrap_err(); + assert!(matches!(err, SandboxError::NotFound(_))); + } + + #[test] + fn test_destroy_missing_sandbox() { + let mock = MockSandboxProvider::new(true); + let err = mock.destroy("nonexistent").unwrap_err(); + assert!(matches!(err, SandboxError::NotFound(_))); + } + + #[test] + fn test_run_destroyed_sandbox() { + let mock = MockSandboxProvider::new(true); + let instance = mock.create("test-run-destroyed", None, None, None).unwrap(); + let id = &instance.id; + + mock.destroy(id).unwrap(); + let err = mock.run_command(id, &["echo", "hi"], None).unwrap_err(); + assert!(matches!(err, SandboxError::LifecycleViolation(_))); + } - mock.destroy(&instance.id).unwrap(); - assert!(mock.list().unwrap().is_empty()); + #[test] + fn test_invalid_transition_failed_to_running() { + let mock = MockSandboxProvider::new(true); + let instance = mock.create("test-failed", None, None, None).unwrap(); + let id = &instance.id; + + mock.set_state(id, SandboxState::Failed); + + let err = mock.run_command(id, &["echo", "hi"], None).unwrap_err(); + assert!(matches!(err, SandboxError::LifecycleViolation(_))); + } } - #[test] - fn test_mock_run_command() { - let mock = MockSandboxProvider::new(true); - let instance = mock.create("test-run", None).unwrap(); + mod phase3_cleanup { + use super::*; + + #[test] + fn test_cleanup_attempts_tracked() { + let mock = MockSandboxProvider::new(true); + let instance = mock.create("test-cleanup", None, None, None).unwrap(); + assert_eq!(*mock.cleanup_attempts.lock().unwrap(), 0); + + mock.destroy(&instance.id).unwrap(); + assert_eq!(*mock.cleanup_attempts.lock().unwrap(), 1); + + let instance2 = mock.create("test-cleanup-2", None, None, None).unwrap(); + mock.destroy(&instance2.id).unwrap(); + assert_eq!(*mock.cleanup_attempts.lock().unwrap(), 2); + } - let result = mock.run_command(&instance.id, &["echo", "hello"]).unwrap(); - assert_eq!(result.exit_code, 0); - assert!(result.stdout.contains("echo hello")); + #[test] + fn test_double_cleanup() { + let mock = MockSandboxProvider::new(true); + let instance = mock.create("test-double", None, None, None).unwrap(); + mock.destroy(&instance.id).unwrap(); + assert_eq!(*mock.cleanup_attempts.lock().unwrap(), 1); + let err = mock.destroy(&instance.id).unwrap_err(); + assert!(matches!(err, SandboxError::NotFound(_))); + assert_eq!(*mock.cleanup_attempts.lock().unwrap(), 1); + } - let result = mock.run_command(&instance.name, &["ls", "-la"]).unwrap(); - assert!(result.stdout.contains("ls -la")); + #[test] + fn test_failed_run_cleanup_tracking() { + let mock = MockSandboxProvider::new(true); + let instance = mock.create("test-fail-cleanup", None, None, None).unwrap(); + assert_eq!(*mock.cleanup_attempts.lock().unwrap(), 0); + mock.destroy(&instance.id).unwrap(); + assert_eq!(*mock.cleanup_attempts.lock().unwrap(), 1); + } } - #[test] - fn test_mock_destroy_not_found() { - let mock = MockSandboxProvider::new(true); - let err = mock.destroy("nonexistent").unwrap_err(); - assert!(matches!(err, SandboxError::NotFound(_))); + mod phase4_resource_limits { + use super::*; + + #[test] + fn test_create_with_resource_limits_mock() { + let mock = MockSandboxProvider::new(true); + let instance = mock + .create("test-resources", None, Some("4g"), Some("4.0")) + .unwrap(); + assert_eq!(instance.memory.as_deref(), Some("4g")); + assert_eq!(instance.cpus.as_deref(), Some("4.0")); + } + + #[test] + fn test_create_with_default_resources_mock() { + let mock = MockSandboxProvider::new(true); + let instance = mock.create("test-defaults", None, None, None).unwrap(); + assert_eq!(instance.memory, None); + assert_eq!(instance.cpus, None); + } + + #[test] + fn test_resource_limits_serialized() { + let instance = SandboxInstance { + id: "test-id".into(), + name: "root-sandbox-test".into(), + status: "created".into(), + state: SandboxState::Created, + created_at: "now".into(), + image: "ubuntu:latest".into(), + memory: Some("4g".into()), + cpus: Some("4.0".into()), + }; + let json = serde_json::to_string(&instance).unwrap(); + assert!(json.contains("\"4g\"")); + assert!(json.contains("\"4.0\"")); + } } - #[test] - fn test_mock_destroy_root_owned_container() { - let mock = MockSandboxProvider::new(true); - mock.create("my-sandbox", None).unwrap(); - mock.destroy("root-sandbox-my-sandbox").unwrap(); - assert!(mock.list().unwrap().is_empty()); + mod phase5_timeout { + use super::*; + + #[test] + fn test_timeout_returns_exit_code_124() { + let mock = MockSandboxProvider::new(true); + *mock.simulate_timeout.lock().unwrap() = true; + let instance = mock.create("test-timeout", None, None, None).unwrap(); + + let result = mock + .run_command(&instance.id, &["sleep", "10"], Some(1)) + .unwrap(); + assert_eq!(result.exit_code, 124); + assert!(result.stderr.contains("timed out")); + } + + #[test] + fn test_timeout_not_triggered_with_sufficient_time() { + let mock = MockSandboxProvider::new(true); + let instance = mock.create("test-no-timeout", None, None, None).unwrap(); + + let result = mock + .run_command(&instance.id, &["echo", "hello"], Some(300)) + .unwrap(); + assert_eq!(result.exit_code, 0); + } + + #[test] + fn test_default_timeout_300() { + let mock = MockSandboxProvider::new(true); + let instance = mock + .create("test-default-timeout", None, None, None) + .unwrap(); + + let result = mock + .run_command(&instance.id, &["echo", "hello"], None) + .unwrap(); + assert_eq!(result.exit_code, 0); + } } - #[test] - fn test_mock_destroy_rejects_non_root_container() { - let mock = MockSandboxProvider::new(true); - // Manually insert a container that does not have root-sandbox- prefix - { - let mut sandboxes = mock.sandboxes.lock().unwrap(); - sandboxes.push(SandboxInstance { - id: "ext-123".to_string(), - name: "external-container".to_string(), - status: "running".to_string(), - created_at: chrono::Utc::now().to_rfc3339(), - image: "ubuntu:latest".to_string(), - }); + mod phase6_validation { + use super::*; + + #[test] + fn test_check_exists_returns_true_for_existing() { + let mock = MockSandboxProvider::new(true); + let instance = mock.create("test-exists", None, None, None).unwrap(); + assert!(mock.check_exists(&instance.id).unwrap()); + } + + #[test] + fn test_check_exists_returns_false_for_destroyed() { + let mock = MockSandboxProvider::new(true); + let instance = mock.create("test-gone", None, None, None).unwrap(); + mock.destroy(&instance.id).unwrap(); + assert!(!mock.check_exists(&instance.id).unwrap()); + } + + #[test] + fn test_check_exists_returns_false_for_unknown() { + let mock = MockSandboxProvider::new(true); + assert!(!mock.check_exists("unknown").unwrap()); + } + + #[test] + fn test_check_reachable_returns_true_for_running() { + let mock = MockSandboxProvider::new(true); + let instance = mock.create("test-reachable", None, None, None).unwrap(); + assert!(mock.check_reachable(&instance.id).unwrap()); + } + + #[test] + fn test_check_reachable_returns_false_for_destroyed() { + let mock = MockSandboxProvider::new(true); + let instance = mock.create("test-unreachable", None, None, None).unwrap(); + mock.destroy(&instance.id).unwrap(); + assert!(!mock.check_reachable(&instance.id).unwrap()); } - let err = mock.destroy("external-container").unwrap_err(); - assert!(matches!(err, SandboxError::NotRootOwned(_))); } - #[test] - fn test_mock_destroy_by_id_root_owned() { - let mock = MockSandboxProvider::new(true); - let instance = mock.create("test-1", None).unwrap(); - mock.destroy(&instance.id).unwrap(); - assert!(mock.list().unwrap().is_empty()); + mod phase8_error_normalization { + use super::*; + + #[test] + fn test_error_display_not_available() { + let err = SandboxError::NotAvailable("Docker not found".into()); + let msg = format!("{}", err); + assert!(msg.contains("not available")); + assert!(msg.contains("Docker not found")); + } + + #[test] + fn test_error_display_not_found() { + let err = SandboxError::NotFound("sandbox-123".into()); + let msg = format!("{}", err); + assert!(msg.contains("not found")); + assert!(msg.contains("sandbox-123")); + } + + #[test] + fn test_error_display_lifecycle_violation() { + let err = SandboxError::LifecycleViolation( + "Invalid state transition: Destroyed -> Running".into(), + ); + let msg = format!("{}", err); + assert!(msg.contains("Invalid state transition")); + } + + #[test] + fn test_error_display_timeout() { + let err = SandboxError::TimeoutExceeded("300".into()); + let msg = format!("{}", err); + assert!(msg.contains("timed out")); + assert!(msg.contains("300")); + } + + #[test] + fn test_error_display_cleanup_failed() { + let err = SandboxError::CleanupFailed("rm failed".into()); + let msg = format!("{}", err); + assert!(msg.contains("Cleanup failed")); + assert!(msg.contains("rm failed")); + } + + #[test] + fn test_error_display_permission_denied() { + let err = SandboxError::PermissionDenied("access denied".into()); + let msg = format!("{}", err); + assert!(msg.contains("Permission denied")); + assert!(msg.contains("access denied")); + } + + #[test] + fn test_error_display_resource_limit() { + let err = SandboxError::ResourceLimitExceeded("OOM killed".into()); + let msg = format!("{}", err); + assert!(msg.contains("Resource limit exceeded")); + assert!(msg.contains("OOM killed")); + } } - #[test] - fn test_mock_unavailable_errors() { - let mock = MockSandboxProvider::new(false); + mod existing_tests_migrated { + use super::*; - assert!(mock.create("x", None).is_err()); - assert!(mock.run_command("x", &["echo"]).is_err()); - assert!(mock.list().is_err()); - assert!(mock.destroy("x").is_err()); + #[test] + fn test_mock_availability() { + let mock = MockSandboxProvider::new(true); + assert!(mock.check_availability().unwrap()); + + let mock_unavailable = MockSandboxProvider::new(false); + assert!(!mock_unavailable.check_availability().unwrap()); + } + + #[test] + fn test_mock_create_list_destroy() { + let mock = MockSandboxProvider::new(true); + + let instance = mock.create("test-1", None, None, None).unwrap(); + assert_eq!(instance.name, "root-sandbox-test-1"); + assert_eq!(instance.state, SandboxState::Created); + + let instances = mock.list().unwrap(); + assert_eq!(instances.len(), 1); + + mock.destroy(&instance.id).unwrap(); + assert!(mock.list().unwrap().is_empty()); + } + + #[test] + fn test_mock_run_command() { + let mock = MockSandboxProvider::new(true); + let instance = mock.create("test-run-migrated", None, None, None).unwrap(); + + let result = mock + .run_command(&instance.id, &["echo", "hello"], None) + .unwrap(); + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("echo hello")); + + let result = mock + .run_command(&instance.name, &["ls", "-la"], None) + .unwrap(); + assert!(result.stdout.contains("ls -la")); + } + + #[test] + fn test_mock_destroy_not_found() { + let mock = MockSandboxProvider::new(true); + let err = mock.destroy("nonexistent").unwrap_err(); + assert!(matches!(err, SandboxError::NotFound(_))); + } + + #[test] + fn test_mock_destroy_root_owned_container() { + let mock = MockSandboxProvider::new(true); + mock.create("my-sandbox", None, None, None).unwrap(); + + let result = mock.destroy("root-sandbox-my-sandbox"); + assert!(result.is_ok(), "expected ok: {:?}", result); + assert!(mock.list().unwrap().is_empty()); + } + + #[test] + fn test_mock_destroy_rejects_non_root_container() { + let mock = MockSandboxProvider::new(true); + { + let mut sandboxes = mock.sandboxes.lock().unwrap(); + sandboxes.push(SandboxInstance { + id: "ext-123".to_string(), + name: "external-container".to_string(), + status: "running".to_string(), + state: SandboxState::Running, + created_at: chrono::Utc::now().to_rfc3339(), + image: "ubuntu:latest".to_string(), + memory: None, + cpus: None, + }); + } + let err = mock.destroy("external-container").unwrap_err(); + assert!(matches!(err, SandboxError::NotRootOwned(_))); + } + + #[test] + fn test_mock_destroy_by_id_root_owned() { + let mock = MockSandboxProvider::new(true); + let instance = mock.create("test-1", None, None, None).unwrap(); + mock.destroy(&instance.id).unwrap(); + assert!(mock.list().unwrap().is_empty()); + } + + #[test] + fn test_mock_unavailable_errors() { + let mock = MockSandboxProvider::new(false); + + assert!(mock.create("x", None, None, None).is_err()); + assert!(mock.run_command("x", &["echo"], None).is_err()); + assert!(mock.list().is_err()); + assert!(mock.destroy("x").is_err()); + } } } From f45eb530f99fa644235b58f97c8fef33d4b9040a Mon Sep 17 00:00:00 2001 From: Sergio Rovira Date: Wed, 24 Jun 2026 02:12:18 -0400 Subject: [PATCH 4/5] Fix sandbox audit doc version number --- Docs/Sandbox/{V0_2_1_SANDBOX_AUDIT.md => V0_2_3_SANDBOX_AUDIT.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Docs/Sandbox/{V0_2_1_SANDBOX_AUDIT.md => V0_2_3_SANDBOX_AUDIT.md} (100%) diff --git a/Docs/Sandbox/V0_2_1_SANDBOX_AUDIT.md b/Docs/Sandbox/V0_2_3_SANDBOX_AUDIT.md similarity index 100% rename from Docs/Sandbox/V0_2_1_SANDBOX_AUDIT.md rename to Docs/Sandbox/V0_2_3_SANDBOX_AUDIT.md From ef05dc01d411f2b6f89cb88e5aff0276a34d6cd0 Mon Sep 17 00:00:00 2001 From: Sergio Rovira Date: Wed, 24 Jun 2026 02:16:09 -0400 Subject: [PATCH 5/5] Bump version to 0.2.3 --- Cargo.lock | 18 +++++++++--------- Cargo.toml | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 837d538..f0c7ab2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -461,7 +461,7 @@ dependencies = [ [[package]] name = "root-agent" -version = "0.2.1" +version = "0.2.3" dependencies = [ "serde", "serde_json", @@ -469,7 +469,7 @@ dependencies = [ [[package]] name = "root-cli" -version = "0.2.1" +version = "0.2.3" dependencies = [ "anyhow", "clap", @@ -483,7 +483,7 @@ dependencies = [ [[package]] name = "root-core" -version = "0.2.1" +version = "0.2.3" dependencies = [ "anyhow", "chrono", @@ -500,7 +500,7 @@ dependencies = [ [[package]] name = "root-doctor" -version = "0.2.1" +version = "0.2.3" dependencies = [ "anyhow", "root-lockfile", @@ -511,7 +511,7 @@ dependencies = [ [[package]] name = "root-lockfile" -version = "0.2.1" +version = "0.2.3" dependencies = [ "anyhow", "dirs", @@ -525,7 +525,7 @@ dependencies = [ [[package]] name = "root-nix" -version = "0.2.1" +version = "0.2.3" dependencies = [ "anyhow", "dirs", @@ -534,7 +534,7 @@ dependencies = [ [[package]] name = "root-sandbox" -version = "0.2.1" +version = "0.2.3" dependencies = [ "anyhow", "chrono", @@ -546,7 +546,7 @@ dependencies = [ [[package]] name = "root-snapshot" -version = "0.2.1" +version = "0.2.3" dependencies = [ "anyhow", "chrono", @@ -557,7 +557,7 @@ dependencies = [ [[package]] name = "root-verify" -version = "0.2.1" +version = "0.2.3" dependencies = [ "anyhow", "root-lockfile", diff --git a/Cargo.toml b/Cargo.toml index 55dadd7..5fc41c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,6 @@ members = [ resolver = "2" [workspace.package] -version = "0.2.1" +version = "0.2.3" edition = "2021" authors = ["Root Contributors"]