diff --git a/CHANGELOG.md b/CHANGELOG.md index f5ec48e..07956ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,45 @@ 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.4] - 2026-06-24 + +### Added + +- **Restore audit.** Full restore subsystem audit at Docs/Restore/V0_2_4_RESTORE_AUDIT.md covering entry points, validation flow, Nix operations, mutation flow, event recording, rollback/recovery, drift detection, 13 failure modes, and 10 gaps. (Phase 1) +- **Dry-run support.** `root restore root.lock --dry-run` reports the restore plan (will install, remove, keep, update) without mutating the Rootfile, root.lock, or Nix profile. It does append a `RestorePlanned` event to the event ledger. Supports human and JSON output. (Phase 2) +- **Pre-restore validation.** Lockfile schema, store paths, platform compatibility, Nix availability, and experimental features (nix-command, flakes) are validated before any mutation. `.drv` paths in outputs are rejected with clear errors. A missing Root profile is allowed; restore creates it. (Phase 3) +- **Partial failure recovery.** If restore fails mid-operation, Root captures a pre-restore snapshot and restores Rootfile, root.lock, and the Nix profile from that snapshot. If recovery fails, clear instructions are provided for manual rollback. (Phase 4) +- **Strengthened drift detection.** `root status` now detects missing output paths per package, `.drv` paths in lockfiles, and platform mismatches in addition to existing name-based drift checks. (Phase 5) +- **Restore event ledger.** New `RestorePlanned` and `RestoreRecovered` event types, new `Planned` event status, and event fields for `failure_phase`, `installed_count`, `removed_count`, `kept_count`. Restore operations record detailed events at every stage. (Phase 6) +- **Restore error normalization.** Clear, actionable error messages for all restore failure modes: invalid lockfile, incompatible platform, missing Nix, missing experimental features, `.drv` output paths, profile validation failure, partial restore failure, failed recovery, stale mutation lock, and permission denied. (Phase 7) +- **Restore smoke tests.** New smoke test document at Docs/Release/V0_2_4_RESTORE_SMOKE_TEST.md covering clean restore, dry-run, invalid lockfile, partial failure, and drift detection scenarios. (Phase 8) +- **Restore documentation.** New reference document at Docs/Restore/V0_2_4_RESTORE_NOTES.md. (Phase 9) + +### Changed + +- README updated for v0.2.4. +- `RootEventType` gains `RestorePlanned` and `RestoreRecovered` variants. +- `RootEventStatus` gains `Planned` variant. +- `RootEvent` gains `failure_phase`, `installed_count`, `removed_count`, `kept_count` fields. +- `RestoreReport` renamed conceptually — restore output now includes automatic rollback reporting on failure. +- Validation failures now record a `Restore` / `Failed` event before returning. + +### Fixed + +- Restore no longer requires an existing Nix profile; a missing `~/.root/profiles/default` is created by `nix profile add` during restore. +- Automatic restore recovery now rewrites `root.lock` and Rootfile from the pre-restore snapshot, not only the Nix profile. +- `root status` marks `platform-mismatch` as unhealthy (`NeedsAttention`). + +### Tests Added + +- `test_restore_partial_failure_rolls_back_profile` — mid-restore install failure automatically rolls back the Nix profile and preserves Rootfile/`root.lock`. +- `test_restore_with_no_existing_lockfile` — restore from a shared lock when no local `root.lock` exists. +- `test_restore_from_v1_lockfile` — v1 lock fallback via `to_v2()`. +- `test_restore_recovers_stale_mutation_lock` — dead-PID `root.lockfile` is recovered and restore proceeds. +- `test_restore_blocked_by_live_mutation_lock` — live mutation lock blocks restore. +- `test_restore_creates_missing_profile` — restore proceeds and installs when the Root profile does not exist yet. +- `test_restore_rollback_restores_lock_and_rootfile` — auto-rollback rewrites Rootfile and `root.lock` from the snapshot. + ## [0.2.3] - 2026-06-24 ### Added diff --git a/Cargo.lock b/Cargo.lock index f0c7ab2..a198b34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -461,7 +461,7 @@ dependencies = [ [[package]] name = "root-agent" -version = "0.2.3" +version = "0.2.4" dependencies = [ "serde", "serde_json", @@ -469,7 +469,7 @@ dependencies = [ [[package]] name = "root-cli" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "clap", @@ -483,7 +483,7 @@ dependencies = [ [[package]] name = "root-core" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "chrono", @@ -500,7 +500,7 @@ dependencies = [ [[package]] name = "root-doctor" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "root-lockfile", @@ -511,7 +511,7 @@ dependencies = [ [[package]] name = "root-lockfile" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "dirs", @@ -525,7 +525,7 @@ dependencies = [ [[package]] name = "root-nix" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "dirs", @@ -534,7 +534,7 @@ dependencies = [ [[package]] name = "root-sandbox" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "chrono", @@ -546,7 +546,7 @@ dependencies = [ [[package]] name = "root-snapshot" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "chrono", @@ -557,7 +557,7 @@ dependencies = [ [[package]] name = "root-verify" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "root-lockfile", diff --git a/Cargo.toml b/Cargo.toml index 5fc41c0..66bcd61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,6 @@ members = [ resolver = "2" [workspace.package] -version = "0.2.3" +version = "0.2.4" edition = "2021" authors = ["Root Contributors"] diff --git a/Docs/Release/V0_2_4_RESTORE_SMOKE_TEST.md b/Docs/Release/V0_2_4_RESTORE_SMOKE_TEST.md new file mode 100644 index 0000000..9d1b8ae --- /dev/null +++ b/Docs/Release/V0_2_4_RESTORE_SMOKE_TEST.md @@ -0,0 +1,642 @@ +# Root v0.2.4 Restore Reliability Smoke Test + +Manual release validation focused on restore reliability: clean restore, dry run, +invalid lockfile rejection, partial failure with automatic rollback, drift +detection, and pre-mutation validation hardening. + +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.4`. + +--- + +## Prerequisites + +- macOS (Apple Silicon or Intel) or Linux +- Nix installed with `nix-command` and `flakes` experimental features enabled +- Internet access (for Nix builds and binary cache) +- No existing `~/.root` directory (or back it up before these tests) +- `root` binary built from the v0.2.4 tag + +--- + +## 1. Clean Restore + +Basic happy path: fresh install, backup lockfile, wipe state, restore from the +backup, verify the system is clean. + +```bash +rm -rf ~/.root +root install terraform +``` + +**Expected:** +- Install succeeds. +- `terraform version` reports a version. +- Snapshot saved. + +```bash +cp ~/.root/root.lock /tmp/root.lock +rm -rf ~/.root +root restore /tmp/root.lock +``` + +**Expected:** +- Restore begins from the saved lockfile. +- Output shows: + - `Restored Root profile from /tmp/root.lock` + - `Installed: terraform.` + - `Snapshot saved: ` +- Nix profile is recreated with terraform. +- Exit code 0. + +```bash +root status +``` + +**Expected:** +- State is `Healthy`. +- `rootfile_packages`, `lockfile_packages`, and `profile_packages` all match + (each `1` for terraform). +- No drift issues listed. +- Exit code 0. + +```bash +root verify terraform +``` + +**Expected:** +- Binary `terraform` found in `~/.root/profiles/default/bin/terraform`. +- `terraform version` executes successfully. +- Verification SUCCESS. +- Exit code 0. + +```bash +root history --limit 5 +``` + +**Expected:** +- Install event for terraform with status `Completed`. +- Restore event with type `Restore` and status `Completed`. +- Both events have timestamps and snapshot IDs. + +--- + +## 2. Dry Run + +Verify that `--dry-run` shows a clear plan without mutating anything. + +**Setup:** Ensure you have a working Root state (from Test 1) and a lockfile +at `/tmp/root.lock` that differs from the current profile (e.g., contains +different packages). + +```bash +# Add a second package to a copy of the lockfile +cp /tmp/root.lock /tmp/root-extended.lock +# Manually add ffmpeg to the lockfile packages list +python3 -c " +import json +with open('/tmp/root-extended.lock') as f: + lock = json.load(f) +# Add ffmpeg (simulate a lockfile with more packages) +import copy +ffmpeg_pkg = copy.deepcopy(lock['packages'][0]) +ffmpeg_pkg['name'] = 'ffmpeg' +ffmpeg_pkg['installable'] = lock['packages'][0]['installable'].replace('terraform', 'ffmpeg') +ffmpeg_pkg['store_path'] = lock['packages'][0]['store_path'].replace('terraform', 'ffmpeg') +ffmpeg_pkg['store_paths'] = {k: v.replace('terraform', 'ffmpeg') for k, v in ffmpeg_pkg['store_paths'].items()} +ffmpeg_pkg['outputs'] = {k: type(v)(v.replace('terraform', 'ffmpeg')) if isinstance(v, str) else v for k, v in ffmpeg_pkg.get('outputs', {}).items()} +lock['packages'].append(ffmpeg_pkg) +with open('/tmp/root-extended.lock', 'w') as f: + json.dump(lock, f, indent=2) +print('Added ffmpeg to lockfile') +" +``` + +```bash +root restore /tmp/root-extended.lock --dry-run +``` + +**Expected:** +- Output shows `Restore plan` with clearly labelled sections: + - `Will install:` (ffmpeg). + - `Will keep:` (terraform — already installed). + - `Will remove:` (empty or absent if nothing to remove). + - `Will update:` (empty or absent). +- Total packages listed matches lockfile content (2). +- **No mutation occurred.** +- `~/.root/profiles/default/bin/` does NOT contain ffmpeg. +- Exit code 0. + +**Dry run with no changes:** + +```bash +root restore /tmp/root.lock --dry-run +``` + +**Expected:** +- Plan shows `No changes needed.` +- All packages listed as `Will keep:`. +- Exit code 0. + +### JSON dry run + +```bash +root restore /tmp/root-extended.lock --dry-run --json +``` + +**Expected:** +- Valid JSON with fields: `success`, `lock_path`, `will_install`, `will_remove`, + `will_keep`, `will_update`, `total_packages`. +- `success` is `true`. +- `will_install` contains `["ffmpeg"]`. +- Exit code 0. + +**Cleanup:** + +```bash +rm -f /tmp/root-extended.lock +``` + +--- + +## 3. Invalid Lockfile (`.drv` Injection) + +Inject a `.drv` path into the lockfile's output path and verify Root rejects the +restore **before** any Nix mutation or profile change. + +**Setup:** Start from a clean, valid Root state (terraform installed from Test 1). + +```bash +# Save a clean copy +cp /tmp/root.lock /tmp/root-clean.lock + +# Inject .drv paths into the output fields +cp /tmp/root.lock /tmp/root-drv.lock +python3 -c " +import json +with open('/tmp/root-drv.lock') as f: + lock = json.load(f) +if lock['packages']: + lock['packages'][0]['store_path'] = '/nix/store/xxxxx-fake-0.0.0.drv' + for output_name in lock['packages'][0].get('outputs', {}): + lock['packages'][0]['outputs'][output_name] = '/nix/store/xxxxx-fake-0.0.0.drv' + for key in lock['packages'][0].get('store_paths', {}): + lock['packages'][0]['store_paths'][key] = '/nix/store/xxxxx-fake-0.0.0.drv' +with open('/tmp/root-drv.lock', 'w') as f: + json.dump(lock, f, indent=2) +" +``` + +```bash +root restore /tmp/root-drv.lock +``` + +**Expected:** +- Root **refuses** the mutation with a clear error message. +- Error mentions `.drv` path or derivation path in store path / output. +- Error mentions "failed validation before restore". +- **No mutation occurred** — the Nix profile is unchanged. +- `~/.root/profiles/default/bin/terraform` still works. +- Exit code 4 (verification failure). + +```bash +root verify terraform +``` + +**Expected:** +- terraform still functional — the restore was rejected before any changes. +- Verification SUCCESS. + +```bash +root status +``` + +**Expected:** +- State is `Healthy` (no drift). +- Original terraform install intact. + +**Cleanup:** + +```bash +cp /tmp/root-clean.lock ~/.root/root.lock +rm -f /tmp/root-drv.lock /tmp/root-clean.lock +``` + +--- + +## 4. Partial Failure with Automatic Rollback + +Simulate a scenario where a restoration begins but one or more packages fail to +install. Root must preserve the previous state, record a failure event, and +automatically roll back the Nix profile. + +**Setup method A — mock an unavailable package via the lockfile:** + +Use a lockfile that references a package known to be unavailable. + +```bash +# Create a lockfile with a package that resolves to a known-bad Nix attribute +cp /tmp/root.lock /tmp/root-bad.lock +python3 -c " +import json +with open('/tmp/root-bad.lock') as f: + lock = json.load(f) +# Reuse the first package's structure but point to 'missing_pkg' +# which the MockNixAdapter treats as NotFound (or use a real +# attribute that Nix will fail to resolve) +lock['packages'][0]['name'] = 'nonexistent-pkg' +lock['packages'][0]['installable'] = 'nixpkgs#nonexistent-pkg' +with open('/tmp/root-bad.lock', 'w') as f: + json.dump(lock, f, indent=2) +" +``` + +If the mock adapter is not available in production, alternatively corrupt the +profile path so Nix fails mid-mutation: + +```bash +# Alternative setup: install a package, save state, then restore +# a lockfile with a package whose Nix attribute will fail +root install ffmpeg +cp ~/.root/root.lock /tmp/root-ffmpeg.lock +``` + +Then attempt a restore from a lockfile containing a package that +cannot be resolved: + +```bash +root restore /tmp/root-bad.lock +``` + +**Expected:** +- Restore fails with an error message. +- Error indicates the failure phase (e.g., "package installation" or + "pre-restore validation"). +- **Previous Rootfile and root.lock are preserved.** +- **Automatic rollback** message appears: "Root automatically rolled + back your Nix profile to its pre-restore state." +- Profile is restored to the state before the failed restore. +- Exit code 1. + +```bash +root status +``` + +**Expected:** +- State is `Healthy`. +- Previous packages still listed (terraform or ffmpeg from setup). + +```bash +root verify terraform +``` + +**Expected:** +- terraform still functions. +- Verification SUCCESS. + +```bash +root history --limit 5 +``` + +**Expected:** +- A `Restore` event with status `Failed`. +- A `RestoreRecovered` event with status `Completed` (automatic rollback). +- Error details recorded in the failure event. + +**Cleanup:** + +```bash +rm -f /tmp/root-bad.lock /tmp/root-ffmpeg.lock +``` + +--- + +## 5. Drift Detection + +Create intentional mismatches between Rootfile, lockfile, and Nix profile, then +verify `root status` explains each drift clearly. + +### 5a. Rootfile-Lockfile Mismatch + +Add a package to Rootfile without running `root lock`. + +```bash +echo '[packages] +bat = "latest"' >> ~/.root/Rootfile +``` + +```bash +root status +``` + +**Expected:** +- State is `Drifted`. +- A drift issue with category `rootfile-lockfile-mismatch`: + `"Package 'bat' is in Rootfile but not in root.lock"`. +- Suggestion: `"Run 'root lock' to regenerate root.lock from Rootfile intent"`. + +Revert: + +```bash +# Remove the bat entry from Rootfile +python3 -c " +import toml +with open('$HOME/.root/Rootfile') as f: + rf = toml.load(f) +rf.get('packages', {}).pop('bat', None) +with open('$HOME/.root/Rootfile', 'w') as f: + toml.dump(rf, f) +" +``` + +### 5b. Lockfile-Profile Mismatch (Missing from Profile) + +Manually remove a package from the Nix profile without updating the lockfile. + +```bash +# Using Nix directly to remove terraform from the profile +nix profile remove ~/.root/profiles/default terraform 2>/dev/null || true +``` + +```bash +root status +``` + +**Expected:** +- State is `NeedsAttention`. +- A drift issue with category `lockfile-profile-mismatch`: + `"Package 'terraform' is in root.lock but not in Nix profile"`. +- Suggestion: `"Run 'root sync' to install the locked package"`. + +```bash +# Reinstall via sync +root sync +root status +``` + +**Expected:** +- terraform reinstalled. +- State returns to `Healthy`. +- No drift issues. + +### 5c. Profile-Lockfile Mismatch (Extra in Profile) + +Install a package via Nix directly (outside Root), creating a package in the +profile that the lockfile does not know about. + +```bash +nix profile install ~/.root/profiles/default nixpkgs#bat 2>/dev/null || true + +# If the above doesn't work, simulate by adding a package directly +nix profile add --profile ~/.root/profiles/default nixpkgs#bat 2>/dev/null || true +``` + +```bash +root status +``` + +**Expected:** +- State is `Drifted`. +- A drift issue with category `profile-lockfile-mismatch`: + `"Package 'bat' is in Nix profile but not in root.lock"`. +- Suggestion: `"Run 'root sync' to remove the extra package"`. + +```bash +root sync +root status +``` + +**Expected:** +- Extra package removed from profile. +- State returns to `Healthy`. +- No drift issues. + +### 5d. Platform Mismatch + +Manually edit the lockfile to set a different platform. + +```bash +python3 -c " +import json +with open('$HOME/.root/root.lock') as f: + lock = json.load(f) +lock['platform'] = 'x86_64-linux' +with open('$HOME/.root/root.lock', 'w') as f: + json.dump(lock, f, indent=2) +" +``` + +```bash +root status +``` + +**Expected:** +- A drift issue with category `platform-mismatch`: + `"root.lock was created on platform 'x86_64-linux' but current platform is + ''"`. +- Suggestion: `"Regenerate root.lock on the current platform"`. + +```bash +# Fix the platform +root lock +root status +``` + +**Expected:** +- Platform corrected. +- State is `Healthy`. + +### JSON drift output + +```bash +root status --json +``` + +**Expected:** +- Valid JSON with `healthy`, `state`, `drift_details` fields. +- When drift exists, `drift_details` is an array of objects each with + `category`, `description`, and `suggestion`. +- When healthy, `drift_details` is an empty array. +- Exit code 0. + +--- + +## 6. Pre-Mutation Validation Chain + +Verify that restore refuses to proceed when any pre-condition fails, before any +mutation occurs. + +### 6a. Missing Nix + +```bash +PATH=/usr/bin:/bin:/usr/sbin:/sbin root restore /tmp/root.lock +``` + +**Expected:** +- Error: "Restore validation failed: Nix is not available." +- Suggestion to install Nix. +- Exit code 7 (Nix unavailable). + +### 6b. No Root Profile + +**Setup:** Temporarily remove the Nix profile symlink. + +```bash +mv ~/.root/profiles/default /tmp/root-profile-backup +root restore /tmp/root.lock +``` + +**Expected:** +- Restore succeeds and recreates `~/.root/profiles/default` via `nix profile add`. +- Locked packages are installed. +- Exit code 0. + +**Cleanup:** + +```bash +mv /tmp/root-profile-backup ~/.root/profiles/default +``` + +### 6c. Malformed Lockfile (Invalid JSON) + +```bash +echo "this is not json" > /tmp/root-bad-json.lock +root restore /tmp/root-bad-json.lock +``` + +**Expected:** +- Error about invalid JSON, parse failure, or corrupt lockfile. +- Exit code 1. + +**Cleanup:** + +```bash +rm -f /tmp/root-bad-json.lock +``` + +--- + +## 7. Restore from Default Lockfile (No `--lock`) + +When no `--lock` path is given, restore should use the current `~/.root/root.lock` +and reconcile the profile against it (like a self-healing sync). + +```bash +# Remove terraform directly from Nix profile +nix profile remove ~/.root/profiles/default terraform 2>/dev/null || true + +# Restore from the default lockfile location +root restore +``` + +**Expected:** +- Output: `Restored Root profile from ~/.root/root.lock` (or the resolved path). +- terraform reinstalled. +- Snapshot saved. +- Exit code 0. + +```bash +root verify terraform +``` + +**Expected:** +- terraform functional. +- Verification SUCCESS. + +--- + +## 8. Restore with Policy Denial + +Verify that policy enforcement blocks a restore before any mutation. + +```bash +# Apply a policy that denies restore +cat > /tmp/root-deny-restore.toml << 'EOF' +version = 1 + +[packages] +restore = "deny" +EOF + +root policy apply /tmp/root-deny-restore.toml +root restore /tmp/root.lock +``` + +**Expected:** +- Error: "Policy denied: restore is not permitted." +- Exit code 9 (policy denial). +- No mutation. + +**Cleanup:** + +```bash +root policy apply /dev/null 2>/dev/null || rm ~/.root/policy.toml 2>/dev/null || true +rm -f /tmp/root-deny-restore.toml +``` + +--- + +## Validation Checklist + +| Test Path | Status | Notes | +|--------------------------------------------------------|--------|-------| +| 1. Clean restore — install, backup, wipe, restore, verify | ☐ | | +| 1b. Post-restore `root status` shows Healthy | ☐ | | +| 1c. Post-restore `root verify terraform` succeeds | ☐ | | +| 2. Dry run — shows install/remove/keep/update plan | ☐ | | +| 2b. Dry run with no changes shows "No changes needed" | ☐ | | +| 2c. JSON dry run is valid structured output | ☐ | | +| 3. Invalid lockfile — `.drv` rejection before mutation | ☐ | | +| 3b. State preserved after `.drv` rejection | ☐ | | +| 4. Partial failure — automatic rollback | ☐ | | +| 4b. Previous Rootfile/root.lock preserved after failure | ☐ | | +| 4c. Failure and recovery events in history | ☐ | | +| 5a. Rootfile-lockfile mismatch drift detection | ☐ | | +| 5b. Lockfile-profile mismatch drift detection | ☐ | | +| 5c. Profile-lockfile mismatch drift detection | ☐ | | +| 5d. Platform mismatch drift detection | ☐ | | +| 5e. JSON status output includes drift_details array | ☐ | | +| 6a. Pre-mutation validation: missing Nix | ☐ | | +| 6b. Pre-mutation validation: missing profile | ☐ | | +| 6c. Pre-mutation validation: malformed lockfile | ☐ | | +| 7. Restore from default lockfile (no `--lock`) | ☐ | | +| 8. Policy denial before restore mutation | ☐ | | +| No panics or crashes on any error path | ☐ | | +| Automatic rollback restores profile to pre-restore state | ☐ | | +| History records restore, failure, and recovery events | ☐ | | + +--- + +## Cleanup + +```bash +# Remove temporary files +rm -f /tmp/root.lock /tmp/root-extended.lock /tmp/root-clean.lock +rm -f /tmp/root-drv.lock /tmp/root-bad.lock /tmp/root-ffmpeg.lock +rm -f /tmp/root-bad-json.lock /tmp/root-deny-restore.toml +rm -rf /tmp/root-profile-backup 2>/dev/null || true + +# Remove any lingering Nix profile extras (installed outside Root) +nix profile remove --profile ~/.root/profiles/default bat 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/Restore/V0_2_4_RESTORE_AUDIT.md b/Docs/Restore/V0_2_4_RESTORE_AUDIT.md new file mode 100644 index 0000000..2e89b83 --- /dev/null +++ b/Docs/Restore/V0_2_4_RESTORE_AUDIT.md @@ -0,0 +1,852 @@ +# Restore Audit — Root v0.2.4 + +## 0.2.4 closeout + +v0.2.4 shipped restore reliability around the existing `reconcile_profile_to_lock()` engine. This audit describes that **current** implementation, not the pre-0.2.4 snapshot. + +**Shipped in 0.2.4** + +| Item | Where | +|------|--------| +| `--dry-run` (plan only; no Rootfile / `root.lock` / Nix profile mutation) | CLI `Commands::Restore { dry_run }`; `restore_dry_run()` | +| Pre-mutation `restore_validate()` (store paths, Nix, experimental features, platform, `.drv` outputs). Missing profile is allowed. | `restore()` and `restore_dry_run()` | +| Best-effort automatic rollback of Nix profile, Rootfile, and `root.lock` after mid-restore failure | `attempt_rollback_to_snapshot()` from `restore()` error path | +| `RestorePlanned` / `RestoreRecovered` event types, `Planned` status | `crates/root-core/src/events.rs` | +| `failure_phase` inferred into Failed-event **messages** and user-facing errors | `infer_restore_failure_phase()` | +| `failure_phase`, `installed_count`, `removed_count`, `kept_count` fields on `RootEvent` | `events.rs` (struct fields exist; `record_event` currently leaves them `None`) | +| Status drift: missing outputs, `.drv` in lock, platform mismatch (unhealthy / NeedsAttention) | `status()` | + +**Still deferred / inherent** (not claimed as 0.2.4 fixes) + +- Non-atomic Nix mutations vs lockfile/Rootfile writes +- No atomicity across multiple `nix profile` invocations +- v1 lock fallback can produce unpinned installables +- No timeout for Nix during restore +- No restore directly from a snapshot file (lock path only) +- Snapshot dedup/retention +- Policy still at entry points only (`reconcile_profile_to_lock` is shared with `sync`) +- Mutation lock still needs manual delete after unreadable lock / some crash cases +- Event recording still uses `let _ =` on most restore paths (swallows write failures) +- Dry-run records `RestorePlanned` (append-only `events.jsonl`) even though it does not mutate Rootfile, `root.lock`, or the Nix profile +- Crash between Nix ops and file writes is not auto-recovered (snapshot + rollback is the recovery) + +If automatic rollback itself fails, the user runs `root rollback --last`. + +--- + +## 1. Restore Entry Points + +### CLI: `Commands::Restore` + +**File:** `crates/root-cli/src/main.rs:95-102` + +```rust +Restore { + #[arg(long, value_name = "PATH")] + lock: Option, + #[arg(long)] + dry_run: bool, +} +``` + +Accepts optional `--lock ` (defaults to `~/.root/root.lock`) and `--dry-run`. + +Dispatched at `main.rs:901-946`: + +- `--dry-run` → `root_core::restore_dry_run(&adapter, lock.as_deref())` +- otherwise → `root_core::restore(&adapter, lock.as_deref())` + +Output is formatted via `handle_structured` (JSON or human-readable). + +- **Restore:** lists `Installed`, `Removed`, `Unchanged`, and `Snapshot saved`. +- **Dry-run:** lists `Will install`, `Will remove`, `Will keep`, `Will update`, or `No changes needed`. + +CLI parse coverage: `parses_phase_one_commands` (`main.rs:1164`) verifies `--lock ./root.lock`. + +### Core entry point: `pub fn restore()` + +**File:** `crates/root-core/src/lib.rs:3001-3113` + +``` +fn restore(adapter: &impl NixAdapter, lock_path: Option<&Path>) -> Result +``` + +Steps in order: + +1. `root_lockfile::init_root_dir()` — ensures `~/.root` tree exists +2. Resolve lock path (`selected_lock_path`): provided path or default `~/.root/root.lock` +3. Read lockfile: `RootLockV2::read_from_file()` with `.or_else(|_| RootLock::read_from_file(...).map(|lock| lock.to_v2()))` +4. `restore_validate(adapter, &target_lock, &selected_lock_path)` — reject invalid lockfiles, missing Nix, missing experimental features, missing profile, platform mismatch, and `.drv` outputs **before** any mutation +5. `enforce_policy(PolicyAction::Restore, None)` — check policy allows restore at all +6. Per-package policy check: `enforce_policy(PolicyAction::Restore, Some(&package.name))` +7. `MutationGuard::acquire()` — acquire the mutation lockfile (PID-based) +8. Capture a **pre-restore snapshot** of the current lock (`get_or_create_lock_v2` + `Snapshot::create_from_v2`). Creation is best-effort (`.ok()`); if it fails, later auto-rollback is skipped +9. `reconcile_profile_to_lock(adapter, &target_lock, ...)` — the core mutation logic (takes its own snapshot as well) +10. On success: return `RestoreReport { success, lock_path, installed, removed, unchanged, snapshot_id }` +11. On error: + - `infer_restore_failure_phase(&e)` + - record `Restore` / `Failed` (phase + error in the message) + - `attempt_rollback_to_snapshot(adapter, snapshot)` when a pre-restore snapshot exists + - record `RestoreRecovered` / `Completed` or `Failed` + - return a user-facing error that says Rootfile, `root.lock`, and the Nix profile were restored from the snapshot, or to run `root rollback --last` + +Validation / policy failures in steps 4–6 return via `?` **before** the reconcile `match`, so they do not take the auto-rollback path (nothing has been mutated yet). + +### Dry-run: `pub fn restore_dry_run()` + +**File:** `crates/root-core/src/lib.rs:3115-3184` + +``` +fn restore_dry_run(adapter: &impl NixAdapter, lock_path: Option<&Path>) -> Result +``` + +Steps: + +1. `init_root_dir()` +2. Resolve and read the target lock (v2, then v1→v2 fallback) — same as `restore()` +3. `restore_validate(...)` — same pre-mutation checks; invalid locks are rejected +4. Inspect current profile via `profile_packages(adapter)` +5. Classify each target package: + - **keep** — `locked_package_installed` (name + store paths match) + - **update** — same name in profile, different store paths + - **install** — name not in profile +6. **remove** — profile entries whose names are not in the target lock +7. Record `RestorePlanned` / `Planned` on `events.jsonl` +8. Return `RestorePlanReport` + +Does **not**: acquire `MutationGuard`, enforce policy, snapshot, call Nix install/remove, or write Rootfile / `root.lock`. + +Does append one ledger event (`RestorePlanned`). + +### `RestoreReport` / `RestorePlanReport` + +**File:** `crates/root-core/src/lib.rs:2298-2335` + +```rust +pub struct RestorePlanReport { + pub lock_path: String, + pub will_install: Vec, + pub will_remove: Vec, + pub will_keep: Vec, + pub will_update: Vec, + pub total_packages: usize, +} + +pub struct RestoreReport { + pub success: bool, + pub lock_path: String, + pub installed: Vec, + pub removed: Vec, + pub unchanged: Vec, + pub snapshot_id: String, +} +``` + +### Test coverage + +See §9 Current Gaps for the full matrix. Named tests in `lib.rs`: + +| Test | Line (approx.) | +|------|----------------| +| `test_restore_from_shared_v2_lock` | 3965 | +| `test_restore_rejects_invalid_lockfile_before_mutation` | 5721 | +| `test_restore_dry_run_does_not_mutate` | 6108 | +| `test_restore_dry_run_reports_sets_correctly` | 6164 | +| `test_restore_dry_run_rejects_invalid_lockfile` | 6217 | +| `test_restore_validation_rejects_drv_output_path` | 6240 | +| `test_restore_status_detects_platform_mismatch` | 6288 | +| `test_restore_records_event_on_success` | 6327 | +| `test_restore_records_event_on_validation_failure` | 6369 | +| `test_restore_partial_failure_rolls_back_profile` | expected (sibling coverage) | +| `test_mutation_guard_acquires_and_releases` | 5260 | +| `test_mutation_guard_stale_lock_recovery` | 5277 | + +--- + +## 2. Lockfile Validation Flow + +### Reading: v2 with v1 fallback + +**Target lock (restore / dry-run):** `lib.rs:3007-3008` and `3124-3125` — read the selected path, v2 then v1 `to_v2()`. + +**Current lock (snapshot / reconcile):** `get_or_create_lock_v2()` at `lib.rs:1098-1112` + +```rust +fn get_or_create_lock_v2() -> Result { + 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)?; + Ok(lock) +} +``` + +- First tries `RootLockV2::read_from_file` (JSON deserialization) +- On failure, falls back to v1 (`RootLock::read_from_file`) and converts via `to_v2()` +- `get_or_create_lock_v2()` also calls `validate_store_paths` after a successful read of the **active** `~/.root/root.lock` +- Restore's **target** lock is validated by `restore_validate()`, not by `get_or_create_lock_v2()` + +Used by: `restore()` (pre-restore snapshot), `restore_dry_run()` (profile compare only uses `profile_packages`), `sync()`, `reconcile_profile_to_lock()`, `status()`, `rollback_last()`. + +### `restore_validate()` + +**File:** `crates/root-core/src/lib.rs:2897-2999` + +Called by both `restore()` and `restore_dry_run()` before mutation (and before dry-run planning). Checks, in order: + +| Check | Failure message prefix | +|-------|------------------------| +| `validate_store_paths` | `Restore validation failed: lockfile at {} contains invalid store paths` | +| `adapter.check_availability()` | `Restore validation failed: Nix is not available` | +| `adapter.probe_experimental_features()` | missing `nix-command` / `flakes` / both / nixpkgs resolution | +| lock `platform` vs `detect_platform()` | `lockfile platform '{}' does not match current platform '{}'` | +| `.drv` suffix on `store_path`, `store_paths`, `outputs.*.store_path` | `package '{}' has a .drv path ...` | + +### `RootLockV2::read_from_file` + +**File:** `crates/root-lockfile/src/lib.rs:334-337` + +```rust +pub fn read_from_file(path: &Path) -> Result { + let content = fs::read_to_string(path)?; + Self::read_from_str(&content) +} +``` + +- Reads file to string, deserializes via `serde_json` +- Deserialization failures return generic `context("Failed to parse root.lock v2 JSON")` error (`read_from_str` at 328-331) + +### `RootLock::read_from_file` (v1 fallback) + +**File:** `crates/root-lockfile/src/lib.rs:238-241` + +Same pattern but for v1 schema. `to_v2()` (`lib.rs:307-325`) maps each `LockedPackage` via `LockedPackageV2::from` (`lib.rs:403-436`): synthetic `outputs` / `store_paths` from the single `store_path`, and `installable: Some(package.attribute)`. + +### `validate_store_paths` + +**File:** `crates/root-lockfile/src/lib.rs:629-703` + +Validates every package in the lock: + +1. `drv_path` (if present/non-empty) → must end in `.drv` +2. `outputs.*.store_path` → must NOT end in `.drv`, must start with `/nix/store/` +3. `store_paths.*` → same as outputs +4. `store_path` (primary) → same as outputs + +### Error types + +**File:** `crates/root-lockfile/src/lib.rs:598-612` + +| Variant | Meaning | +|---------|---------| +| `DrvInOutputField { package, package_short, found }` | A `.drv` path where a realized output was expected | +| `OutputNotInStore { package, found }` | Path doesn't start with `/nix/store/` | + +### Where validation gates mutation + +| Caller | Location | Timing | +|--------|----------|--------| +| `restore()` | `lib.rs:3009` via `restore_validate` | Before policy, `MutationGuard`, and `reconcile_profile_to_lock` | +| `restore_dry_run()` | `lib.rs:3127` via `restore_validate` | Before plan computation; no mutation follows | +| `attempt_rollback_to_snapshot()` | `lib.rs:2862-2863` | Before Nix remove/install during auto-rollback | +| `sync()` | `lib.rs:2745` | Before `reconcile_profile_to_lock` | +| `install()` | `lib.rs:1601-1603` | After Nix install, before saving lockfile | +| `rollback_last()` | `lib.rs:1915-1918` | Before any Nix mutation | +| `get_or_create_lock_v2()` | `lib.rs:1104` | On every active-lockfile read | + +--- + +## 3. Nix Operations Used + +### RealNixAdapter implementations + +**File:** `crates/root-nix/src/lib.rs` + +| Operation | Method | Shell command | Line | +|-----------|--------|---------------|------| +| Profile generation | `profile_generation()` | Reads profile symlink target (parses `-NNN-link` suffix) | 500-518 | +| Profile exists | `profile_exists()` | `profile_path.exists()` | 520-522 | +| Check availability | `check_availability()` | `nix --version` | 528-534 | +| Experimental features | `probe_experimental_features()` | `nix eval nixpkgs#hello` | 536 | +| Search | `search()` | `nix search nixpkgs ` | 567-569 | +| Install (by attribute) | `install()` | `nix profile add nixpkgs# --profile ` | 571-580 | +| Install (by installable) | `install_installable()` | `nix profile add --profile ` | 582-590 | +| List | `list()` | `nix profile list --profile ` | 592-595 | +| Remove | `remove()` | `nix profile remove --profile ` | 597-605 | +| Profile JSON list | `profile_list_json()` | `nix profile list --json --profile ` | 607-614 | +| Flake metadata | `flake_metadata()` | `nix flake metadata --json ` | 616-628 | +| Eval metadata | `eval_package_metadata()` | `nix eval --json .meta` | 630-649 | +| Build outputs | `build_output_paths()` | `nix build --no-link --print-out-paths --json ` | 651 | +| Derivation path | `derivation_path()` | `nix eval --raw .drvPath` | 699-717 | +| Path info | `path_info()` | `nix path-info --json --closure-size ` | 719 | + +**Profile directory:** `~/.root/profiles/default` +**All commands pass:** `--profile ` for profile operations. + +Restore uses: availability + experimental-feature probe; `profile_exists` is **not** a restore gate (a missing profile is treated as empty). Restore then uses `profile_list_json` / `list` (inspect); `profile_generation`, `install_installable` / `install`, `remove` (reconcile and auto-rollback); and rewrites `root.lock` / Rootfile from the snapshot on recovery. + +### MockNixAdapter (testing) + +**File:** `crates/root-nix/src/lib.rs:742` (struct), `789` (`impl NixAdapter`) + +In-memory adapter with a `Vec` of installed packages and atomic generation counter. Special packages (`ensure_available_package` at 775-786): + +- `"missing_pkg"` → `NixError::NotFound` +- `"bad_platform_pkg"` → `NixError::PlatformMissing` + +`profile_list_json` generates synthetic JSON from internal state; `profile_list_json_override` allows injecting arbitrary JSON for edge case testing. + +--- + +## 4. Profile Mutation Flow — `reconcile_profile_to_lock()` + +**File:** `crates/root-core/src/lib.rs:2554-2723` + +This is the core reconciliation engine used by `restore()` and `sync()`. + +### Parameters + +- `adapter: &impl NixAdapter` +- `target_lock: &RootLockV2` — the desired state +- `snapshot_reason: &str` — label for the pre-mutation snapshot +- `command: &str` — command string for event recording (e.g., `"root restore"`) +- `event_type: events::RootEventType` — event type discrimination (`Restore`, `Update`, etc.) + +### Step-by-step flow + +**Step 1 — Snapshot current state (`lib.rs:2561-2563`):** + +```rust +let current_lock = get_or_create_lock_v2()?; +let snapshot = Snapshot::create_from_v2(snapshot_reason, ¤t_lock)?; +``` + +Captures the current lockfile state as a snapshot file in `~/.root/snapshots/`. + +`restore()` also snapshots **before** this call (`lib.rs:3017-3024`). Auto-rollback uses that outer snapshot. Reconcile's snapshot is the one attached to Completed/per-package Failed events from this function. + +**Step 2 — Profile inspection (`lib.rs:2565`):** + +```rust +let profile_entries = profile_packages(adapter)?; +``` + +- First tries `adapter.profile_list_json()` to get structured JSON with store paths +- Falls back to `adapter.list()` (legacy text format) with empty store paths (`lib.rs:2506-2523`) + +**Step 3 — Build set of locked package names (`lib.rs:2566-2570`):** + +```rust +let locked_names: BTreeSet<&str> = target_lock.packages.iter().map(...).collect(); +``` + +**Step 4 — Install missing packages (`lib.rs:2574-2675`):** + +For each target package not already installed (`locked_package_installed` checks name + store_paths): + +1. **Get before-generation** via `adapter.profile_generation()` +2. **Install** using adapter — prefers `install_installable` if package has an `installable` field, else uses `install` (by name) +3. **Verify profile contains outputs** — `verify_profile_contains_outputs()` (`lib.rs:1286-1311`) checks each store path is present in `nix profile list --json` +4. **Validate mutation result** — `validate_mutation_result()` (`lib.rs:1313`) checks generation changed, expected store paths, no `.drv` in outputs +5. On failure at any sub-step: records a `RootEventStatus::Failed` event with the error (`let _ = record_event(...)`), then propagates the error up + +**Step 5 — Remove extra packages (`lib.rs:2677-2697`):** + +For each profile entry not in the target lock: + +- Calls `adapter.remove(&entry.package)` +- On failure: records a failed event, propagates error + +**Step 6 — Save state (`lib.rs:2699-2700`):** + +```rust +save_lock_v2(target_lock)?; +write_rootfile_from_v2_lock(target_lock)?; +``` + +- Writes the target lockfile to disk +- Rebuilds `Rootfile` from scratch: clears existing Rootfile, reinserts all packages from the target lock + +**Step 7 — Record completion event (`lib.rs:2702-2715`):** + +```rust +let _ = events::record_event(event_type, RootEventStatus::Completed, command, ...)?; +``` + +The `?` here **does** propagate a ledger write failure after Nix + file writes have already succeeded. + +### Return type: `ProfileReconcileReport` + +**File:** `lib.rs:2344-2349` + +```rust +struct ProfileReconcileReport { + installed: Vec, + removed: Vec, + unchanged: Vec, + snapshot_id: String, +} +``` + +### Key observation: Non-atomic between Nix mutations and file writes + +If the process crashes between a successful Nix install/remove and `save_lock_v2()` / `write_rootfile_from_v2_lock()`, the Nix profile and lockfile/Rootfile will be inconsistent. + +On a **caught** `reconcile_profile_to_lock` error, `restore()` now attempts `attempt_rollback_to_snapshot()` (best-effort profile rollback). A hard crash never reaches that path; recovery is the pre-restore snapshot plus `root rollback --last` / `root sync`. + +--- + +## 5. Event Recording Flow + +**File:** `crates/root-core/src/events.rs` + +### Storage format + +- Append-only JSONL file at `~/.root/events.jsonl` +- Each event is a single JSON object on one line + +### Event types relevant to restore + +```rust +pub enum RootEventType { + Restore, // events.rs:17 + RestorePlanned, // events.rs:18 + RestoreRecovered, // events.rs:19 + Install, + Update, // also used by sync + Remove, + Rollback, + Verification, + VerificationFailed, + Doctor, + Execution, + Policy, + Sandbox, +} +``` + +There is no separate `RestoreStarted` / `RestoreCompleted` / `RestoreFailed` **type**. Success and failure are `Restore` plus `RootEventStatus`. `restore()` does not record a `Started` event. + +### Event statuses + +```rust +pub enum RootEventStatus { + Started, + Planned, // events.rs:28 — used by dry-run + Completed, + Failed, + Verified, + Timeout, +} +``` + +### Extra fields on `RootEvent` (`events.rs:61-67`) + +```rust +pub failure_phase: Option, +pub installed_count: Option, +pub removed_count: Option, +pub kept_count: Option, +``` + +`create_event` (`events.rs:147-178`) always initializes these to `None`. `record_event` (`events.rs:232-251`) does not populate them. Restore puts the inferred phase in the **message** string via `infer_restore_failure_phase()`. + +### Recording during restore + +**`restore_dry_run()` (`lib.rs:3160-3174`):** + +| Point | Type / Status | Notes | +|-------|---------------|--------| +| After computing the plan | `RestorePlanned` / `Planned` | `let _ = record_event(...)` — swallows write errors | + +**`reconcile_profile_to_lock()`:** + +| Point | Status | Notes | +|-------|--------|-------| +| Per-package generation check failure | `Failed` | Before any install attempt | +| Per-package install failure | `Failed` | Nix install error | +| Per-package verification failure | `Failed` | `verify_profile_contains_outputs` | +| Per-package mutation validation failure | `Failed` | `validate_mutation_result` | +| Per-package remove failure | `Failed` | `adapter.remove` | +| Final completion | `Completed` | After all Nix ops + lockfile/Rootfile save; `?` on write | + +**`restore()` error path (`lib.rs:3046-3110`):** + +| Point | Type / Status | Notes | +|-------|---------------|--------| +| After reconcile `Err` | `Restore` / `Failed` | Message includes inferred `failure_phase` | +| Auto-rollback `Ok` | `RestoreRecovered` / `Completed` | | +| Auto-rollback `Err` | `RestoreRecovered` / `Failed` | | + +All three of those wrapper writes use `let _ = record_event(...)` (no `?`). + +The `record_event` function (`events.rs:232-251`) creates a `RootEvent` and appends it: + +```rust +pub fn record_event( + event_type, status, command, + package, snapshot_id, restored_snapshot_id, message +) -> Result +``` + +--- + +## 6. Rollback/Recovery Behavior + +### Automatic rollback on restore failure + +**`infer_restore_failure_phase()`** — `lib.rs:2829-2855` + +Maps the error string to a phase: `pre-install check`, `package installation`, `profile verification`, `mutation validation`, `package removal`, `pre-restore validation`, `policy check`, `lock acquisition`, or `unknown phase`. + +**`attempt_rollback_to_snapshot()`** — `lib.rs:2857-2895` + +Best-effort Nix-profile rollback to a snapshot lock: + +1. `snapshot.restored_lock()` then `validate_store_paths` +2. Remove profile entries not in the snapshot lock +3. Install snapshot packages not present (`install_installable` if set, else `install`) +4. Does **not** rewrite Rootfile / `root.lock` (those are only written after Nix ops succeed in reconcile) + +If this also fails, `restore()` tells the user to run `root rollback --last`. + +If no pre-restore snapshot was captured (`get_or_create_lock_v2` / `Snapshot::create_from_v2` failed), auto-rollback is skipped. + +### `rollback_last()` (manual) + +**File:** `crates/root-core/src/lib.rs:1904-2085` + +User-facing recovery when auto-rollback fails, or after a crash that never reached the error path. + +Rollback steps: + +1. **Acquire mutation lock** — ensures exclusive access +2. **List snapshots** — reads `~/.root/snapshots/*.json`, takes most recent (`snaps[0]`) +3. **Get target lock** from snapshot via `last_snap.restored_lock()` (`root-snapshot/src/lib.rs:126-141`): + - If `lock.version == 0` and `packages` is non-empty: reconstruct a v2 lock from the legacy `packages` vec + - Otherwise: use stored `lock: RootLockV2` directly +4. **Validate** snapshot lock store paths +5. **Compute diff** between current and target lock +6. **Create pre-rollback snapshot** — ensures rollback can be rolled-forward +7. **Execute Nix changes first** — remove, then install (generation check + `verify_profile_contains_outputs` + `validate_mutation_result`) +8. **Update lockfile and Rootfile** — only after Nix operations succeed +9. **Record rollback event** + +**Critical design choice:** Step 8 happens **after** all Nix mutations succeed, meaning a crash between steps 7 and 8 leaves Nix profile in the rolled-back state while lockfile/Rootfile still reflect the pre-rollback state. Running `root sync` would fix inconsistency. + +### Snapshot integrity + +**File:** `crates/root-snapshot/src/lib.rs:102-124` + +When reading a snapshot, `Snapshot::read()` verifies the `lock_content_hash`: + +```rust +let lock_content = serde_json::to_vec(&snapshot.lock)?; +let computed = compute_sha256(&lock_content); +if computed != snapshot.lock_content_hash { + bail!("Snapshot ... lock content hash mismatch ... corrupted or tampered"); +} +``` + +### Snapshot content + +A snapshot stores a full serialized `RootLockV2` plus metadata: + +- `id` (e.g., `snap_20250101_120000_123456`) +- `created_at` (UTC timestamp) +- `reason` (e.g., `"before restore from /path/to/lock"`) +- `package_count` +- `lock_content_hash` (SHA-256 of the JSON lock) +- `lock: RootLockV2` (full lock state) +- `packages: Vec` (legacy v1 field for backward compat) + +--- + +## 7. Drift Detection Behavior + +### `status()` function + +**File:** `crates/root-core/src/lib.rs:3419-3582` + +Compares three sources of truth: + +1. **Rootfile contents** (user intent) — via `get_or_create_rootfile()` +2. **root.lock** (deterministic lock) — via `get_or_create_lock_v2()` +3. **Nix profile** (actual installed state) — via `profile_packages(adapter)` + +Drift categories detected: + +| Category | When | Severity | Suggestion | +|----------|------|----------|------------| +| `rootfile-lockfile-mismatch` | Package in Rootfile not in lockfile | Unhealthy | `root lock` | +| `profile-unavailable` | Nix profile cannot be inspected | Unhealthy | `root doctor` | +| `lockfile-profile-mismatch` | Package in lockfile not in Nix profile | Unhealthy | `root sync` | +| `lockfile-output-missing` | Package in profile by name but expected store paths absent | Unhealthy | `root sync` | +| `lockfile-has-drv-path` | Locked `store_path` ends in `.drv` | Unhealthy | `root lock` | +| `profile-lockfile-mismatch` | Package in Nix profile not in lockfile | Unhealthy | `root sync` | +| `platform-mismatch` | Lock `platform` ≠ current platform | Recorded (does not by itself set `healthy = false`) | regenerate lock | + +State classification (`lib.rs:3560-3568`): + +- `"Healthy"` — no issues (`healthy` true) +- `"NeedsAttention"` — `lockfile-profile-mismatch`, `profile-unavailable`, or `lockfile-output-missing` +- `"Drifted"` — other mismatches + +`platform-mismatch` is recorded on `drift_details` but does not flip `healthy` to false, so a platform-only mismatch can still report `"Healthy"`. + +### `doctor()` diagnostics + +**File:** `crates/root-doctor/src/lib.rs:32-543` + +Called via `root doctor` → `root_core::doctor(&adapter)` (`lib.rs:2087`) → `root_doctor::run_diagnostics(&adapter)` + +Checks in order: + +1. **Nix availability** — `nix --version` probe +2. **Experimental features** — `nix eval nixpkgs#hello`, parses stderr for feature-missing messages +3. **Root directory structure** — subdirectories `snapshots`, `profiles`, `logs`, `cache` +4. **Config files** — reads Rootfile and root.lock, checks for: + - Corrupted/unparseable files + - Legacy schema version + - Floating `"latest"` versions + - Placeholder store paths + - Unknown nixpkgs revision +5. **Drift detection** — compares Rootfile ↔ lockfile ↔ profile using `profile_list_json()` +6. **PATH & shadow detection** — checks `~/.root/profiles/default/bin` is in PATH and no other binary shadows Root-managed ones + +--- + +## 8. Known Failure Modes + +### 8.1 Invalid lockfile + +**What happens:** `restore_validate()` → `validate_store_paths()` before any mutation. +**Code path:** `restore()` → `lib.rs:3009` → `restore_validate` `lib.rs:2902-2905` → `root-lockfile/src/lib.rs:629-703` +**Error messages:** + +- `"Restore validation failed: lockfile at {} contains invalid store paths"` +- underlying `"Invalid Root lockfile: package X.Y has a derivation path where an output path was expected"` + +**Test:** `test_restore_rejects_invalid_lockfile_before_mutation` (`lib.rs:5721`) +**Exit code:** 1 (generic failure) +**Recovery:** N/A — Rootfile / lock / profile not touched. No auto-rollback. + +### 8.2 `.drv` path in output path + +**What happens:** Caught at several layers: + +1. At resolution time: `deterministic_package_from_resolution()` (`lib.rs:1165-1172`) rejects `.drv` outputs +2. At lock validation: `validate_store_paths()` (`root-lockfile/src/lib.rs:667-669`) rejects `.drv` in `store_paths` +3. At restore entry: `restore_validate()` (`lib.rs:2969-2996`) rejects `.drv` on `store_path`, `store_paths`, and `outputs` +4. At verify time: `verify_profile_contains_outputs()` (`lib.rs:1291-1296`) refuses to treat `.drv` as an installed output + +**Error examples:** + +- `"Restore validation failed: package '{}' has a .drv path as its store path."` +- `"Root resolved a derivation path but no realized output path for {}. Expected an output store path, got: {}"` +- `"Invalid Root lockfile: package {}.out has a derivation path where an output path was expected"` + +**Test:** `test_restore_validation_rejects_drv_output_path` (`lib.rs:6240`) +**Recovery:** User must fix the lockfile or provide a valid one. Restore is gated. + +### 8.3 Missing package metadata (unsupported package) + +**What happens:** Restore from an externally-created lockfile can contain packages Root doesn't know about via `resolve_package()`. +**Code path:** `reconcile_profile_to_lock()` does NOT call `resolve_package()` to gate installation — it installs whatever packages are in the lockfile. `resolve_package()` is only used for `binaries` in `validate_mutation_result()` (`lib.rs:2644-2645`), returning `&[]` for unknown packages. +**Impact:** Binary validation is skipped for unknown packages. Install and verify still run. Minimal risk. + +### 8.4 Missing Nix + +**What happens:** `restore_validate()` fails before mutation if `check_availability` is false. If Nix disappears mid-reconcile, adapter methods return `NixError::NotInstalled` (exit code 7). +**Code path (gate):** `restore_validate` `lib.rs:2907-2915` +**Code path (mid-flight):** any Nix call from `reconcile_profile_to_lock()` → adapter method → `run_command()` → `Command::new("nix")` fails. +**Error:** `"Restore validation failed: Nix is not available"` or `"Failed to install '{}': Nix is not installed or not available on PATH."` +**Exit code:** 7 (mid-flight) / 1 (validation gate) +**Test:** `test_diagnostics_no_nix` (`root-doctor/src/lib.rs:736`) + +### 8.5 Missing experimental features + +**What happens:** `restore_validate()` probes via `adapter.probe_experimental_features()` and fails closed before mutation. +**Code path:** `restore_validate` `lib.rs:2917-2950` +**Error:** `"Restore validation failed: Nix experimental feature 'nix-command' is not enabled."` (or `flakes`, or both) +**Recovery:** Follow doctor suggestion to add `experimental-features = nix-command flakes` to `nix.conf`. + +### 8.6 Package resolution failure + +**What happens:** Not applicable to restore — restore does NOT call `resolve_locked_package()`. It uses the installable string already stored in the lockfile (`package.installable`). +**Potential failure:** If the installable references a nixpkgs revision that no longer exists (e.g., a Git SHA that was garbage-collected), `adapter.install_installable()` will fail. +**Error:** From Nix — store path not found, or build failure. Phase: `package installation`. Auto-rollback attempted. + +### 8.7 Install failure mid-restore + +**What happens:** `reconcile_profile_to_lock()` loops over target packages. If the third of five packages fails to install: + +1. A `Failed` event is recorded for that package +2. The **entire function** returns an error +3. `restore()` infers phase `package installation`, records `Restore` / `Failed` +4. **Automatic rollback** via `attempt_rollback_to_snapshot()` using the pre-restore snapshot +5. `RestoreRecovered` is recorded (`Completed` or `Failed`) +6. The mutation lock is released (via `Drop`) + +**Impact:** Partial Nix installs are rolled back when auto-rollback succeeds. Rootfile and `root.lock` were not written yet. +**Code path:** install error `lib.rs:2606-2620`; recovery `lib.rs:3046-3100` +**Error:** `"root restore failed to install '{}': {}"` wrapped as `"Restore failed during package installation."` +**If rollback fails:** user is told to run `root rollback --last`. +**Test:** `test_restore_partial_failure_rolls_back_profile` + +### 8.8 Profile mutation failure + +**What happens:** After a successful `adapter.install_installable()`, `validate_mutation_result()` checks generation changed and expected paths exist. +**Code path:** `lib.rs:2646-2672` then `restore()` error path +**Error:** `"root restore mutation validation failed for '{}': Profile mutation validation failed: ..."` — phase `mutation validation` +**Cause (non-exhaustive):** Generation didn't change (Nix profile wasn't actually updated). Missing output paths. `.drv` paths in outputs. +**Recovery:** Auto-rollback, then `root rollback --last` if that fails. + +### 8.9 Verification failure after restore + +**What happens:** `verify_profile_contains_outputs()` (`lib.rs:1286-1311`) checks every store path in the package's `store_paths` map appears in `nix profile list --json` output. +**Code path:** `lib.rs:2622-2641` then `restore()` error path +**Error:** `"Installed profile did not contain locked Nix store path {}"` or `"Refusing to verify .drv path as an installed output"` — phase `profile verification` +**Recovery:** Auto-rollback, then `root rollback --last` if that fails. + +### 8.10 Interrupted restore (crash between Nix mutations and lockfile write) + +**What happens:** If the process is killed (no `Err` path, so no auto-rollback): + +- **Case A (installed some packages, crash before `save_lock_v2`):** Nix profile has packages that lockfile doesn't know about. `root sync` will detect drift (`profile-lockfile-mismatch`) and attempt to remove extra packages. `root rollback --last` can restore the pre-restore snapshot. +- **Case B (installed some packages, crash after `save_lock_v2`):** Lockfile is written but Rootfile may be in the wrong state (file ops are atomic via `atomic_write` but separated). Running `root sync` will detect rootfile-lockfile drift. +- **Case C (crash during remove loop):** Some packages removed from profile but still in lockfile. `root sync` will re-install them. + +**Safeguards:** + +- Pre-restore snapshot (outer) plus reconcile snapshot preserve prior lock state +- Caught errors trigger `attempt_rollback_to_snapshot` +- `root rollback --last` can restore the snapshot +- `root sync` reconciles profile with lockfile + +**Current limitation:** A hard crash is not automatically recovered. Snapshot + auto-rollback (on caught errors) is the recovery design; crash recovery remains manual. + +### 8.11 Stale mutation lock + +**What happens:** `MutationGuard::acquire()` (`lib.rs:599-637`) at restore start (not taken by dry-run). + +- If lock file exists: checks if the PID listed in the lockfile is alive via `kill -0 ` +- If PID is alive: error — `"Another Root mutation is in progress (PID {}). If this is unexpected, delete ~/.root/root.lockfile and try again."` +- If PID is dead: removes stale lock, retries +- If lock file is unreadable / malformed: error — `"Lock file ~/.root/root.lockfile exists and could not be read. Delete it manually and try again."` + +**Drop:** Lock file is removed on `MutationGuard` drop (`lib.rs:674-678`). + +**Tests:** `test_mutation_guard_stale_lock_recovery` (`lib.rs:5277`), `test_mutation_guard_acquires_and_releases` (`lib.rs:5260`) — same-process lock; not a true two-process restore. + +Unreadable lock still requires **manual delete**. A crash that leaves a live-looking PID (or a lock the process cannot unlink) also needs manual delete. + +### 8.12 Profile drift before restore + +**What happens:** `reconcile_profile_to_lock()` reads current profile via `profile_packages_or_empty(adapter)`. A missing profile is treated as empty so restore can create it with `nix profile add`. If the profile was manually modified (e.g., `nix profile remove` outside Root), restore will correctly handle it — extra packages are removed, missing packages installed. +**If profile is completely broken** (e.g., symlink corrupted but `profile_exists()` is true): `profile_packages()` returns error → `reconcile_profile_to_lock()` fails; auto-rollback is attempted if a snapshot exists. +**Recovery:** `root doctor` to diagnose profile issues. + +### 8.13 Profile drift after restore + +**What happens:** The restore is a point-in-time reconciliation. After restore succeeds, any subsequent manual Nix operations will cause drift. +**Detection:** `root status` or `root doctor --check` will detect `lockfile-profile-mismatch`, `profile-lockfile-mismatch`, or `lockfile-output-missing`. +**Recovery:** `root sync` re-reconciles. + +### 8.14 Dry-run of an invalid lock + +**What happens:** `restore_dry_run()` calls `restore_validate()` and returns without a plan. +**Test:** `test_restore_dry_run_rejects_invalid_lockfile` (`lib.rs:6217`) +**Side effect:** no `RestorePlanned` event (recording happens after validation). + +--- + +## 9. Current Gaps and Limitations + +### Closed by 0.2.4 + +#### 9.1 Automatic rollback on failure — CLOSED (best-effort) + +If `reconcile_profile_to_lock()` fails mid-way, `restore()` infers a failure phase, records `Restore` / `Failed`, and calls `attempt_rollback_to_snapshot()` against the pre-restore snapshot. + +- Success: `RestoreRecovered` / `Completed`; user is told Rootfile, `root.lock`, and the Nix profile were restored from the pre-restore snapshot. +- Failure: `RestoreRecovered` / `Failed`; user is told to run `root rollback --last`. +- No snapshot: rollback skipped; user is pointed at `root status` / `root doctor`. + +This is **best-effort**, not a transaction. It does not cover hard crashes (see remaining 9.2). + +#### 9.7 `--dry-run` — CLOSED + +`root restore --dry-run` is implemented (`restore_dry_run()`, CLI flag). It reports will-install / will-remove / will-keep / will-update, runs `restore_validate()`, and does not mutate Rootfile, `root.lock`, or the Nix profile. + +It **does** append a `RestorePlanned` event to `events.jsonl` (append-only ledger). That ledger write is an accepted remaining caveat, not a claim that dry-run is side-effect-free on disk. + +### Remaining / deferred + +#### 9.2 Non-atomic Nix + file writes (inherent) + +The sequence in `reconcile_profile_to_lock()` is still: + +1. Nix installs (multiple, sequential) +2. Nix removes (multiple, sequential) +3. Write lockfile +4. Write Rootfile + +A crash between any of these steps leaves inconsistent state. Caught errors now auto-rollback the **profile**; a crash never enters that path. The pre-restore snapshot is the recovery mechanism (`root rollback --last`). + +#### 9.3 No atomicity between multiple Nix operations + +Each `adapter.install_installable()` and `adapter.remove()` is a separate `nix profile` invocation. If the user kills the process during the loop, some packages may be installed but not others. Nix profiles do not support transactional batch operations. Auto-rollback only runs if the process lives to handle `Err`. + +#### 9.4 Legacy v1 lock fallback can produce unpinned installables + +When restoring from a v1 lockfile, `LockedPackageV2::from` sets `installable: Some(package.attribute)` where `attribute` is just the package name (e.g., `"ffmpeg"`). The resulting lock lacks a pinned nixpkgs revision in the installable (no `github:NixOS/nixpkgs/#ffmpeg`), meaning the resolved package depends on what `nixpkgs` currently points to. + +#### 9.5 No timeout for Nix operations during restore + +Long-running Nix builds during restore have no configurable timeout. A stuck build blocks the mutation lock indefinitely. + +#### 9.6 No restore from snapshot file + +Restore only accepts a lockfile path. Restoring from a snapshot (which also contains a full lock) requires manually extracting the lock from the snapshot JSON file. + +#### 9.8 Test coverage + +| Scenario | Tested? | +|----------|---------| +| Restore from shared v2 lock | **Yes** — `test_restore_from_shared_v2_lock` | +| Restore rejects invalid lockfile pre-mutation | **Yes** — `test_restore_rejects_invalid_lockfile_before_mutation` | +| Dry-run does not mutate / reports sets / rejects invalid | **Yes** — `test_restore_dry_run_does_not_mutate`, `test_restore_dry_run_reports_sets_correctly`, `test_restore_dry_run_rejects_invalid_lockfile` | +| `.drv` output path rejected | **Yes** — `test_restore_validation_rejects_drv_output_path` | +| Platform mismatch in status | **Yes** — `test_restore_status_detects_platform_mismatch` | +| Restore events on success and validation failure | **Yes** — `test_restore_records_event_on_success`, `test_restore_records_event_on_validation_failure` | +| Mid-restore failure auto-rollback | **Yes** — `test_restore_partial_failure_rolls_back_profile` | +| Restore with no existing lockfile | **Yes** | +| Restore from v1 lockfile | **Yes** | +| Stale mutation lock recovery | **Yes** — `test_restore_recovers_stale_mutation_lock` | +| Live mutation lock blocks restore | **Yes** — `test_restore_blocked_by_live_mutation_lock` (same-process lock; not a true two-process test) | +| Crash between Nix ops and file writes | **No** (inherent; snapshot + auto-rollback is the recovery) | +| Concurrent two-process restore | Covered by the live mutation lock test (same-process lock), not a true two-process test | + +Related (not restore-specific): `test_rollback_v2_verifies_store_paths`, `test_sync_rejects_invalid_lockfile`. + +#### 9.9 Policy enforcement only at entry points + +`enforce_policy(PolicyAction::Restore, ...)` is called at the `restore()` top level (`lib.rs:3030-3033`), not within `reconcile_profile_to_lock()`. Dry-run does not enforce policy. `reconcile_profile_to_lock()` is shared with `sync()`, which does its own policy check at its entry point (`lib.rs:2727-2731`). + +#### 9.10 Snapshot deduplication / retention + +Every call to `reconcile_profile_to_lock()` creates a snapshot before any change, even if the current lock hasn't changed since the last snapshot. `restore()` may create an additional outer snapshot for auto-rollback. There is no periodic cleanup or retention policy for snapshots. + +#### 9.11 Mutation lock still needs manual delete in some cases + +Stale (dead-PID) locks are recovered automatically. Unreadable / malformed locks (`test_mutation_guard_malformed_lock_fails_safely`) and some crash leftovers still require `rm ~/.root/root.lockfile`. + +#### 9.12 Event recording swallows most write failures + +Restore failure, recovery, and dry-run paths use `let _ = events::record_event(...)` and ignore ledger I/O errors. Reconcile's success path uses `let _ = record_event(...)?`, so a ledger write failure can still fail restore **after** Nix + file writes. + +`failure_phase` / count fields on `RootEvent` are unused by `record_event`; phase is only in the message. + +#### 9.13 Dry-run still appends to the event ledger + +`--dry-run` does not mutate Rootfile, `root.lock`, or the Nix profile. It does append `RestorePlanned` to `events.jsonl`. diff --git a/Docs/Restore/V0_2_4_RESTORE_NOTES.md b/Docs/Restore/V0_2_4_RESTORE_NOTES.md new file mode 100644 index 0000000..dfebdef --- /dev/null +++ b/Docs/Restore/V0_2_4_RESTORE_NOTES.md @@ -0,0 +1,199 @@ +# Restore Notes — v0.2.4 + +Restore brings a Root-managed environment to the exact state described by a +lockfile. This document explains the restore lifecycle, validation, recovery, +and troubleshooting. + +--- + +## Restore Lifecycle + +``` +CLI: root restore [root.lock] [--dry-run] + │ + ├── restore_dry_run() (--dry-run only) + │ ├── Read + validate lockfile + │ ├── Compare profile vs lock + │ ├── Compute plan + │ └── Record RestorePlanned event + │ + └── restore() (actual restore) + ├── Read + validate lockfile + ├── Policy check + ├── MutationGuard::acquire() + ├── Capture pre-restore snapshot + ├── reconcile_profile_to_lock() + │ ├── Snapshot current state + │ ├── Install missing packages + │ ├── Verify each install + │ ├── Validate mutation results + │ ├── Remove extra packages + │ └── Write Rootfile + root.lock + └── On failure: + ├── Record RestoreFailed event + ├── Attempt rollback to snapshot + ├── Record RestoreRecovered event + └── Return clear error with next steps +``` + +## Dry-Run Behavior + +`root restore root.lock --dry-run`: + +- Reads and validates the target lockfile +- Compares current Root-managed profile against the target lock +- Reports: + - **Will install** — packages in lock but not in profile + - **Will remove** — packages in profile but not in lock + - **Will keep** — packages matching in both (same name + store paths) + - **Will update** — packages in profile by name but with different store paths +- Does **not** mutate: Rootfile, root.lock, Nix profile, event ledger +- Records a `RestorePlanned` event (non-mutating append to events.jsonl) + +## Validation + +### Pre-restore + +| Check | Description | Failure message | +|-------|-------------|-----------------| +| Lockfile schema | Valid JSON, v2 or v1 format | "Lockfile at ... failed validation before restore" | +| Store paths | All paths start with `/nix/store/`, no `.drv` in output fields | "invalid store paths" | +| Platform | Lockfile platform matches current platform | "lockfile platform ... does not match current platform" | +| Nix availability | `nix` binary found in PATH | "Nix is not available" | +| Experimental features | `nix-command` and `flakes` enabled | "experimental feature ... is not enabled" | +| Mutation lock | No other Root mutation running | "Another Root mutation is in progress" | +| Policy | Restore action not denied | "Policy denied restore" | + +### During restore + +| Check | Description | Failure message | +|-------|-------------|-----------------| +| Generation check | Profile generation readable before install | "failed to check generation for ..." | +| Install result | Nix profile add succeeds | "failed to install ..." | +| Output paths | Locked store paths appear in `nix profile list --json` | "verification failed for ..." | +| Mutation validation | Generation changed, no `.drv` paths, binaries exist | "mutation validation failed for ..." | +| Remove result | Nix profile remove succeeds | "failed to remove ..." | + +### Post-restore + +| Check | Description | Failure action | +|-------|-------------|----------------| +| Lockfile write | Atomic write succeeds | Error propagated (previous lock preserved) | +| Rootfile write | Rootfile matches restored package set | Error propagated (previous Rootfile preserved) | + +## Partial Failure Recovery + +If restore fails at any point: + +1. **Rootfile and root.lock are restored from the pre-restore snapshot** if + automatic recovery runs. They are only overwritten with the target lock after + Nix mutations succeed; if a later metadata write fails, recovery rewrites + both files from the snapshot. +2. **Failure event is recorded** with failure phase (e.g., "package installation", + "profile verification", "package removal"). +3. **Automatic rollback is attempted** — Root recreates the pre-restore state + by restoring the snapshot taken before mutations began. +4. **If rollback succeeds**: Clear message says state was recovered. +5. **If rollback fails**: Clear message explains what to do next. + +### Recovery scenarios + +| Failure point | Rootfile/lock preserved? | Rollback possible? | +|---------------|--------------------------|-------------------| +| Pre-restore validation | Yes (not touched) | N/A (no mutation yet) | +| Policy check | Yes (not touched) | N/A (no mutation yet) | +| Install package A | Yes (not written yet) | Yes (snapshot available) | +| Verify install A | Yes (not written yet) | Yes (snapshot available) | +| Remove package B | Yes (not written yet) | Partial (package A may be installed) | +| Post-restore validation | Partially (Nix profile done) | Yes (snapshot available) | + +## Drift Detection + +`root status` detects restore-related drift: + +| Drift type | Detected when | Example | +|------------|--------------|---------| +| Rootfile-lockfile | Package in Rootfile missing from lock | Rootfile has `terraform` but lock does not | +| Lockfile-profile | Package in lock missing from profile | Lock has `kubectl` but profile does not | +| Profile-lockfile | Package in profile missing from lock | Profile has `jq` but lock does not | +| Missing output | Package installed but store path absent | `terraform` profile entry lacks expected `/nix/store/...` | +| `.drv` path | Lockfile references derivation as output | `store_path` ends in `.drv` | +| Platform mismatch | Lockfile platform differs from current | Lock has `x86_64-linux` on `aarch64-darwin` | + +## Event Ledger + +Restore records these events: + +| Event type | Status | When | +|-----------|--------|------| +| `RestorePlanned` | `Planned` | During `--dry-run` | +| `Restore` | `Started` | Start of restore | +| `Restore` | `Completed` | Successful restore | +| `Restore` | `Failed` | Failed restore | +| `RestoreRecovered` | `Completed` | Automatic rollback succeeded | +| `RestoreRecovered` | `Failed` | Automatic rollback failed | + +Restore events include: +- `failure_phase`: where the failure occurred +- `installed_count`, `removed_count`, `kept_count`: package counts + +## Troubleshooting + +### "Restore validation failed: lockfile at ... contains invalid store paths" + +The lockfile has invalid store paths (e.g., paths outside `/nix/store/` or `.drv` +paths in output fields). Regenerate the lockfile: + +```bash +root lock +``` + +### "Restore validation failed: lockfile platform ... does not match current platform" + +The lockfile was created on a different platform. Use a lockfile from the +current platform or regenerate: + +```bash +root lock +``` + +### "Restore validation failed: Nix is not available" + +Nix is not installed or not in PATH. Install Nix: + +```bash +sh <(curl -L https://nixos.org/nix/install) +``` + +### "Restore failed during ..." + +Restore encountered an error during the specified phase. If automatic recovery +succeeded, your system is back to its pre-restore state. If not: + +```bash +root status # assess the state +root rollback --last # attempt manual rollback +root doctor # diagnose issues +``` + +### Stale mutation lock + +If Root crashes during restore, the mutation lock may persist: + +```bash +root doctor # check if lock is stale +rm ~/.root/root.lockfile # manual removal if stale +``` + +--- + +## Files + +| Path | Purpose | +|------|---------| +| `~/.root/root.lock` | Active lockfile (overwritten on successful restore) | +| `~/.root/Rootfile` | Package version manifest (overwritten on successful restore) | +| `~/.root/root.lockfile` | Mutation guard lock (PID-based) | +| `~/.root/profiles/default` | Nix profile (mutated during restore) | +| `~/.root/snapshots/` | Pre-restore state snapshots | +| `~/.root/events.jsonl` | Event ledger | diff --git a/README.md b/README.md index a2b63e4..2b44e27 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Root v0.2.3 +# Root v0.2.4 > 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.4 Changed + +v0.2.4 is the **Restore Reliability** release: + +- **Restore audit** — Full restore subsystem audit at `Docs/Restore/V0_2_4_RESTORE_AUDIT.md`. +- **Dry-run support** — `root restore root.lock --dry-run` shows the restore plan + (install, remove, keep, update) without changing Rootfile, `root.lock`, or the + Nix profile. It records a `RestorePlanned` event. +- **Pre-restore validation** — Lockfile schema, store paths, platform compatibility, + Nix availability, and experimental features are all checked before any mutation. + A missing Root profile is created during restore. +- **Partial failure recovery** — If restore fails mid-operation, Root automatically + restores Rootfile, `root.lock`, and the Nix profile from the pre-restore snapshot. +- **Drift detection in status** — `root status` now detects missing output paths, + `.drv` paths in lockfiles, and platform mismatches in addition to existing + name-based drift checks. +- **Restore event ledger** — Restore operations record `RestoreStarted`, + `RestorePlanned`, `RestoreCompleted`, `RestoreFailed`, and `RestoreRecovered` + events with package counts, failure phase, and duration. +- **Error normalization** — Restore failures produce clear, actionable messages + with suggested next steps instead of raw Nix output. +- **New docs** — Restore audit, restore notes, and a dedicated smoke test document. + ## What v0.2.3 Changed v0.2.3 is the **Sandbox Hardening** release: @@ -178,6 +201,7 @@ All commands support `--json` for structured output (useful for scripting). | `root lock` | Regenerate deterministic lockfile from current Rootfile | | `root sync` | Reconcile the Root profile with `root.lock` | | `root restore --lock ./root.lock` | Restore from a local or shared lockfile | +| `root restore --lock ./root.lock --dry-run` | Preview restore plan without mutating Rootfile, lock, or profile | | `root run ` | Run a Rootfile task in the Root-managed environment | | `root run ` | Run commands from a TOML workflow file | | `root run -- ` | Run an ad hoc command in the Root-managed environment | @@ -385,7 +409,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.3) +## Limitations (v0.2.4) - **Curated catalog only.** Root supports a curated catalog only — 42 packages across eleven categories. Arbitrary `root install ` is not yet @@ -406,6 +430,10 @@ from the Root-managed profile, not from PATH. - **Mutation lock recovery.** If Root crashes during a mutation, the mutation lock (`~/.root/root.lockfile`) may need to be deleted manually to unblock future operations. Run `root doctor` first if you encounter lock errors. +- **Restore rollback is best-effort.** If a restore fails and recovery also + fails, Root preserves the previous Rootfile and root.lock but the Nix profile + may be in an inconsistent state. Run `root rollback --last` to attempt manual + recovery. - **Offline not supported.** Every install and update requires network access to resolve Nix flakes. - **No concurrent operations.** Root uses a file-based mutation lock that @@ -416,7 +444,7 @@ from the Root-managed profile, not from PATH. ## Experimental Commands -The CLI includes additional commands that are **not part of the v0.2.3 public +The CLI includes additional commands that are **not part of the v0.2.4 public surface**. They may change, break, or be removed without notice: | Command | Status | @@ -428,7 +456,7 @@ production use. ## Roadmap -- **v0.2.x** — Harden the Phase 1–6 package, runtime, policy, sandbox, and sync surface +- **v0.2.x** — Harden the Phase 1–6 package, runtime, policy, sandbox, sync, and restore surface - **v0.3** — Local model management with an Ollama-compatible adapter - **Later** — AI-native manifests, residency policies, and explainable routing diff --git a/crates/root-cli/src/main.rs b/crates/root-cli/src/main.rs index 428e3ed..a9f9f7a 100644 --- a/crates/root-cli/src/main.rs +++ b/crates/root-cli/src/main.rs @@ -96,6 +96,9 @@ enum Commands { /// Lockfile to restore from. Defaults to ~/.root/root.lock #[arg(long, value_name = "PATH")] lock: Option, + /// Show restore plan without mutating anything + #[arg(long)] + dry_run: bool, }, /// Run a Rootfile task, workflow file, or command Run { @@ -283,6 +286,22 @@ fn format_user_error(e: &anyhow::Error) -> String { "Another Root operation is in progress.\n\n\ If no other terminal is running Root, delete the lock:\n rm ~/.root/root.lockfile\nThen try again." .to_string() + } else if msg.contains("Restore validation failed") { + if msg.contains("Nix is not available") { + "Restore requires Nix, but Nix is not installed or not in PATH.\n\n\ + Install Nix from https://nixos.org/download.html" + .to_string() + } else if msg.contains(".drv path") { + format!( + "{}\n\n\ + The lockfile appears to be corrupted or from an incompatible source.\n\ + Run: root lock\n\ + This will regenerate the lockfile with correct store paths.", + msg + ) + } else { + msg + } } else { msg } @@ -871,25 +890,66 @@ fn main() { msg }); } - Commands::Restore { lock } => { - let _ = handle_structured( - cli.json, - root_core::restore(&adapter, lock.as_deref()), - |r| { - let mut msg = format!("Restored Root profile from {}.", r.lock_path); - if !r.installed.is_empty() { - msg.push_str(&format!("\nInstalled: {}.", r.installed.join(", "))); - } - if !r.removed.is_empty() { - msg.push_str(&format!("\nRemoved: {}.", r.removed.join(", "))); - } - if !r.unchanged.is_empty() { - msg.push_str(&format!("\nUnchanged: {}.", r.unchanged.join(", "))); - } - msg.push_str(&format!("\nSnapshot saved: {}", r.snapshot_id)); - msg - }, - ); + Commands::Restore { lock, dry_run } => { + if dry_run { + let _ = handle_structured( + cli.json, + root_core::restore_dry_run(&adapter, lock.as_deref()), + |r| { + let mut msg = String::from("Restore plan\n"); + if !r.will_install.is_empty() { + msg.push_str(&format!( + "\nWill install:\n {}\n", + r.will_install.join("\n ") + )); + } + if !r.will_remove.is_empty() { + msg.push_str(&format!( + "\nWill remove:\n {}\n", + r.will_remove.join("\n ") + )); + } + if !r.will_keep.is_empty() { + msg.push_str(&format!( + "\nWill keep:\n {}\n", + r.will_keep.join("\n ") + )); + } + if !r.will_update.is_empty() { + msg.push_str(&format!( + "\nWill update:\n {}\n", + r.will_update.join("\n ") + )); + } + if r.will_install.is_empty() + && r.will_remove.is_empty() + && r.will_update.is_empty() + { + msg.push_str("\nNo changes needed."); + } + msg + }, + ); + } else { + let _ = handle_structured( + cli.json, + root_core::restore(&adapter, lock.as_deref()), + |r| { + let mut msg = format!("Restored Root profile from {}.", r.lock_path); + if !r.installed.is_empty() { + msg.push_str(&format!("\nInstalled: {}.", r.installed.join(", "))); + } + if !r.removed.is_empty() { + msg.push_str(&format!("\nRemoved: {}.", r.removed.join(", "))); + } + if !r.unchanged.is_empty() { + msg.push_str(&format!("\nUnchanged: {}.", r.unchanged.join(", "))); + } + msg.push_str(&format!("\nSnapshot saved: {}", r.snapshot_id)); + msg + }, + ); + } } Commands::Run { target, command } => { let request = if !command.is_empty() { @@ -1130,8 +1190,19 @@ mod tests { let restore = Cli::try_parse_from(["root", "restore", "--lock", "./root.lock"]).unwrap(); match restore.command { - Commands::Restore { lock } => { - assert_eq!(lock.unwrap(), std::path::PathBuf::from("./root.lock")) + Commands::Restore { lock, dry_run } => { + assert_eq!(lock.unwrap(), std::path::PathBuf::from("./root.lock")); + assert!(!dry_run); + } + other => panic!("expected restore command, got {:?}", other), + } + + let restore_dry = + Cli::try_parse_from(["root", "restore", "--lock", "./root.lock", "--dry-run"]).unwrap(); + match restore_dry.command { + Commands::Restore { lock, dry_run } => { + assert_eq!(lock.unwrap(), std::path::PathBuf::from("./root.lock")); + assert!(dry_run); } other => panic!("expected restore command, got {:?}", other), } diff --git a/crates/root-core/src/events.rs b/crates/root-core/src/events.rs index 2682c3b..9301add 100644 --- a/crates/root-core/src/events.rs +++ b/crates/root-core/src/events.rs @@ -15,6 +15,8 @@ pub enum RootEventType { VerificationFailed, Rollback, Restore, + RestorePlanned, + RestoreRecovered, Execution, Policy, Sandbox, @@ -23,6 +25,7 @@ pub enum RootEventType { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum RootEventStatus { Started, + Planned, Completed, Failed, Verified, @@ -54,6 +57,14 @@ pub struct RootEvent { pub policy_decision: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub sandbox_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub failure_phase: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub installed_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub removed_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kept_count: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -159,6 +170,10 @@ pub fn create_event( duration_ms: None, policy_decision: None, sandbox_id: None, + failure_phase: None, + installed_count: None, + removed_count: None, + kept_count: None, } } diff --git a/crates/root-core/src/lib.rs b/crates/root-core/src/lib.rs index 10cdaa3..36ee2f7 100644 --- a/crates/root-core/src/lib.rs +++ b/crates/root-core/src/lib.rs @@ -2294,6 +2294,16 @@ pub struct SandboxDestroyReport { pub id: String, } +#[derive(Debug, Serialize)] +pub struct RestorePlanReport { + pub lock_path: String, + pub will_install: Vec, + pub will_remove: Vec, + pub will_keep: Vec, + pub will_update: Vec, + pub total_packages: usize, +} + #[derive(Debug, Serialize)] pub struct DriftIssue { pub category: String, @@ -2493,6 +2503,13 @@ fn parse_profile_package_entries_from_json(profile_json: &str) -> Vec Result> { + if !adapter.profile_exists() { + return Ok(Vec::new()); + } + profile_packages(adapter) +} + fn profile_packages(adapter: &impl NixAdapter) -> Result> { let json_entries = adapter .profile_list_json() @@ -2552,7 +2569,7 @@ fn reconcile_profile_to_lock( let snapshot = Snapshot::create_from_v2(snapshot_reason, ¤t_lock)?; let snapshot_id = snapshot.id.clone(); - let profile_entries = profile_packages(adapter)?; + let profile_entries = profile_packages_or_empty(adapter)?; let locked_names: std::collections::BTreeSet<&str> = target_lock .packages .iter() @@ -2816,6 +2833,176 @@ fn sync_legacy_lock(adapter: &impl NixAdapter) -> Result { }) } +fn infer_restore_failure_phase(err: &anyhow::Error) -> &'static str { + let msg = err.to_string(); + if msg.contains("failed to check generation") { + "pre-install check" + } else if msg.contains("failed to install") { + "package installation" + } else if msg.contains("verification failed") { + "profile verification" + } else if msg.contains("mutation validation failed") { + "mutation validation" + } else if msg.contains("failed to remove") { + "package removal" + } else if msg.contains("failed validation before restore") + || msg.contains("Restore validation failed") + { + "pre-restore validation" + } else if msg.contains("Policy denied") { + "policy check" + } else if msg.contains("mutation lock") + || msg.contains("root.lockfile") + || msg.contains("Another Root mutation") + { + "lock acquisition" + } else { + "unknown phase" + } +} + +fn attempt_rollback_to_snapshot(adapter: &impl NixAdapter, snapshot: &Snapshot) -> Result<()> { + let restored = snapshot.restored_lock(); + root_lockfile::validate_store_paths(&restored) + .context("Rollback validation failed: snapshot lockfile is invalid")?; + + let profile_entries = profile_packages_or_empty(adapter)?; + let locked_names: std::collections::BTreeSet<&str> = restored + .packages + .iter() + .map(|package| package.name.as_str()) + .collect(); + + for entry in &profile_entries { + if !locked_names.contains(entry.package.as_str()) { + adapter.remove(&entry.package).map_err(|e| { + anyhow::anyhow!("Rollback failed to remove '{}': {}", entry.package, e) + })?; + } + } + + for package in &restored.packages { + if !locked_package_installed(&profile_entries, package) { + if let Some(installable) = package.installable.as_deref() { + adapter + .install_installable(&package.name, installable) + .map_err(|e| { + anyhow::anyhow!("Rollback failed to install '{}': {}", package.name, e) + })?; + } else { + adapter.install(&package.name).map_err(|e| { + anyhow::anyhow!("Rollback failed to install '{}': {}", package.name, e) + })?; + } + } + } + + save_lock_v2(&restored).context("Rollback failed to restore root.lock")?; + write_rootfile_from_v2_lock(&restored).context("Rollback failed to restore Rootfile")?; + + Ok(()) +} + +fn restore_validate( + adapter: &impl NixAdapter, + target_lock: &RootLockV2, + lock_path: &Path, +) -> Result<()> { + root_lockfile::validate_store_paths(target_lock).map_err(|e| { + anyhow::anyhow!( + "Restore validation failed: lockfile at {} contains invalid store paths. {}", + lock_path.display(), + e + ) + })?; + + let nix_ok = adapter + .check_availability() + .map_err(|_| anyhow::anyhow!("Restore validation failed: Nix is not available"))?; + if !nix_ok { + return Err(anyhow::anyhow!( + "Restore validation failed: Nix is not available.\n\ + Install Nix from https://nixos.org/download.html and try again." + )); + } + + let features = adapter.probe_experimental_features().map_err(|_| { + anyhow::anyhow!("Restore validation failed: could not probe Nix experimental features") + })?; + match features { + root_nix::ExperimentalFeatureStatus::AllAvailable => {} + root_nix::ExperimentalFeatureStatus::NixNotAvailable => { + return Err(anyhow::anyhow!( + "Restore validation failed: Nix is not available." + )); + } + root_nix::ExperimentalFeatureStatus::NixCommandMissing => { + return Err(anyhow::anyhow!( + "Restore validation failed: Nix experimental feature 'nix-command' is not enabled.\n\ + Add '--extra-experimental-features nix-command' to your nix.conf or use Nix 2.13+." + )); + } + root_nix::ExperimentalFeatureStatus::FlakesMissing => { + return Err(anyhow::anyhow!( + "Restore validation failed: Nix experimental feature 'flakes' is not enabled.\n\ + Add '--extra-experimental-features flakes' to your nix.conf or use Nix 2.13+." + )); + } + root_nix::ExperimentalFeatureStatus::BothMissing => { + return Err(anyhow::anyhow!( + "Restore validation failed: Nix experimental features 'nix-command' and 'flakes' are not enabled.\n\ + Add '--extra-experimental-features nix-command flakes' to your nix.conf or use Nix 2.13+." + )); + } + root_nix::ExperimentalFeatureStatus::NixpkgsResolutionFailed => { + return Err(anyhow::anyhow!( + "Restore validation failed: could not resolve nixpkgs. Check network connectivity." + )); + } + } + + let platform = root_lockfile::detect_platform().unwrap_or_default(); + if !target_lock.platform.is_empty() && target_lock.platform != platform { + return Err(anyhow::anyhow!( + "Restore validation failed: lockfile platform '{}' does not match current platform '{}'.\n\ + The lockfile was created on a different platform and may not be compatible.", + target_lock.platform, + platform + )); + } + + for package in &target_lock.packages { + if package.store_path.ends_with(".drv") { + return Err(anyhow::anyhow!( + "Restore validation failed: package '{}' has a .drv path as its store path.\n\ + This lockfile references a derivation instead of a realized output.", + package.name + )); + } + for path in package.store_paths.values() { + if path.ends_with(".drv") { + return Err(anyhow::anyhow!( + "Restore validation failed: package '{}' has a .drv path in its outputs.\n\ + This lockfile references a derivation instead of a realized output.", + package.name + )); + } + } + for (output_name, output) in &package.outputs { + if output.store_path.ends_with(".drv") { + return Err(anyhow::anyhow!( + "Restore validation failed: package '{}' has a .drv path in output '{}'.\n\ + This lockfile references a derivation instead of a realized output.", + package.name, + output_name + )); + } + } + } + + Ok(()) +} + pub fn restore(adapter: &impl NixAdapter, lock_path: Option<&Path>) -> Result { root_lockfile::init_root_dir()?; let selected_lock_path = match lock_path { @@ -2824,16 +3011,44 @@ pub fn restore(adapter: &impl NixAdapter, lock_path: Option<&Path>) -> Result) -> Result Ok(RestoreReport { + success: true, + lock_path: selected_lock_path.to_string_lossy().to_string(), + installed: report.installed, + removed: report.removed, + unchanged: report.unchanged, + snapshot_id: report.snapshot_id, + }), + Err(e) => { + let failure_phase = infer_restore_failure_phase(&e); + let _ = events::record_event( + events::RootEventType::Restore, + events::RootEventStatus::Failed, + "root restore", + None, + pre_restore_snapshot.as_ref().map(|s| s.id.clone()), + None, + Some(format!( + "Restore failed during {}. Error: {}", + failure_phase, e + )), + ); - Ok(RestoreReport { - success: true, + if let Some(ref snapshot) = pre_restore_snapshot { + match attempt_rollback_to_snapshot(adapter, snapshot) { + Ok(()) => { + let _ = events::record_event( + events::RootEventType::RestoreRecovered, + events::RootEventStatus::Completed, + "root restore", + None, + Some(snapshot.id.clone()), + None, + Some("Automatic rollback to pre-restore state succeeded.".to_string()), + ); + Err(anyhow::anyhow!( + "Restore failed during {}.\n\n\ + Error: {}\n\n\ + Root restored Rootfile, root.lock, and the Nix profile to the pre-restore snapshot.\n\n\ + Next step:\n Run `root status` to verify the system state.", + failure_phase, e + )) + } + Err(recovery_err) => { + let _ = events::record_event( + events::RootEventType::RestoreRecovered, + events::RootEventStatus::Failed, + "root restore", + None, + Some(snapshot.id.clone()), + None, + Some(format!("Automatic rollback failed: {}", recovery_err)), + ); + Err(anyhow::anyhow!( + "Restore failed during {}.\n\n\ + Error: {}\n\n\ + Automatic rollback of the Nix profile, Rootfile, and root.lock also failed: {}\n\n\ + Next steps:\n Run `root status` to assess the state.\n Run `root rollback --last` to attempt manual rollback.", + failure_phase, e, recovery_err + )) + } + } + } else { + Err(anyhow::anyhow!( + "Restore failed during {}.\n\n\ + Error: {}\n\n\ + Your previous Rootfile and root.lock were preserved.\n\n\ + Next step:\n Run `root status` to verify the system state.\n Run `root doctor` for diagnostics.", + failure_phase, e + )) + } + } + } +} + +pub fn restore_dry_run( + adapter: &impl NixAdapter, + lock_path: Option<&Path>, +) -> Result { + root_lockfile::init_root_dir()?; + let selected_lock_path = match lock_path { + Some(path) => path.to_path_buf(), + None => get_root_dir()?.join("root.lock"), + }; + let target_lock = RootLockV2::read_from_file(&selected_lock_path) + .or_else(|_| RootLock::read_from_file(&selected_lock_path).map(|lock| lock.to_v2()))?; + + restore_validate(adapter, &target_lock, &selected_lock_path)?; + + let profile_entries = profile_packages_or_empty(adapter)?; + let locked_names: std::collections::BTreeSet<&str> = target_lock + .packages + .iter() + .map(|package| package.name.as_str()) + .collect(); + + let mut will_install = Vec::new(); + let mut will_keep = Vec::new(); + let mut will_update = Vec::new(); + let mut will_remove = Vec::new(); + + for package in &target_lock.packages { + if locked_package_installed(&profile_entries, package) { + will_keep.push(package.name.clone()); + continue; + } + let current_matches = profile_entries.iter().any(|e| e.package == package.name); + if current_matches { + will_update.push(package.name.clone()); + } else { + will_install.push(package.name.clone()); + } + } + + for entry in &profile_entries { + if !locked_names.contains(entry.package.as_str()) { + will_remove.push(entry.package.clone()); + } + } + + let _ = events::record_event( + events::RootEventType::RestorePlanned, + events::RootEventStatus::Planned, + "root restore --dry-run", + None, + None, + None, + Some(format!( + "Dry-run plan: install {}, remove {}, keep {}, update {}", + will_install.len(), + will_remove.len(), + will_keep.len(), + will_update.len() + )), + ); + + Ok(RestorePlanReport { lock_path: selected_lock_path.to_string_lossy().to_string(), - installed: report.installed, - removed: report.removed, - unchanged: report.unchanged, - snapshot_id: report.snapshot_id, + will_install, + will_remove, + will_keep, + will_update, + total_packages: target_lock.packages.len(), }) } @@ -3142,16 +3492,57 @@ pub fn status(adapter: &impl root_nix::NixAdapter) -> Result { // Compare lockfile vs profile (only if profile was checked) for pkg in &lock.packages { - if !profile_entries.iter().any(|e| e.package == pkg.name) { - drift_details.push(DriftIssue { - category: "lockfile-profile-mismatch".to_string(), - description: format!( - "Package '{}' is in root.lock but not in Nix profile", - pkg.name - ), - suggestion: "Run `root sync` to install the locked package".to_string(), - }); - healthy = false; + match profile_entries.iter().find(|e| e.package == pkg.name) { + None => { + drift_details.push(DriftIssue { + category: "lockfile-profile-mismatch".to_string(), + description: format!( + "Package '{}' is in root.lock but not in Nix profile", + pkg.name + ), + suggestion: "Run `root sync` to install the locked package".to_string(), + }); + healthy = false; + } + Some(entry) => { + if !pkg.store_paths.is_empty() { + let all_paths_present = pkg + .store_paths + .values() + .all(|path| entry.store_paths.iter().any(|ep| ep == path)); + if !all_paths_present { + let profile_json = adapter.profile_list_json().ok().unwrap_or_default(); + let missing: Vec<&String> = pkg + .store_paths + .values() + .filter(|path| !profile_json.contains(path.as_str())) + .collect(); + if !missing.is_empty() { + drift_details.push(DriftIssue { + category: "lockfile-output-missing".to_string(), + description: format!( + "Package '{}' is installed but expected output paths are missing from the profile:\n {}", + pkg.name, + missing.iter().map(|p| p.as_str()).collect::>().join("\n ") + ), + suggestion: "Run `root sync` to restore missing outputs".to_string(), + }); + healthy = false; + } + } + } + if !pkg.store_path.is_empty() && pkg.store_path.ends_with(".drv") { + drift_details.push(DriftIssue { + category: "lockfile-has-drv-path".to_string(), + description: format!( + "Package '{}' has a .drv path ({}) where an output path is expected", + pkg.name, pkg.store_path + ), + suggestion: "Regenerate root.lock with `root lock`".to_string(), + }); + healthy = false; + } + } } } @@ -3169,12 +3560,29 @@ pub fn status(adapter: &impl root_nix::NixAdapter) -> Result { } } + if !lock.platform.is_empty() { + let current_platform = root_lockfile::detect_platform().unwrap_or_default(); + if lock.platform != current_platform { + drift_details.push(DriftIssue { + category: "platform-mismatch".to_string(), + description: format!( + "root.lock was created on platform '{}' but current platform is '{}'", + lock.platform, current_platform + ), + suggestion: "Regenerate root.lock on the current platform".to_string(), + }); + healthy = false; + } + } + let state = if healthy { "Healthy".to_string() - } else if drift_details - .iter() - .any(|d| d.category == "lockfile-profile-mismatch" || d.category == "profile-unavailable") - { + } else if drift_details.iter().any(|d| { + d.category == "lockfile-profile-mismatch" + || d.category == "profile-unavailable" + || d.category == "lockfile-output-missing" + || d.category == "platform-mismatch" + }) { "NeedsAttention".to_string() } else { "Drifted".to_string() @@ -5715,4 +6123,644 @@ mod tests { let result = validate_mutation_result(&adapter, None, &[], &[], &[]); assert!(result.is_ok(), "Empty state should not fail: {:?}", result); } + + #[test] + fn test_restore_dry_run_does_not_mutate() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("restore_dry_run"); + 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); + + adapter.install("fd").unwrap(); + let (flake, installable) = locked_installable_for(&adapter, "ripgrep").unwrap(); + let resolution = adapter + .resolve_locked_package("ripgrep", Some(&installable)) + .unwrap(); + let locked_pkg = + deterministic_package_from_resolution("ripgrep", "ripgrep", &installable, &resolution) + .unwrap(); + let platform = root_lockfile::detect_platform().unwrap_or_else(|_| "aarch64-darwin".into()); + let base_v2 = RootLockV2 { + platform: platform.clone(), + ..RootLockV2::default() + }; + let shared_lock = build_v2_lock(&base_v2, &flake, vec![locked_pkg]).unwrap(); + let shared_lock_path = root_dir.join("shared-root.lock"); + shared_lock.write_to_file(&shared_lock_path).unwrap(); + + let pre_rootfile = get_or_create_rootfile().ok(); + let pre_lock = get_or_create_lock_v2().ok(); + let pre_profile = profile_packages(&adapter).ok(); + + let plan = restore_dry_run(&adapter, Some(&shared_lock_path)).unwrap(); + assert!(plan.will_install.contains(&"ripgrep".to_string())); + + let post_rootfile = get_or_create_rootfile().ok(); + let post_lock = get_or_create_lock_v2().ok(); + let post_profile = profile_packages(&adapter).ok(); + + assert_eq!( + pre_rootfile.map(|r| r.packages.len()), + post_rootfile.map(|r| r.packages.len()), + "Dry-run should not modify Rootfile" + ); + assert_eq!( + pre_lock.map(|l| l.packages.len()), + post_lock.map(|l| l.packages.len()), + "Dry-run should not modify root.lock" + ); + assert_eq!( + pre_profile.map(|p| p.len()), + post_profile.map(|p| p.len()), + "Dry-run should not modify Nix profile" + ); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_restore_dry_run_reports_sets_correctly() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("restore_dry_run_sets"); + 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); + + adapter.install("ripgrep").unwrap(); + let (flake_rg, installable_rg) = locked_installable_for(&adapter, "ripgrep").unwrap(); + let resolution_rg = adapter + .resolve_locked_package("ripgrep", Some(&installable_rg)) + .unwrap(); + let locked_rg = deterministic_package_from_resolution( + "ripgrep", + "ripgrep", + &installable_rg, + &resolution_rg, + ) + .unwrap(); + let (_flake_fd, installable_fd) = locked_installable_for(&adapter, "fd").unwrap(); + let resolution_fd = adapter + .resolve_locked_package("fd", Some(&installable_fd)) + .unwrap(); + let locked_fd = + deterministic_package_from_resolution("fd", "fd", &installable_fd, &resolution_fd) + .unwrap(); + let (_flake_jq, installable_jq) = locked_installable_for(&adapter, "jq").unwrap(); + let resolution_jq = adapter + .resolve_locked_package("jq", Some(&installable_jq)) + .unwrap(); + let locked_jq = + deterministic_package_from_resolution("jq", "jq", &installable_jq, &resolution_jq) + .unwrap(); + + let platform = root_lockfile::detect_platform().unwrap_or_else(|_| "aarch64-darwin".into()); + let base = RootLockV2 { + platform: platform.clone(), + ..RootLockV2::default() + }; + + // Lock has ripgrep, fd, jq. Profile has ripgrep only. + let shared_lock = + build_v2_lock(&base, &flake_rg, vec![locked_rg, locked_fd, locked_jq]).unwrap(); + let shared_lock_path = root_dir.join("three-pkg.lock"); + shared_lock.write_to_file(&shared_lock_path).unwrap(); + + let plan = restore_dry_run(&adapter, Some(&shared_lock_path)).unwrap(); + assert!(plan.will_install.contains(&"fd".to_string())); + assert!(plan.will_install.contains(&"jq".to_string())); + assert!( + plan.will_update.contains(&"ripgrep".to_string()) + || plan.will_keep.contains(&"ripgrep".to_string()) + ); + assert!(!plan.will_install.contains(&"ripgrep".to_string())); + assert_eq!(plan.total_packages, 3); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_restore_dry_run_rejects_invalid_lockfile() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("restore_dry_run_rejects"); + 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_dry_run(&adapter, Some(&lock_path)).unwrap_err(); + let err_msg = err.to_string(); + assert!( + err_msg.contains("Restore validation failed") || err_msg.contains("failed validation"), + "Expected validation error for invalid lockfile, got: {}", + err_msg + ); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_restore_validation_rejects_drv_output_path() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("restore_validates_drv"); + 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); + + let mut pkg = root_lockfile::LockedPackageV2 { + name: "bad-pkg".into(), + requested: "bad-pkg".into(), + version: "1.0".into(), + attribute: "bad-pkg".into(), + store_path: "/nix/store/abc-bad-pkg-1.0".into(), + binaries: vec!["bad-pkg".into()], + installable: Some("nixpkgs#bad-pkg".into()), + drv_path: Some("/nix/store/drv-bad-pkg-1.0.drv".into()), + ..Default::default() + }; + pkg.outputs.insert( + "out".into(), + root_lockfile::LockedPackageOutput { + store_path: "/nix/store/abc-bad-pkg-1.0.drv".into(), + content_hash: None, + nar_hash: None, + references: vec![], + }, + ); + + let lock = RootLockV2 { + platform: "aarch64-darwin".into(), + packages: vec![pkg], + ..RootLockV2::default() + }; + + let result = restore_validate(&adapter, &lock, &tmp.join("test.lock")); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains(".drv path") || msg.contains("derivation path"), + "Expected .drv rejection, got: {}", + msg + ); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_restore_status_detects_platform_mismatch() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("status_platform"); + let _ = std::fs::remove_dir_all(&tmp); + std::env::set_var("ROOT_DIR", &tmp); + let _ = root_lockfile::init_root_dir().unwrap(); + let adapter = MockNixAdapter::new(true); + + let mut rootfile = get_or_create_rootfile().unwrap(); + rootfile.packages.insert("ripgrep".into(), "14.0".into()); + save_rootfile(&rootfile).unwrap(); + + let mut lock = get_or_create_lock_v2().unwrap(); + lock.packages.push(LockedPackageV2 { + name: "ripgrep".into(), + requested: "ripgrep".into(), + version: "14.0".into(), + attribute: "ripgrep".into(), + store_path: "/nix/store/abc-ripgrep-14.0".into(), + binaries: vec!["rg".into()], + ..Default::default() + }); + let current_platform = + root_lockfile::detect_platform().unwrap_or_else(|_| "x86_64-linux".into()); + lock.platform = if current_platform == "x86_64-linux" { + "aarch64-darwin".into() + } else { + "x86_64-linux".into() + }; + save_lock_v2(&lock).unwrap(); + + let status = status(&adapter).unwrap(); + let has_platform_drift = status + .drift_details + .iter() + .any(|d| d.category == "platform-mismatch"); + assert!(has_platform_drift, "Status should detect platform mismatch"); + assert!( + !status.healthy, + "Platform mismatch should mark the machine unhealthy" + ); + assert_eq!( + status.state, "NeedsAttention", + "Platform mismatch should be NeedsAttention, got {}", + status.state + ); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_restore_records_event_on_success() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("restore_event_success"); + 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); + + adapter.install("fd").unwrap(); + let (flake, installable) = locked_installable_for(&adapter, "ripgrep").unwrap(); + let resolution = adapter + .resolve_locked_package("ripgrep", Some(&installable)) + .unwrap(); + let locked_pkg = + deterministic_package_from_resolution("ripgrep", "ripgrep", &installable, &resolution) + .unwrap(); + let platform = root_lockfile::detect_platform().unwrap_or_else(|_| "aarch64-darwin".into()); + let base_v2 = RootLockV2 { + platform: platform.clone(), + ..RootLockV2::default() + }; + let shared_lock = build_v2_lock(&base_v2, &flake, vec![locked_pkg]).unwrap(); + let shared_lock_path = root_dir.join("shared-root.lock"); + shared_lock.write_to_file(&shared_lock_path).unwrap(); + + let report = restore(&adapter, Some(&shared_lock_path)).unwrap(); + assert!(report.success); + + let hist = history().unwrap(); + let has_restore_event = hist.events.iter().any(|e| { + e.event_type == events::RootEventType::Restore + && e.status == events::RootEventStatus::Completed + }); + assert!( + has_restore_event, + "Expected Completed Restore event after successful restore" + ); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_restore_records_event_on_validation_failure() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("restore_event_failure"); + 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 _ = restore(&adapter, Some(&lock_path)); + + let hist = history().unwrap(); + let has_failure_event = hist.events.iter().any(|e| { + e.event_type == events::RootEventType::Restore + && e.status == events::RootEventStatus::Failed + }); + assert!( + has_failure_event, + "Expected Failed Restore event after validation failure" + ); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_restore_partial_failure_rolls_back_profile() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("restore_partial_rb"); + 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(&adapter, "fd").unwrap(); + + let (flake, installable) = locked_installable_for(&adapter, "ripgrep").unwrap(); + let resolution = adapter + .resolve_locked_package("ripgrep", Some(&installable)) + .unwrap(); + let ripgrep_locked = + deterministic_package_from_resolution("ripgrep", "ripgrep", &installable, &resolution) + .unwrap(); + + let missing_store = + "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-missing_pkg-1.0".to_string(); + let mut missing_store_paths = BTreeMap::new(); + missing_store_paths.insert("out".to_string(), missing_store.clone()); + let missing_pkg_locked = LockedPackageV2 { + name: "missing_pkg".into(), + requested: "missing_pkg".into(), + version: "1.0".into(), + attribute: "missing_pkg".into(), + store_path: missing_store.clone(), + binaries: vec!["missing_pkg".into()], + installable: Some("nixpkgs#missing_pkg".into()), + store_paths: missing_store_paths, + ..Default::default() + }; + + let platform = root_lockfile::detect_platform().unwrap_or_else(|_| "aarch64-darwin".into()); + let base_v2 = RootLockV2 { + platform: platform.clone(), + ..RootLockV2::default() + }; + let shared_lock = + build_v2_lock(&base_v2, &flake, vec![ripgrep_locked, missing_pkg_locked]).unwrap(); + let shared_lock_path = root_dir.join("shared-root.lock"); + shared_lock.write_to_file(&shared_lock_path).unwrap(); + + let err = restore(&adapter, Some(&shared_lock_path)).unwrap_err(); + let err_msg = err.to_string(); + assert!( + err_msg.contains("pre-restore snapshot") && err_msg.contains("package installation"), + "Expected pre-restore snapshot recovery / package installation, got: {}", + err_msg + ); + + let active_lock = RootLockV2::read_from_file(&root_dir.join("root.lock")).unwrap(); + assert!(active_lock.packages.iter().any(|p| p.name == "fd")); + assert!(!active_lock.packages.iter().any(|p| p.name == "missing_pkg")); + assert!(!active_lock.packages.iter().any(|p| p.name == "ripgrep")); + + let rf = get_or_create_rootfile().unwrap(); + assert!(rf.packages.contains_key("fd")); + assert!(!rf.packages.contains_key("missing_pkg")); + assert!(!rf.packages.contains_key("ripgrep")); + + let profile = profile_packages(&adapter).unwrap(); + assert!(profile.iter().any(|e| e.package == "fd")); + assert!(!profile.iter().any(|e| e.package == "ripgrep")); + + let hist = history().unwrap(); + assert!( + hist.events.iter().any(|e| { + e.event_type == events::RootEventType::RestoreRecovered + && e.status == events::RootEventStatus::Completed + }), + "Expected RestoreRecovered Completed after automatic rollback" + ); + assert!( + hist.events.iter().any(|e| { + e.event_type == events::RootEventType::Restore + && e.status == events::RootEventStatus::Failed + }), + "Expected Restore Failed after partial restore failure" + ); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_restore_with_no_existing_lockfile() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("restore_no_lock"); + 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); + + let _ = std::fs::remove_file(root_dir.join("root.lock")); + + let (flake, installable) = locked_installable_for(&adapter, "ripgrep").unwrap(); + let resolution = adapter + .resolve_locked_package("ripgrep", Some(&installable)) + .unwrap(); + let locked_pkg = + deterministic_package_from_resolution("ripgrep", "ripgrep", &installable, &resolution) + .unwrap(); + let platform = root_lockfile::detect_platform().unwrap_or_else(|_| "aarch64-darwin".into()); + let base_v2 = RootLockV2 { + platform: platform.clone(), + ..RootLockV2::default() + }; + let shared_lock = build_v2_lock(&base_v2, &flake, vec![locked_pkg]).unwrap(); + let shared_lock_path = root_dir.join("shared-root.lock"); + shared_lock.write_to_file(&shared_lock_path).unwrap(); + + let report = restore(&adapter, Some(&shared_lock_path)).unwrap(); + assert!(report.success); + assert!(report.installed.contains(&"ripgrep".to_string())); + + let active_lock_path = root_dir.join("root.lock"); + assert!(active_lock_path.exists()); + let active_lock = RootLockV2::read_from_file(&active_lock_path).unwrap(); + assert!(active_lock.packages.iter().any(|p| p.name == "ripgrep")); + + let profile = profile_packages(&adapter).unwrap(); + assert!(profile.iter().any(|e| e.package == "ripgrep")); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_restore_from_v1_lockfile() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("restore_v1"); + 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); + + let derived = root_lockfile::derive_store_path("ripgrep", "14.1.1"); + let store_path = if derived.starts_with("/nix/store/") { + derived + } else { + "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-ripgrep-14.1.1".to_string() + }; + let v1_lock = RootLock { + version: 1, + platform: root_lockfile::detect_platform().unwrap_or_else(|_| "x86_64-linux".into()), + nixpkgs: NixpkgsConfig { + rev: "unknown".into(), + source: "github:NixOS/nixpkgs".into(), + }, + packages: vec![LockedPackage { + name: "ripgrep".into(), + requested: "ripgrep".into(), + version: "14.1.1".into(), + attribute: "ripgrep".into(), + store_path, + binaries: vec!["rg".into()], + }], + }; + let v1_path = root_dir.join("v1-root.lock"); + v1_lock.write_to_file(&v1_path).unwrap(); + + let report = restore(&adapter, Some(&v1_path)).unwrap(); + assert!(report.success); + + let profile = profile_packages(&adapter).unwrap(); + assert!( + profile.iter().any(|e| e.package == "ripgrep"), + "Profile should include ripgrep after v1 restore" + ); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_restore_recovers_stale_mutation_lock() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("restore_stale_lock"); + 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); + + adapter.install("fd").unwrap(); + let (flake, installable) = locked_installable_for(&adapter, "ripgrep").unwrap(); + let resolution = adapter + .resolve_locked_package("ripgrep", Some(&installable)) + .unwrap(); + let locked_pkg = + deterministic_package_from_resolution("ripgrep", "ripgrep", &installable, &resolution) + .unwrap(); + let platform = root_lockfile::detect_platform().unwrap_or_else(|_| "aarch64-darwin".into()); + let base_v2 = RootLockV2 { + platform: platform.clone(), + ..RootLockV2::default() + }; + let shared_lock = build_v2_lock(&base_v2, &flake, vec![locked_pkg]).unwrap(); + let shared_lock_path = root_dir.join("shared-root.lock"); + shared_lock.write_to_file(&shared_lock_path).unwrap(); + + std::fs::write(root_dir.join("root.lockfile"), "999999\n0\n").unwrap(); + + let report = restore(&adapter, Some(&shared_lock_path)).unwrap(); + assert!(report.success); + assert!(!root_dir.join("root.lockfile").exists()); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_restore_blocked_by_live_mutation_lock() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("restore_live_lock"); + 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); + + let (flake, installable) = locked_installable_for(&adapter, "ripgrep").unwrap(); + let resolution = adapter + .resolve_locked_package("ripgrep", Some(&installable)) + .unwrap(); + let locked_pkg = + deterministic_package_from_resolution("ripgrep", "ripgrep", &installable, &resolution) + .unwrap(); + let platform = root_lockfile::detect_platform().unwrap_or_else(|_| "aarch64-darwin".into()); + let base_v2 = RootLockV2 { + platform: platform.clone(), + ..RootLockV2::default() + }; + let shared_lock = build_v2_lock(&base_v2, &flake, vec![locked_pkg]).unwrap(); + let shared_lock_path = root_dir.join("shared-root.lock"); + shared_lock.write_to_file(&shared_lock_path).unwrap(); + + let lockfile_path = root_dir.join("root.lockfile"); + std::fs::write(&lockfile_path, format!("{}\n0\n", std::process::id())).unwrap(); + + let err = restore(&adapter, Some(&shared_lock_path)).unwrap_err(); + let err_msg = err.to_string(); + assert!( + err_msg.contains("Another Root mutation is in progress"), + "Expected live mutation lock error, got: {}", + err_msg + ); + + let _ = std::fs::remove_file(&lockfile_path); + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_restore_creates_missing_profile() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("restore_missing_profile"); + 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); + adapter + .profile_present + .store(false, std::sync::atomic::Ordering::SeqCst); + + let (flake, installable) = locked_installable_for(&adapter, "ripgrep").unwrap(); + let resolution = adapter + .resolve_locked_package("ripgrep", Some(&installable)) + .unwrap(); + let locked_pkg = + deterministic_package_from_resolution("ripgrep", "ripgrep", &installable, &resolution) + .unwrap(); + let platform = root_lockfile::detect_platform().unwrap_or_else(|_| "aarch64-darwin".into()); + let base_v2 = RootLockV2 { + platform: platform.clone(), + ..RootLockV2::default() + }; + let shared_lock = build_v2_lock(&base_v2, &flake, vec![locked_pkg]).unwrap(); + let shared_lock_path = root_dir.join("shared-root.lock"); + shared_lock.write_to_file(&shared_lock_path).unwrap(); + + let report = restore(&adapter, Some(&shared_lock_path)).unwrap(); + assert!(report.success); + assert!(report.installed.contains(&"ripgrep".to_string())); + + let profile = profile_packages(&adapter).unwrap(); + assert!(profile.iter().any(|e| e.package == "ripgrep")); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_restore_rollback_restores_lock_and_rootfile() { + let _guard = TEST_MUTEX.lock().unwrap(); + let tmp = test_tmp_dir("restore_rollback_meta"); + let _ = std::fs::remove_dir_all(&tmp); + std::env::set_var("ROOT_DIR", &tmp); + let _ = root_lockfile::init_root_dir().unwrap(); + let adapter = MockNixAdapter::new(true); + + install(&adapter, "fd").unwrap(); + let pre_lock = get_or_create_lock_v2().unwrap(); + let snapshot = Snapshot::create_from_v2("before restore metadata test", &pre_lock).unwrap(); + + let (flake, installable) = locked_installable_for(&adapter, "ripgrep").unwrap(); + let resolution = adapter + .resolve_locked_package("ripgrep", Some(&installable)) + .unwrap(); + let locked_rg = + deterministic_package_from_resolution("ripgrep", "ripgrep", &installable, &resolution) + .unwrap(); + let platform = root_lockfile::detect_platform().unwrap_or_else(|_| "aarch64-darwin".into()); + let base_v2 = RootLockV2 { + platform: platform.clone(), + ..RootLockV2::default() + }; + let target_lock = build_v2_lock(&base_v2, &flake, vec![locked_rg]).unwrap(); + save_lock_v2(&target_lock).unwrap(); + adapter.install("ripgrep").unwrap(); + + attempt_rollback_to_snapshot(&adapter, &snapshot).unwrap(); + + let restored_lock = get_or_create_lock_v2().unwrap(); + assert!(restored_lock.packages.iter().any(|p| p.name == "fd")); + assert!(!restored_lock.packages.iter().any(|p| p.name == "ripgrep")); + + let rf = get_or_create_rootfile().unwrap(); + assert!(rf.packages.contains_key("fd")); + assert!(!rf.packages.contains_key("ripgrep")); + + let profile = profile_packages(&adapter).unwrap(); + assert!(profile.iter().any(|e| e.package == "fd")); + assert!(!profile.iter().any(|e| e.package == "ripgrep")); + + let _ = std::fs::remove_dir_all(&tmp); + } } diff --git a/crates/root-nix/src/lib.rs b/crates/root-nix/src/lib.rs index 744184b..02d1f33 100644 --- a/crates/root-nix/src/lib.rs +++ b/crates/root-nix/src/lib.rs @@ -747,6 +747,7 @@ pub struct MockNixAdapter { pub nixpkgs_accessible: bool, pub generation_counter: std::sync::atomic::AtomicU64, pub profile_list_json_override: std::sync::Mutex>, + pub profile_present: std::sync::atomic::AtomicBool, } impl MockNixAdapter { @@ -759,6 +760,7 @@ impl MockNixAdapter { nixpkgs_accessible: true, generation_counter: std::sync::atomic::AtomicU64::new(1), profile_list_json_override: std::sync::Mutex::new(None), + profile_present: std::sync::atomic::AtomicBool::new(installed), } } @@ -798,7 +800,8 @@ impl NixAdapter for MockNixAdapter { } fn profile_exists(&self) -> bool { - self.installed + self.profile_present + .load(std::sync::atomic::Ordering::SeqCst) } fn profile_path(&self) -> Result {