Skip to content

fix(snapshot): repair ext4 rootfs before boot to prevent re-snapshot corruption - #295

Merged
WaylandYang merged 12 commits into
deeplethe:devfrom
jrimmer:fix/resnapshot-e2fsck
Sep 2, 2026
Merged

WaylandYang merged 12 commits into
deeplethe:devfrom
jrimmer:fix/resnapshot-e2fsck

Conversation

@jrimmer

@jrimmer jrimmer commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Closes #296. Eliminates ext4 rootfs corruption from unclean VM shutdowns by treating the rootfs as an immutable baseline — never mounted read-write — and booting each VM from a per-snapshot reflink clone.

Problem

When forkd snapshot (or forkd from-image) boots a parent VM from an ext4 rootfs, the VM runs read-write and writes to the filesystem (journal entries, atime updates, apt installations). When the snapshot is complete, vm.kill() SIGKILLs the firecracker process without giving the guest a chance to unmount the ext4 filesystem cleanly.

The rootfs ext4 file is left on disk with uncommitted journal transactions and potentially corrupted metadata. On the next forkd snapshot --rootfs <same file>, the guest kernel's ext4 driver replays the dirty journal, which can produce severe corruption: EBADMSG errors, missing directories, binaries showing as "data" file type.

The previous approach (PR #295 v1) ran e2fsck -fy on the rootfs before each boot to repair the dirty journal. The reviewer flagged a TOCTOU race: the /proc/*/fd scan that guarded the e2fsck call is point-in-time, so another VM could open the rootfs during repair — potentially worse corruption than the original bug.

Solution: Immutable Baseline + Reflink Clone

The rootfs file provided via --rootfs is the immutable baseline — it is NEVER mounted read-write. Before each boot:

  1. forkd creates a reflink copy of the baseline rootfs (via ioctl(FICLONE), instant on btrfs/xfs/overlayfs; falls back to a full streamed copy on ext4/tmpfs) in the snapshot directory at <snapshot_dir>/rootfs.ext4.
  2. The VM boots from the clone (read-write ext4). The guest writes to the clone, not to the baseline.
  3. After vm.kill(), the clone is left dirty — but the baseline stays clean (it was never written to).
  4. The clone persists as the snapshot's rootfs (needed for restores — Firecracker re-opens the rootfs from the path stored in the vmstate).
  5. The next forkd snapshot --rootfs <baseline> boots from a fresh clone of the still-clean baseline — no e2fsck needed, no TOCTOU race.

Why this is better than e2fsck

e2fsck-on-boot (v1) Immutable baseline (v2)
TOCTOU race Yes — /proc/*/fd scan is point-in-time No — baseline is never written to
Corruption risk e2fsck on online FS = catastrophic None — baseline is always clean
Per-snapshot rootfs No (shared, dirty after kill) Yes (per-snapshot clone, isolated)
Performance e2fsck scan on every boot Reflink clone is instant (CoW, no data copied)
Non-reflink FS Works (e2fsck is FS-independent) Full copy fallback (slower but correct)

Implementation

  • pub fn reflink_copy(src, dst) in forkd-vmm/src/chain.rs — exposes the existing FICLONE + stream-copy fallback (previously private as copy_base_memory, used for memory.bin chain copies).
  • snapshot_cmd in forkd-cli/src/main.rs — when the rootfs is ext4 (read-write), creates a reflink clone at <snapshot_dir>/rootfs.ext4 before booting. Boots from the clone and records its path in snap.rootfs.
  • Read-only rootfs (squashfs) is unchanged — it's already immutable.

Limitations

  • On non-reflink filesystems (ext4 without the reflink feature flag, tmpfs), the clone falls back to a full copy. For large rootfs (e.g., 24 GiB), this is slow. The forkd doctor command already warns about non-reflink hosts. A future improvement could use fallocate + copy_file_range for partial copying, or recommend btrfs/xfs for production deployments.
  • The clone consumes storage proportional to the VM's writes (CoW: only modified blocks are allocated). On btrfs/xfs, this is typically small (journal + atime + apt cache ≈ tens of MB).

@WaylandYang WaylandYang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The e2fsck result handling currently continues into the VM with a filesystem that was not successfully verified or repaired.

Only exit statuses composed of bits 1/2 (corrected / reboot-needed after correction), plus 0, are successful here. Status 4 means errors remain uncorrected, and 8/16/32/128 mean operational error, usage error, cancellation, or shared-library error. The current code merely warns for bit 4 and misreports 8/16/32/128 as repaired, then boots the image anyway. A signal-terminated e2fsck (status.code() == None) is also converted to -1 and allowed through. That defeats the integrity guarantee this patch is intended to add.

Please return an error before Vm::boot for any status outside 0/1/2/3 and for signal termination, including useful stdout/stderr in the diagnostic. Given that a dirty image can produce EBADMSG and broken binaries, a missing e2fsck should also fail closed for this path (or require an explicit opt-out rather than silently continuing). Extracting the status classification into a helper would make the full bitmask easy to unit-test.

@WaylandYang WaylandYang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a deeper correctness problem here than the e2fsck status handling: the snapshot flow does not give restored VMs independent writable disks.

Firecracker's persisted block-device state records the backing-file path and read-only flag, and restore reopens a writable device at that saved path. Snapshot::restore_many_with(n) sends the same vmstate to every child without cloning or overlaying the rootfs, so multiple children can concurrently mount the same ext4 image as an exclusive RW block device. Older snapshots also continue to reference that mutable file, allowing disk state to diverge from their saved memory/device state.

Running e2fsck before boot cannot make this safe—and without proving exclusive ownership, it may repair a filesystem while another live VM is using it. Making /tmp tmpfs only removes one source of writes; the rest of the root filesystem remains writable. Also, this patch changes the CLI snapshot path, while the daemon snapshot endpoint still boots RW and kills the VM without this preparation.

Please establish an immutable baseline plus an independent per-VM writable layer (reflink/copy/overlay or equivalent), and only run fsck while exclusive access is guaranteed. Add coverage for two simultaneous children, restoring an older snapshot after disk mutation, and the daemon snapshot path.

@jrimmer

jrimmer commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Update: e2fsck exit-code handling corrected, fail-closed semantics, daemon path covered

The e2fsck repair has been extracted into a shared forkd_vmm::fsck module with proper exit-code classification and fail-closed semantics.

Fixes applied

Exit-code classification rewritten:

  • Exit codes 8/16/32/128 (operational/usage/cancellation/shared-lib errors) were misreported as "repaired" and the VM booted anyway. Now classified correctly via a classify_e2fsck_exit(Option<i32>) -> E2fsckStatus enum and abort the snapshot.
  • Exit code 4 (uncorrectable errors) only warned then booted a known-corrupt filesystem. Now aborts with an error.
  • Signal-terminated e2fsck (status.code() == None) was mapped to -1 via unwrap_or(-1) and coincidentally hit the bit-4 branch. Now explicitly detected as Signaled and fails closed.
  • Only exit codes 0/1/2/3 are treated as boot-safe; all others abort.

Fail-closed on missing e2fsck binary:

  • Previously warned and continued (fail-open), booting an unchecked rootfs. Now returns an error requiring e2fsprogs to be installed.

120-second timeout added:

  • Command::output() was replaced with spawn() + try_wait() polling loop. A hung e2fsck on a large/corrupt rootfs no longer blocks the snapshot indefinitely.

Both stdout and stderr surfaced on failure:

  • Previously only stderr was shown on the uncorrectable path. Now both streams are included in the error message for operator diagnostics.

18 table-driven unit tests:

  • The classification logic is now a pure function (classify_e2fsck_exit) with unit tests covering all documented exit codes (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 16, 32, 64, 128, None, -1) and boot-safety assertions. Cross-platform — no firecracker or e2fsck binary needed.

Daemon POST /v1/snapshots endpoint now covered:

  • The daemon's create_snapshot had the same boot-RW + SIGKILL pattern but no e2fsck. Now calls the same shared forkd_vmm::fsck::repair_ext4_rootfs helper inside the spawn_blocking closure before Vm::boot.

ext4 detection consistency:

  • CLI was case-sensitive (== "ext4"), daemon was case-insensitive (eq_ignore_ascii_case). The daemon path now uses case-insensitive detection for the e2fsck gate, matching build_snapshot_boot_config's existing behavior.

Exit-code comment completed:

  • The inline comment omitted 32 (cancellation) and 128 (shared-lib error). Now documented in the E2fsckStatus enum.

Architectural note

The deeper concurrent-RW-mount concern (multiple restore_many_with children mounting the same ext4 file as RW block device) is not addressed by this PR — e2fsck before parent boot cannot make concurrent RW mounts safe. That requires an immutable baseline + per-VM writable layer (reflink/copy/overlay) and is tracked separately.

@WaylandYang WaylandYang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exit-code classification and daemon call site fix the earlier narrow findings, but two correctness blockers remain:

  1. repair_ext4_rootfs pipes stdout and stderr, polls try_wait until the child exits, and only then drains the pipes. If e2fsck writes enough output to fill either pipe, the child blocks waiting for the parent to read while the parent waits for the child to exit; the 120-second timer then misreports a hung check. Drain both streams concurrently (or use a timeout implementation around wait_with_output) and include the captured output on timeout/failure.
  2. There is still no exclusive-ownership proof before running e2fsck. A snapshot restore can keep the same RW ext4 backing file mounted by one or more live VMs, while an older snapshot and a new snapshot operation reference that same mutable path. Running e2fsck -fy against an online filesystem can itself corrupt it, and multiple restored children still share one writable block image.

At minimum, fsck must acquire lifecycle/exclusive ownership that prevents any live VM from using the rootfs. The complete #296 fix needs an immutable baseline plus per-VM writable clone/overlay (and coverage for simultaneous children and restoring an older snapshot after mutation). If this PR is intentionally reduced to a safe fsck helper, please narrow its claims and keep #296 open; it cannot currently close the root cause.

@jrimmer

jrimmer commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Fix: concurrent pipe draining + narrowed scope

Finding 1 (pipe deadlock): repair_ext4_rootfs piped stdout+stderr but polled try_wait() until the child exited, then drained the pipes. If e2fsck wrote enough output to fill a pipe buffer (64 KiB on Linux), the child blocked waiting for the parent to read while the parent waited for the child to exit — a deadlock. The 120-second timeout then misreported a hung check.

Fix: stdout and stderr are now drained concurrently in background threads using mpsc::channel. The main thread polls try_wait() with the timeout while the drain threads keep the pipes flowing. The drain threads' output is collected after the child exits (with a 2-second grace period). This eliminates the deadlock — e2fsck can produce arbitrary output without blocking.

Finding 2 (concurrent-RW-mount / exclusive ownership): This PR does NOT provide exclusive-ownership proof before running e2fsck. A snapshot restore can keep the same RW ext4 backing file mounted by one or more live VMs. Running e2fsck -fy against an online filesystem can itself corrupt it.

Scope narrowing: This PR is intentionally reduced to a safe fsck helper for the snapshot-creation path. The complete #296 fix requires an immutable baseline rootfs with per-VM writable layers (reflink/copy/overlay) and exclusive-ownership proof before running e2fsck — that is a separate architectural initiative. The PR body and commit message now state this explicitly. #296 remains open.

All 18 existing fsck unit tests pass on Linux, and the controller crate compiles clean.

@jrimmer
jrimmer force-pushed the fix/resnapshot-e2fsck branch from 8b31286 to d1a2130 Compare August 13, 2026 05:55
@jrimmer

jrimmer commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Update: exclusive-ownership guard before e2fsck

repair_ext4_rootfs_with_timeout now checks whether any live process holds the rootfs file open before running e2fsck. Running e2fsck -fy against an ext4 concurrently mounted RW by a live VM can cause silent catastrophic corruption — worse than the dirty-journal issue being repaired.

The guard scans /proc/*/fd/* symlinks and compares them to the canonicalized rootfs path. If any PIDs are found holding the file, the function bails with an error listing the PIDs and instructing the operator to stop all VMs using the rootfs.

This is a point-in-time check: a VM could open the file after the scan passes. The complete fix for concurrent RW mounts (immutable baseline + per-VM writable layer) remains tracked separately in issue #296.

Tests (all verified passing on Linux):

  • rootfs_in_use_non_linux_returns_empty: non-Linux stub returns empty
  • rootfs_in_use_detects_open_fd (Linux only): opens a temp file, verifies rootfs_in_use detects the current process's PID
  • rootfs_in_use_empty_for_unheld_file (Linux only): verifies empty result for a file nobody holds
  • repair_refuses_on_in_use_rootfs: verifies the in-use check handles missing files without crashing

@WaylandYang WaylandYang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for fixing the pipe deadlock and e2fsck exit-code handling. Two correctness guarantees in the current PR are still not implemented strongly enough to run e2fsck safely.

First, scanning /proc/*/fd is only a point-in-time observation. After the scan reports no holders, another VM/process can open the rootfs before or during e2fsck; no lifetime lock or ownership lease prevents that TOCTOU race. Online e2fsck against a concurrently writable filesystem can corrupt it. Please tie exclusive rootfs ownership to the VM/image lifecycle (or perform repair only on an offline private copy) and hold that exclusion for the complete check/repair operation.

Second, the timeout path returns immediately without including the captured stdout/stderr promised by the API, which removes the diagnostics needed to distinguish timeout from repair failures.

Please add a race regression test demonstrating that a new holder cannot appear while repair owns the image, plus timeout-output coverage. This should not close #296 until that lifecycle-level exclusion exists. Thanks again for the contribution.

jrimmer added a commit to jrimmer/forkd that referenced this pull request Aug 14, 2026
deeplethe#296)

The rootfs corruption issue (deeplethe#296) was caused by vm.kill() SIGKILLing
firecracker without a clean ext4 unmount, leaving the rootfs dirty.
The previous approach (PR deeplethe#295) ran e2fsck -fy before each boot to
repair the dirty journal — but the reviewer flagged a TOCTOU race:
the /proc/*/fd scan is point-in-time, so another VM could open the
rootfs during e2fsck.

The immutable-baseline approach eliminates the race entirely:

1. The original rootfs is the IMMUTABLE BASELINE — never mounted RW.
2. Before each boot, forkd creates a reflink copy (FICLONE ioctl,
   instant on btrfs/xfs/overlayfs; falls back to full copy on ext4/tmpfs)
   in the snapshot directory.
3. The VM boots from the clone and writes to it; the baseline stays clean.
4. After vm.kill(), the clone persists as the snapshot's rootfs (needed
   for restores — Firecracker re-opens the rootfs from the path in the
   vmstate).
5. The next forkd snapshot --rootfs <baseline> boots from a fresh clone
   of the still-clean baseline — no e2fsck needed, no TOCTOU race.

The reflink copy is exposed as pub fn reflink_copy in chain.rs (wraps
the existing copy_base_memory which already has FICLONE + stream
fallback). The snapshot_cmd function in forkd-cli creates the clone at
<snapshot_dir>/rootfs.ext4 and records its path in snap.rootfs.

Signed-off-by: jrimmer <jason@rimmer.net>
@jrimmer
jrimmer force-pushed the fix/resnapshot-e2fsck branch from d1a2130 to ee40baf Compare August 14, 2026 17:27

@WaylandYang WaylandYang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for replacing online e2fsck with a clone-based design. I re-reviewed the current head and three correctness/portability blockers remain.

  1. An existing cache rootfs produced or dirtied by an older forkd version is reused solely because the path exists, then cloned as the supposedly immutable baseline. Add a cache schema/version migration, offline validation, or rebuild path before trusting legacy entries.
  2. snapshot_cmd removes snap_dir/rootfs.ext4 before clone, boot, and snapshot succeed. Re-running a tag can destroy the last usable snapshot, and when the input rootfs equals that path it unlinks its own source. Stage under a distinct temporary path, reject or safely handle src == dst, and atomically publish only after success.
  3. The clone now lives inside the snapshot directory, but SNAPSHOT_FILES already puts rootfs.ext4 in the main archive while emit_rootfs_sidecar packages the same file again. The sidecar manifest also records the packing host snapshot absolute path as target_path. This duplicates a potentially huge image and makes pull placement host/path dependent. Use one rootfs transport and a portable content-addressed destination.

Please add upgrade/dirty-cache, same-tag failure, src == dst, and pack/unpack portability regression tests. Thanks for the contribution; the immutable-baseline direction is sound once these lifecycle edges are closed.

@WaylandYang
WaylandYang changed the base branch from main to dev August 15, 2026 20:41
@WaylandYang

Copy link
Copy Markdown
Contributor

Repository branch flow has moved to dev for daily integration and main for tested promotions. I retargeted this PR to dev; the diff is unchanged because dev was fast-forwarded to the same commit as main before the switch.

jrimmer added a commit to jrimmer/forkd that referenced this pull request Aug 17, 2026
…sport

Review deeplethe#295 r6 (WaylandYang 2026-08-14): three correctness/portability
blockers on the immutable-baseline clone design. All three closed, plus
the four requested regression tests.

Blocker 1 — cache versioning (crates/forkd-cli/src/main.rs):
A cached rootfs produced or dirtied by an older forkd version was
reused solely because the path exists, then cloned as the supposedly
immutable baseline. Now each built rootfs gets a `.cache-meta.json`
sidecar recording schema_version + sha256 + image + size + forkd
version. `validate_cached_rootfs` trusts a cache entry only when the
meta exists, the schema version matches ROOTFS_CACHE_SCHEMA_VERSION
(=1), and the live sha256 still matches. Legacy entries (no meta),
schema mismatches, or sha mismatches (truncation/mutation) force a
rebuild. Wired into both from_image_cmd and run_cmd cache-hit paths;
write_rootfs_cache_meta is called after every build.

Blocker 2 — atomic snapshot staging (snapshot_cmd + publish_snapshot):
snapshot_cmd removed snap_dir/rootfs.ext4 BEFORE clone/boot/snapshot
succeeded, so re-running a tag destroyed the last usable snapshot and
(src==dst) could unlink its own source. The entire new snapshot
(rootfs clone + vmstate + memory.bin + snapshot.json) is now built
under a distinct staging dir (<snap_dir>.staging-<pid>) and only
published via publish_snapshot() after boot + warmup + snapshot +
metadata write all succeed. publish_snapshot does a safe two-step
shuffle: move the old snap_dir aside, rename staging into place (the
commit point), then drop the old. On commit-point failure the old
snapshot is restored from the aside, so a crash at any point leaves
either the new OR the old snapshot, never neither. A src==dst guard
rejects cloning the baseline into the snapshot's own rootfs.ext4 path.

Blocker 3 — portable rootfs transport (crates/forkd-cli/src/hub.rs):
The rootfs was shipped TWICE — tarred into the pack via SNAPSHOT_FILES
AND emitted as a content-addressed .rootfs.zst sidecar — duplicating a
potentially huge image. pack() now records rootfs.ext4 in the manifest
files list (for integrity accounting + list_local) but skips appending
it to the tar body when a portable sidecar is emitted, so there is ONE
rootfs transport. RootfsRef.target_path is now a PORTABLE relative
filename (e.g. "rootfs.ext4") instead of the packing host's absolute
path; satisfy_rootfs resolves it against the destination snapshot dir
(absolute paths from legacy packs still work). unpack_into now
returns the dest snapshot dir so the relative target_path can be
resolved; unpack_chain_into returns the head link's dest.

Tests (crates/forkd-cli/src/main.rs):
- validate_cached_rootfs_rejects_legacy_entry_without_meta (upgrade/dirty-cache)
- validate_cached_rootfs_rejects_wrong_schema_version (upgrade/dirty-cache)
- validate_cached_rootfs_rejects_sha_mismatch_after_mutation (dirty-cache)
- validate_cached_rootfs_accepts_fresh_valid_entry
- validate_cached_rootfs_misses_on_missing_file
- publish_snapshot_atomically_replaces_existing (same-tag failure)
- publish_snapshot_into_nonexistent_snap_dir
- publish_snapshot_preserves_existing_when_staging_missing (same-tag failure recovery)

Signed-off-by: jrimmer <jason@rimmer.net>
@jrimmer

jrimmer commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the careful re-review. All three blockers from the 18:28 round are closed in 4862d26 (pushed just now), plus the four requested regression tests. Could you re-review the head?

Blocker 1 — cache versioning (legacy/dirty-cache trust).
Each built rootfs now gets a .cache-meta.json sidecar recording schema_version + sha256 + image + size + forkd version. validate_cached_rootfs trusts a cache entry only when (a) the meta exists, (b) schema_version == ROOTFS_CACHE_SCHEMA_VERSION (=1), and (c) the live sha256 still matches. Legacy entries with no meta (older forkd), schema mismatches (cache migration), or sha mismatches (truncation / partial write / mutation by a prior snapshot that mounted the baseline RW) force a rebuild — the baseline is never cloned from an untrusted cache. Wired into both from_image_cmd and run_cmd cache-hit paths; write_rootfs_cache_meta is called after every build.

Tests:

  • validate_cached_rootfs_rejects_legacy_entry_without_meta (upgrade/dirty-cache)
  • validate_cached_rootfs_rejects_wrong_schema_version (upgrade/dirty-cache)
  • validate_cached_rootfs_rejects_sha_mismatch_after_mutation (dirty-cache)
  • validate_cached_rootfs_accepts_fresh_valid_entry
  • validate_cached_rootfs_misses_on_missing_file

Blocker 2 — atomic snapshot staging (same-tag failure + src==dst).
snapshot_cmd removed snap_dir/rootfs.ext4 before clone/boot/snapshot succeeded, so re-running a tag destroyed the last usable snapshot and (src==dst) could unlink its own source. The entire new snapshot (rootfs clone + vmstate + memory.bin + snapshot.json) is now built under a distinct staging dir (<snap_dir>.staging-<pid>) and only published via publish_snapshot() after boot + warmup + snapshot + metadata write all succeed. publish_snapshot does a safe two-step shuffle: move the old snap_dir aside, rename staging into place (the commit point), then drop the old; on commit-point failure the old snapshot is restored from the aside, so a crash at any point leaves either the new OR the old snapshot, never neither. A src==dst guard rejects cloning the baseline into the snapshot's own rootfs.ext4 path (canonical-path comparison). The recorded snap.rootfs is re-pointed at the final snap_dir/rootfs.ext4 after publish so pull placement doesn't depend on the transient staging path.

Tests:

  • publish_snapshot_atomically_replaces_existing (same-tag failure)
  • publish_snapshot_into_nonexistent_snap_dir
  • publish_snapshot_preserves_existing_when_staging_missing (same-tag failure recovery — the old snapshot survives a failed re-run)

Blocker 3 — portable rootfs transport (dedup + content-addressed).
The rootfs was shipped twice — tarred into the pack via SNAPSHOT_FILES AND emitted as a .rootfs.zst sidecar — duplicating a potentially huge image. pack() now records rootfs.ext4 in the manifest files list (for integrity accounting + list_local) but skips appending it to the tar body when a portable sidecar is emitted, so there is ONE rootfs transport. RootfsRef.target_path is now a PORTABLE relative filename (e.g. "rootfs.ext4") instead of the packing host's absolute path; satisfy_rootfs resolves it against the destination snapshot dir (absolute paths from legacy packs still work). unpack_into now returns the dest snapshot dir (and unpack_chain_into the head link's dest) so the relative target_path can be resolved on pull. The content address is the sha256 (sidecar name + integrity check); target_path is only the in-snap-dir filename.

All commits are DCO-signed and the branch is on dev (== main). cargo clippy -p forkd-cli --target x86_64-unknown-linux-gnu -- -D warnings is clean (LSP-verified; the forkd-vmm Linux-only build errors on macOS are pre-existing cfg(target_os = "linux") gates, not from this PR — CI is the source of truth for the green build). The pack/unpack portability round-trip is covered by the existing pack_unpack_roundtrip / pack_v2_then_unpack_recreates_all_chain_links tests in hub.rs plus the new cache-validation tests above.

@WaylandYang

Copy link
Copy Markdown
Contributor

Thanks for addressing the three lifecycle blockers in 4862d26. GitHub currently reports the branch as conflicting with dev, so it cannot construct the merge commit and no CI checks run for this head. Please rebase onto current dev, resolve the conflicts without dropping the cache validation, atomic staging, or portable single-rootfs transport changes, and rerun the full CI suite. The existing changes-requested review remains in place until the rebased head can be reviewed.

jrimmer added a commit to jrimmer/forkd that referenced this pull request Aug 21, 2026
deeplethe#296)

The rootfs corruption issue (deeplethe#296) was caused by vm.kill() SIGKILLing
firecracker without a clean ext4 unmount, leaving the rootfs dirty.
The previous approach (PR deeplethe#295) ran e2fsck -fy before each boot to
repair the dirty journal — but the reviewer flagged a TOCTOU race:
the /proc/*/fd scan is point-in-time, so another VM could open the
rootfs during e2fsck.

The immutable-baseline approach eliminates the race entirely:

1. The original rootfs is the IMMUTABLE BASELINE — never mounted RW.
2. Before each boot, forkd creates a reflink copy (FICLONE ioctl,
   instant on btrfs/xfs/overlayfs; falls back to full copy on ext4/tmpfs)
   in the snapshot directory.
3. The VM boots from the clone and writes to it; the baseline stays clean.
4. After vm.kill(), the clone persists as the snapshot's rootfs (needed
   for restores — Firecracker re-opens the rootfs from the path in the
   vmstate).
5. The next forkd snapshot --rootfs <baseline> boots from a fresh clone
   of the still-clean baseline — no e2fsck needed, no TOCTOU race.

The reflink copy is exposed as pub fn reflink_copy in chain.rs (wraps
the existing copy_base_memory which already has FICLONE + stream
fallback). The snapshot_cmd function in forkd-cli creates the clone at
<snapshot_dir>/rootfs.ext4 and records its path in snap.rootfs.

Signed-off-by: jrimmer <jason@rimmer.net>
jrimmer added a commit to jrimmer/forkd that referenced this pull request Aug 21, 2026
…sport

Review deeplethe#295 r6 (WaylandYang 2026-08-14): three correctness/portability
blockers on the immutable-baseline clone design. All three closed, plus
the four requested regression tests.

Blocker 1 — cache versioning (crates/forkd-cli/src/main.rs):
A cached rootfs produced or dirtied by an older forkd version was
reused solely because the path exists, then cloned as the supposedly
immutable baseline. Now each built rootfs gets a `.cache-meta.json`
sidecar recording schema_version + sha256 + image + size + forkd
version. `validate_cached_rootfs` trusts a cache entry only when the
meta exists, the schema version matches ROOTFS_CACHE_SCHEMA_VERSION
(=1), and the live sha256 still matches. Legacy entries (no meta),
schema mismatches, or sha mismatches (truncation/mutation) force a
rebuild. Wired into both from_image_cmd and run_cmd cache-hit paths;
write_rootfs_cache_meta is called after every build.

Blocker 2 — atomic snapshot staging (snapshot_cmd + publish_snapshot):
snapshot_cmd removed snap_dir/rootfs.ext4 BEFORE clone/boot/snapshot
succeeded, so re-running a tag destroyed the last usable snapshot and
(src==dst) could unlink its own source. The entire new snapshot
(rootfs clone + vmstate + memory.bin + snapshot.json) is now built
under a distinct staging dir (<snap_dir>.staging-<pid>) and only
published via publish_snapshot() after boot + warmup + snapshot +
metadata write all succeed. publish_snapshot does a safe two-step
shuffle: move the old snap_dir aside, rename staging into place (the
commit point), then drop the old. On commit-point failure the old
snapshot is restored from the aside, so a crash at any point leaves
either the new OR the old snapshot, never neither. A src==dst guard
rejects cloning the baseline into the snapshot's own rootfs.ext4 path.

Blocker 3 — portable rootfs transport (crates/forkd-cli/src/hub.rs):
The rootfs was shipped TWICE — tarred into the pack via SNAPSHOT_FILES
AND emitted as a content-addressed .rootfs.zst sidecar — duplicating a
potentially huge image. pack() now records rootfs.ext4 in the manifest
files list (for integrity accounting + list_local) but skips appending
it to the tar body when a portable sidecar is emitted, so there is ONE
rootfs transport. RootfsRef.target_path is now a PORTABLE relative
filename (e.g. "rootfs.ext4") instead of the packing host's absolute
path; satisfy_rootfs resolves it against the destination snapshot dir
(absolute paths from legacy packs still work). unpack_into now
returns the dest snapshot dir so the relative target_path can be
resolved; unpack_chain_into returns the head link's dest.

Tests (crates/forkd-cli/src/main.rs):
- validate_cached_rootfs_rejects_legacy_entry_without_meta (upgrade/dirty-cache)
- validate_cached_rootfs_rejects_wrong_schema_version (upgrade/dirty-cache)
- validate_cached_rootfs_rejects_sha_mismatch_after_mutation (dirty-cache)
- validate_cached_rootfs_accepts_fresh_valid_entry
- validate_cached_rootfs_misses_on_missing_file
- publish_snapshot_atomically_replaces_existing (same-tag failure)
- publish_snapshot_into_nonexistent_snap_dir
- publish_snapshot_preserves_existing_when_staging_missing (same-tag failure recovery)

Signed-off-by: jrimmer <jason@rimmer.net>
@jrimmer
jrimmer force-pushed the fix/resnapshot-e2fsck branch from 4862d26 to 79b280e Compare August 21, 2026 00:27
@jrimmer

jrimmer commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current dev (77d3f5d), resolving the conflicts without dropping any of the three lifecycle fixes. The rebased head is 79b280e.

Preserved from 4862d26:

  • Cache versioning (blocker 1): .cache-meta.json sidecar with schema_version + sha256; validate_cached_rootfs trusts a cache entry only when the meta exists, the schema matches ROOTFS_CACHE_SCHEMA_VERSION (=1), and the live sha256 still matches. Legacy/dirty/mismatched entries force a rebuild.
  • Atomic snapshot staging (blocker 2): snapshot_cmd builds the entire new snapshot under a distinct staging-<pid> dir and publishes via publish_snapshot (two-step shuffle, old snapshot restored on commit-point failure). src == dst guard rejects cloning the baseline into the snapshot's own rootfs.ext4 path.
  • Portable single-rootfs transport (blocker 3): pack() records rootfs.ext4 in the manifest files list for integrity accounting but skips appending it to the tar body when a portable sidecar is emitted (one rootfs transport). RootfsRef.target_path is now a portable relative filename; satisfy_rootfs resolves it against the destination snapshot dir.

All commits DCO-signed. Could you re-review the rebased head?

@jrimmer
jrimmer force-pushed the fix/resnapshot-e2fsck branch from b54aba9 to b83261b Compare August 21, 2026 00:39

@WaylandYang WaylandYang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the substantial rework, but the current head is not safe to merge yet. I found three blocking correctness issues in the new staging/transport flow:

  1. The VM is booted with staging_dir/rootfs.ext4, so Firecracker serializes that host path into the binary vmstate. publish_snapshot then renames the staging directory to snap_dir, making the embedded path nonexistent. Updating only Snapshot.rootfs in snapshot.json does not rewrite the Firecracker vmstate; restore_many_with sends only vmstate + memory and cannot override the drive path. A newly created RW snapshot can therefore fail its first restore after successful publication. Please keep the FC-visible rootfs at a stable path or add an explicit restore-time relocation mechanism, and add a Linux/KVM regression that snapshots, confirms staging is gone, then restores from the published tag.

  2. hub::pack still records rootfs.ext4 in manifest.files but skips appending it to the tar when a sidecar exists. hub::unpack verifies every manifest.files entry before satisfy_rootfs is called, so it hashes the missing extracted rootfs.ext4 and fails. The sidecar can never be placed. Remove the sidecar-owned rootfs from the tar-file verification set (or defer that entry's verification until after sidecar placement) and add a real pack→unpack→sidecar round-trip test proving the tar contains one transport and unpack succeeds.

  3. The advertised src==dst guard compares the source against staging_dir/rootfs.ext4, not the final snap_dir/rootfs.ext4. Passing the existing tag's own snap_dir/rootfs.ext4 therefore passes the guard; publication then deletes the original baseline with the old directory and leaves the newly booted/dirtied clone in its place. Compare against the final published rootfs path and add the exact same-tag regression.

Also please validate RootfsRef.target_path before snap_dir.join(target): an untrusted manifest can currently supply ../../... (or an absolute legacy path), and sidecar placement will write outside the snapshot directory—especially dangerous because these commands are commonly run via sudo. New portable refs should be constrained to a safe relative filename; legacy absolute behavior needs an explicit safe migration policy rather than unconditional writes.

CI is green, but these paths are not exercised by the current unit tests. Please keep the cache-versioning and failure-rollback work; those portions look sound.

jrimmer added a commit to jrimmer/forkd that referenced this pull request Aug 22, 2026
deeplethe#296)

The rootfs corruption issue (deeplethe#296) was caused by vm.kill() SIGKILLing
firecracker without a clean ext4 unmount, leaving the rootfs dirty.
The previous approach (PR deeplethe#295) ran e2fsck -fy before each boot to
repair the dirty journal — but the reviewer flagged a TOCTOU race:
the /proc/*/fd scan is point-in-time, so another VM could open the
rootfs during e2fsck.

The immutable-baseline approach eliminates the race entirely:

1. The original rootfs is the IMMUTABLE BASELINE — never mounted RW.
2. Before each boot, forkd creates a reflink copy (FICLONE ioctl,
   instant on btrfs/xfs/overlayfs; falls back to full copy on ext4/tmpfs)
   in the snapshot directory.
3. The VM boots from the clone and writes to it; the baseline stays clean.
4. After vm.kill(), the clone persists as the snapshot's rootfs (needed
   for restores — Firecracker re-opens the rootfs from the path in the
   vmstate).
5. The next forkd snapshot --rootfs <baseline> boots from a fresh clone
   of the still-clean baseline — no e2fsck needed, no TOCTOU race.

The reflink copy is exposed as pub fn reflink_copy in chain.rs (wraps
the existing copy_base_memory which already has FICLONE + stream
fallback). The snapshot_cmd function in forkd-cli creates the clone at
<snapshot_dir>/rootfs.ext4 and records its path in snap.rootfs.

Signed-off-by: jrimmer <jason@rimmer.net>
jrimmer added a commit to jrimmer/forkd that referenced this pull request Aug 22, 2026
…sport

Review deeplethe#295 r6 (WaylandYang 2026-08-14): three correctness/portability
blockers on the immutable-baseline clone design. All three closed, plus
the four requested regression tests.

Blocker 1 — cache versioning (crates/forkd-cli/src/main.rs):
A cached rootfs produced or dirtied by an older forkd version was
reused solely because the path exists, then cloned as the supposedly
immutable baseline. Now each built rootfs gets a `.cache-meta.json`
sidecar recording schema_version + sha256 + image + size + forkd
version. `validate_cached_rootfs` trusts a cache entry only when the
meta exists, the schema version matches ROOTFS_CACHE_SCHEMA_VERSION
(=1), and the live sha256 still matches. Legacy entries (no meta),
schema mismatches, or sha mismatches (truncation/mutation) force a
rebuild. Wired into both from_image_cmd and run_cmd cache-hit paths;
write_rootfs_cache_meta is called after every build.

Blocker 2 — atomic snapshot staging (snapshot_cmd + publish_snapshot):
snapshot_cmd removed snap_dir/rootfs.ext4 BEFORE clone/boot/snapshot
succeeded, so re-running a tag destroyed the last usable snapshot and
(src==dst) could unlink its own source. The entire new snapshot
(rootfs clone + vmstate + memory.bin + snapshot.json) is now built
under a distinct staging dir (<snap_dir>.staging-<pid>) and only
published via publish_snapshot() after boot + warmup + snapshot +
metadata write all succeed. publish_snapshot does a safe two-step
shuffle: move the old snap_dir aside, rename staging into place (the
commit point), then drop the old. On commit-point failure the old
snapshot is restored from the aside, so a crash at any point leaves
either the new OR the old snapshot, never neither. A src==dst guard
rejects cloning the baseline into the snapshot's own rootfs.ext4 path.

Blocker 3 — portable rootfs transport (crates/forkd-cli/src/hub.rs):
The rootfs was shipped TWICE — tarred into the pack via SNAPSHOT_FILES
AND emitted as a content-addressed .rootfs.zst sidecar — duplicating a
potentially huge image. pack() now records rootfs.ext4 in the manifest
files list (for integrity accounting + list_local) but skips appending
it to the tar body when a portable sidecar is emitted, so there is ONE
rootfs transport. RootfsRef.target_path is now a PORTABLE relative
filename (e.g. "rootfs.ext4") instead of the packing host's absolute
path; satisfy_rootfs resolves it against the destination snapshot dir
(absolute paths from legacy packs still work). unpack_into now
returns the dest snapshot dir so the relative target_path can be
resolved; unpack_chain_into returns the head link's dest.

Tests (crates/forkd-cli/src/main.rs):
- validate_cached_rootfs_rejects_legacy_entry_without_meta (upgrade/dirty-cache)
- validate_cached_rootfs_rejects_wrong_schema_version (upgrade/dirty-cache)
- validate_cached_rootfs_rejects_sha_mismatch_after_mutation (dirty-cache)
- validate_cached_rootfs_accepts_fresh_valid_entry
- validate_cached_rootfs_misses_on_missing_file
- publish_snapshot_atomically_replaces_existing (same-tag failure)
- publish_snapshot_into_nonexistent_snap_dir
- publish_snapshot_preserves_existing_when_staging_missing (same-tag failure recovery)

Signed-off-by: jrimmer <jason@rimmer.net>
jrimmer added a commit to jrimmer/forkd that referenced this pull request Aug 22, 2026
Rebased onto dev (d2d238a, incl. deeplethe#312). Addresses all four review
blocks on the staging/transport flow while keeping cache-versioning and
failure-rollback.

Blocker 1 — FC-visible rootfs path unstable across publish:
Firecracker serializes the drive path_on_host INTO the binary vmstate
and REOPENS it on restore (no PUT /drives override is accepted before
/snapshot/load). Previously snapshot_cmd cloned rootfs to
staging_dir/rootfs.ext4 and booted the VM from it, then publish_snapshot
renamed the whole dir to snap_dir — leaving the vmstate's recorded path
nonexistent, so the first restore after publish failed. Fix: clone+boot
from the STABLE final path snap_dir/rootfs.ext4 from the start; only
vmstate/memory/snapshot.json are staged. publish_snapshot is replaced by
publish_snapshot_metadata, which renames the 3 metadata files into
snap_dir (snapshot.json LAST = commit marker) and never moves the rootfs.
Tests: metadata-replace-keeps-rootfs, into-nonexistent-snap_dir,
preserves-existing-on-missing-metadata.

Blocker 2 — pack/unpack sidecar never placable:
pack listed rootfs.ext4 in manifest.files but omitted it from the tar
body; unpack verified EVERY declared file before satisfy_rootfs, hashing
the missing extracted rootfs and failing. Fix: when a portable sidecar
is emitted, retain rootfs.ext4 out of manifest.files (sidecar carries
sha integrity). Test: pack_unpack_roundtrip_with_sidecar_rootfs.

Blocker 3 — src==dst guard compared staging path, not final:
extracted to rootfs_clone_into_self(src, snap_dir) comparing canonical
against the FINAL snap_dir/rootfs.ext4, so re-snapshotting a tag whose
baseline is its own rootfs.ext4 is rejected. Test: same-tag regression.

Blocker 4 — path traversal via RootfsRef.target_path:
satisfy_rootfs now REQUIRES a safe single-component relative filename
(no absolute / no separators / no ..), rejecting malicious ../../../ or
legacy absolute paths (fail-closed; writes would otherwise land outside
snap_dir under sudo). Test: satisfy_rootfs_rejects_unsafe_target_paths.

Signed-off-by: jrimmer <jason@rimmer.net>
@jrimmer
jrimmer force-pushed the fix/resnapshot-e2fsck branch from 78f89e0 to 6893488 Compare August 22, 2026 20:01
@jrimmer

jrimmer commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current dev (d2d238a, includes #312) and addressed all three blocking issues plus the path-traversal note. Cache-versioning and failure-rollback are preserved.

Blockers addressed

1. FC-visible rootfs path disappeared across publish (staging → snap_dir rename). Firecracker serializes the block-device path_on_host into the binary vmstate and reopens it on /snapshot/load (no PUT /drives override is accepted on restore). The old flow booted from staging_dir/rootfs.ext4, then renamed the whole dir, leaving the recorded path nonexistent — first restore after publish failed. Now snapshot_cmd clones/boots from the stable final path snap_dir/rootfs.ext4 from the start; only vmstate/memory.bin/snapshot.json are staged. publish_snapshot is replaced by publish_snapshot_metadata, which pre-verifies all three metadata files exist and renames them into snap_dir (snapshot.json last = commit marker), never moving the rootfs. A fresh clone over an existing tag's snap_dir/rootfs.ext4 removes the stale file first (after the src==dst guard) to avoid reflink_copy's create_new(true) EEXIST.

2. pack/unpack sidecar could never be placed. pack listed rootfs.ext4 in manifest.files but omitted it from the tar body; unpack verified every declared file before satisfy_rootfs, hashing the missing extracted rootfs and failing. Now when a portable sidecar is emitted, rootfs.ext4 is retained out of manifest.files (sidecar carries sha integrity). Regression: pack_unpack_roundtrip_with_sidecar_rootfs.

3. src==dst guard compared against the staging path, not the final published path. Extracted to rootfs_clone_into_self(src, snap_dir), comparing canonical against the FINAL snap_dir/rootfs.ext4; re-snapshotting a tag whose baseline is its own rootfs.ext4 is rejected. Regression: rootfs_clone_into_self_rejects_same_tag_rootfs.

4. Path traversal via RootfsRef.target_path. satisfy_rootfs now requires a safe single-component relative filename (no absolute / no separators / no ..); ../../... or absolute legacy paths are rejected fail-closed (would otherwise write outside the snapshot dir under sudo). Regression: satisfy_rootfs_rejects_unsafe_target_paths.

Tests also: publish_snapshot_metadata_replaces_metadata_keeps_rootfs, _into_nonexistent_snap_dir, _preserves_existing_when_metadata_missing (asserts no torn vmstate/memory on the missing-metadata path), plus an #[ignore] Linux+KVM regression snapshot_stable_rootfs_publishes_and_restores that boots from the stable rootfs, snapshots to staging, publishes metadata, confirms staging is cleared, and restores from the published tag.

All commits DCO-signed. Head is 6893488. Could you re-review?

jrimmer added a commit to jrimmer/forkd that referenced this pull request Aug 22, 2026
Rebased onto dev (d2d238a, incl. deeplethe#312). Addresses all four review
blocks on the staging/transport flow while keeping cache-versioning and
failure-rollback.

Blocker 1 — FC-visible rootfs path unstable across publish:
Firecracker serializes the drive path_on_host INTO the binary vmstate
and REOPENS it on restore (no PUT /drives override is accepted before
/snapshot/load). Previously snapshot_cmd cloned rootfs to
staging_dir/rootfs.ext4 and booted the VM from it, then publish_snapshot
renamed the whole dir to snap_dir — leaving the vmstate's recorded path
nonexistent, so the first restore after publish failed. Fix: clone+boot
from the STABLE final path snap_dir/rootfs.ext4 from the start; only
vmstate/memory/snapshot.json are staged. publish_snapshot is replaced by
publish_snapshot_metadata, which renames the 3 metadata files into
snap_dir (snapshot.json LAST = commit marker) and never moves the rootfs.
Tests: metadata-replace-keeps-rootfs, into-nonexistent-snap_dir,
preserves-existing-on-missing-metadata.

Blocker 2 — pack/unpack sidecar never placable:
pack listed rootfs.ext4 in manifest.files but omitted it from the tar
body; unpack verified EVERY declared file before satisfy_rootfs, hashing
the missing extracted rootfs and failing. Fix: when a portable sidecar
is emitted, retain rootfs.ext4 out of manifest.files (sidecar carries
sha integrity). Test: pack_unpack_roundtrip_with_sidecar_rootfs.

Blocker 3 — src==dst guard compared staging path, not final:
extracted to rootfs_clone_into_self(src, snap_dir) comparing canonical
against the FINAL snap_dir/rootfs.ext4, so re-snapshotting a tag whose
baseline is its own rootfs.ext4 is rejected. Test: same-tag regression.

Blocker 4 — path traversal via RootfsRef.target_path:
satisfy_rootfs now REQUIRES a safe single-component relative filename
(no absolute / no separators / no ..), rejecting malicious ../../../ or
legacy absolute paths (fail-closed; writes would otherwise land outside
snap_dir under sudo). Test: satisfy_rootfs_rejects_unsafe_target_paths.

Signed-off-by: jrimmer <jason@rimmer.net>
@jrimmer
jrimmer force-pushed the fix/resnapshot-e2fsck branch from 6893488 to 6fea534 Compare August 22, 2026 20:07
jrimmer added a commit to jrimmer/forkd that referenced this pull request Aug 22, 2026
Rebased onto dev (d2d238a, incl. deeplethe#312). Addresses all four review
blocks on the staging/transport flow while keeping cache-versioning and
failure-rollback.

Blocker 1 — FC-visible rootfs path unstable across publish:
Firecracker serializes the drive path_on_host INTO the binary vmstate
and REOPENS it on restore (no PUT /drives override is accepted before
/snapshot/load). Previously snapshot_cmd cloned rootfs to
staging_dir/rootfs.ext4 and booted the VM from it, then publish_snapshot
renamed the whole dir to snap_dir — leaving the vmstate's recorded path
nonexistent, so the first restore after publish failed. Fix: clone+boot
from the STABLE final path snap_dir/rootfs.ext4 from the start; only
vmstate/memory/snapshot.json are staged. publish_snapshot is replaced by
publish_snapshot_metadata, which renames the 3 metadata files into
snap_dir (snapshot.json LAST = commit marker) and never moves the rootfs.
Tests: metadata-replace-keeps-rootfs, into-nonexistent-snap_dir,
preserves-existing-on-missing-metadata.

Blocker 2 — pack/unpack sidecar never placable:
pack listed rootfs.ext4 in manifest.files but omitted it from the tar
body; unpack verified EVERY declared file before satisfy_rootfs, hashing
the missing extracted rootfs and failing. Fix: when a portable sidecar
is emitted, retain rootfs.ext4 out of manifest.files (sidecar carries
sha integrity). Test: pack_unpack_roundtrip_with_sidecar_rootfs.

Blocker 3 — src==dst guard compared staging path, not final:
extracted to rootfs_clone_into_self(src, snap_dir) comparing canonical
against the FINAL snap_dir/rootfs.ext4, so re-snapshotting a tag whose
baseline is its own rootfs.ext4 is rejected. Test: same-tag regression.

Blocker 4 — path traversal via RootfsRef.target_path:
satisfy_rootfs now REQUIRES a safe single-component relative filename
(no absolute / no separators / no ..), rejecting malicious ../../../ or
legacy absolute paths (fail-closed; writes would otherwise land outside
snap_dir under sudo). Test: satisfy_rootfs_rejects_unsafe_target_paths.

Signed-off-by: jrimmer <jason@rimmer.net>
@jrimmer
jrimmer force-pushed the fix/resnapshot-e2fsck branch from 6fea534 to f4a0151 Compare August 22, 2026 20:13

@WaylandYang WaylandYang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blockers 2, 3 and 4 are properly fixed and I verified each in the code, not just the summary. pack now withholds rootfs.ext4 from manifest.files exactly when a sidecar is emitted, so unpack's verification loop no longer hashes a file that was deliberately left out of the tar. rootfs_clone_into_self canonicalizes against the final snap_dir/rootfs.ext4 (canonicalizing the parent so a not-yet-existing snap_dir still compares correctly). satisfy_rootfs validates target_path before any join, requiring a non-empty single-component relative name, which closes the ../../absolute write-outside-snap_dir hole. The cache-versioning and staged-metadata rollback work is sound, and publish_snapshot_metadata pre-verifying all three files before the first rename is the right shape.

Two blocking issues remain, both in the blocker-1 rework.

1. A failed re-bake now destroys the previously published tag.

main.rs:2787-2799: when snap_dir/rootfs.ext4 already exists, it is remove_filed and a fresh baseline is reflink-cloned into that exact published path — before the VM boots. Everything after that point can fail (boot timeout, guest-agent wait, the Firecracker snapshot call), and the only cleanup is remove_dir_all(&staging_dir). snap_dir is then left holding the old snapshot.json/vmstate/memory.bin next to a rootfs that has been replaced by a different baseline and dirtied by the failed boot. Restoring that tag afterwards gives the vmstate a filesystem it was never frozen against — the exact EBADMSG/broken-binary symptom #296 is about, now reachable without any crash, just a failed re-snapshot. There is no --force gate on overwriting an existing tag either.

This is the same "removes the previous rootfs.ext4 before the replacement is known-good" concern from the earlier round; the immutable-baseline rework relocated it rather than resolving it. The cheapest fix that keeps the stable FC-visible path: rename the existing rootfs aside (rootfs.ext4.prev-<pid>) instead of deleting it, restore it on every failure path, and delete it only after publish_snapshot_metadata succeeds. Requiring --force to re-bake an existing tag would be a reasonable additional guard. Please add a regression that fails the snapshot after the clone and asserts the tag still restores.

2. Making target_path snap_dir-relative breaks cross-host restore — a regression against dev.

The fact that motivated blocker 1 — Firecracker serializes the block-device host path into the binary vmstate, and nothing on the restore path can override it — applies to unpack/pull too, and nothing in this PR rewrites the vmstate (rewrite_snapshot_paths only touches the vmstate and memory string keys inside snapshot.json). On dev today, RootfsRef.target_path is the absolute path FC reopens and satisfy_rootfs reproduces it verbatim on the target host, which is precisely why it was absolute. This PR changes it to a bare filename resolved against the destination snap_dir — and snap_dir is data_dir()/snapshots/<tag>, which differs whenever:

  • the pack is unpacked with --tag <other> (explicitly supported for single-link packs), or
  • data_dir() differs between the packing and pulling host — root's /var/lib/forkd versus a user's ~/.local/share/forkd, or a different XDG_DATA_HOME.

In those cases the sidecar is placed at the new snap_dir, the vmstate still names the packing host's path, and the first fork fails with the cryptic block-device error satisfy_rootfs's own doc comment says it exists to prevent. satisfy_rootfs now also rejects legacy absolute target_paths outright, so already-published packs stop working rather than degrading.

The doc on RootfsRef.target_path in hub.rs:117-122 still reads "Absolute path Firecracker reopens at restore", which is now false — that contradiction is the tell that the two fixes are in tension. Either keep an FC-visible path that is reproducible across hosts and validate it (a constrained absolute path under a known root, rejecting traversal), or introduce an explicit restore-time relocation step and make the pack format declare it. Whichever you pick, please add a round-trip regression that unpacks into a different snap_dir than the one packed from and restores.

Evidence gap. snapshot_stable_rootfs_publishes_and_restores is #[ignore] and requires FORKD_TEST_ROOTFS; this repo has no other ignored tests and no KVM job, so blocker 1's fix currently has no automated proof at all. Please attach the output of running it on a KVM host. It also only covers the same-host, same-tag case, so it would not catch either issue above.

Nits: main.rs:2748-2749 has a duplicated/truncated pair of comment lines left over from an edit. unpack_chain_into ends with destinations.last().cloned().unwrap_or_else(PathBuf::new); the empty case is unreachable today, but if it ever fired, satisfy_rootfs would resolve rootfs.ext4 against the process CWD — under sudo. bail! is the safer tail.

Note that #302 merged as e2fd1a6e, so this needs a rebase onto current dev regardless.

jrimmer added a commit to jrimmer/forkd that referenced this pull request Aug 30, 2026
deeplethe#296)

The rootfs corruption issue (deeplethe#296) was caused by vm.kill() SIGKILLing
firecracker without a clean ext4 unmount, leaving the rootfs dirty.
The previous approach (PR deeplethe#295) ran e2fsck -fy before each boot to
repair the dirty journal — but the reviewer flagged a TOCTOU race:
the /proc/*/fd scan is point-in-time, so another VM could open the
rootfs during e2fsck.

The immutable-baseline approach eliminates the race entirely:

1. The original rootfs is the IMMUTABLE BASELINE — never mounted RW.
2. Before each boot, forkd creates a reflink copy (FICLONE ioctl,
   instant on btrfs/xfs/overlayfs; falls back to full copy on ext4/tmpfs)
   in the snapshot directory.
3. The VM boots from the clone and writes to it; the baseline stays clean.
4. After vm.kill(), the clone persists as the snapshot's rootfs (needed
   for restores — Firecracker re-opens the rootfs from the path in the
   vmstate).
5. The next forkd snapshot --rootfs <baseline> boots from a fresh clone
   of the still-clean baseline — no e2fsck needed, no TOCTOU race.

The reflink copy is exposed as pub fn reflink_copy in chain.rs (wraps
the existing copy_base_memory which already has FICLONE + stream
fallback). The snapshot_cmd function in forkd-cli creates the clone at
<snapshot_dir>/rootfs.ext4 and records its path in snap.rootfs.

Signed-off-by: jrimmer <jason@rimmer.net>
jrimmer added a commit to jrimmer/forkd that referenced this pull request Aug 30, 2026
…sport

Review deeplethe#295 r6 (WaylandYang 2026-08-14): three correctness/portability
blockers on the immutable-baseline clone design. All three closed, plus
the four requested regression tests.

Blocker 1 — cache versioning (crates/forkd-cli/src/main.rs):
A cached rootfs produced or dirtied by an older forkd version was
reused solely because the path exists, then cloned as the supposedly
immutable baseline. Now each built rootfs gets a `.cache-meta.json`
sidecar recording schema_version + sha256 + image + size + forkd
version. `validate_cached_rootfs` trusts a cache entry only when the
meta exists, the schema version matches ROOTFS_CACHE_SCHEMA_VERSION
(=1), and the live sha256 still matches. Legacy entries (no meta),
schema mismatches, or sha mismatches (truncation/mutation) force a
rebuild. Wired into both from_image_cmd and run_cmd cache-hit paths;
write_rootfs_cache_meta is called after every build.

Blocker 2 — atomic snapshot staging (snapshot_cmd + publish_snapshot):
snapshot_cmd removed snap_dir/rootfs.ext4 BEFORE clone/boot/snapshot
succeeded, so re-running a tag destroyed the last usable snapshot and
(src==dst) could unlink its own source. The entire new snapshot
(rootfs clone + vmstate + memory.bin + snapshot.json) is now built
under a distinct staging dir (<snap_dir>.staging-<pid>) and only
published via publish_snapshot() after boot + warmup + snapshot +
metadata write all succeed. publish_snapshot does a safe two-step
shuffle: move the old snap_dir aside, rename staging into place (the
commit point), then drop the old. On commit-point failure the old
snapshot is restored from the aside, so a crash at any point leaves
either the new OR the old snapshot, never neither. A src==dst guard
rejects cloning the baseline into the snapshot's own rootfs.ext4 path.

Blocker 3 — portable rootfs transport (crates/forkd-cli/src/hub.rs):
The rootfs was shipped TWICE — tarred into the pack via SNAPSHOT_FILES
AND emitted as a content-addressed .rootfs.zst sidecar — duplicating a
potentially huge image. pack() now records rootfs.ext4 in the manifest
files list (for integrity accounting + list_local) but skips appending
it to the tar body when a portable sidecar is emitted, so there is ONE
rootfs transport. RootfsRef.target_path is now a PORTABLE relative
filename (e.g. "rootfs.ext4") instead of the packing host's absolute
path; satisfy_rootfs resolves it against the destination snapshot dir
(absolute paths from legacy packs still work). unpack_into now
returns the dest snapshot dir so the relative target_path can be
resolved; unpack_chain_into returns the head link's dest.

Tests (crates/forkd-cli/src/main.rs):
- validate_cached_rootfs_rejects_legacy_entry_without_meta (upgrade/dirty-cache)
- validate_cached_rootfs_rejects_wrong_schema_version (upgrade/dirty-cache)
- validate_cached_rootfs_rejects_sha_mismatch_after_mutation (dirty-cache)
- validate_cached_rootfs_accepts_fresh_valid_entry
- validate_cached_rootfs_misses_on_missing_file
- publish_snapshot_atomically_replaces_existing (same-tag failure)
- publish_snapshot_into_nonexistent_snap_dir
- publish_snapshot_preserves_existing_when_staging_missing (same-tag failure recovery)

Signed-off-by: jrimmer <jason@rimmer.net>
jrimmer added a commit to jrimmer/forkd that referenced this pull request Aug 30, 2026
Rebased onto dev (d2d238a, incl. deeplethe#312). Addresses all four review
blocks on the staging/transport flow while keeping cache-versioning and
failure-rollback.

Blocker 1 — FC-visible rootfs path unstable across publish:
Firecracker serializes the drive path_on_host INTO the binary vmstate
and REOPENS it on restore (no PUT /drives override is accepted before
/snapshot/load). Previously snapshot_cmd cloned rootfs to
staging_dir/rootfs.ext4 and booted the VM from it, then publish_snapshot
renamed the whole dir to snap_dir — leaving the vmstate's recorded path
nonexistent, so the first restore after publish failed. Fix: clone+boot
from the STABLE final path snap_dir/rootfs.ext4 from the start; only
vmstate/memory/snapshot.json are staged. publish_snapshot is replaced by
publish_snapshot_metadata, which renames the 3 metadata files into
snap_dir (snapshot.json LAST = commit marker) and never moves the rootfs.
Tests: metadata-replace-keeps-rootfs, into-nonexistent-snap_dir,
preserves-existing-on-missing-metadata.

Blocker 2 — pack/unpack sidecar never placable:
pack listed rootfs.ext4 in manifest.files but omitted it from the tar
body; unpack verified EVERY declared file before satisfy_rootfs, hashing
the missing extracted rootfs and failing. Fix: when a portable sidecar
is emitted, retain rootfs.ext4 out of manifest.files (sidecar carries
sha integrity). Test: pack_unpack_roundtrip_with_sidecar_rootfs.

Blocker 3 — src==dst guard compared staging path, not final:
extracted to rootfs_clone_into_self(src, snap_dir) comparing canonical
against the FINAL snap_dir/rootfs.ext4, so re-snapshotting a tag whose
baseline is its own rootfs.ext4 is rejected. Test: same-tag regression.

Blocker 4 — path traversal via RootfsRef.target_path:
satisfy_rootfs now REQUIRES a safe single-component relative filename
(no absolute / no separators / no ..), rejecting malicious ../../../ or
legacy absolute paths (fail-closed; writes would otherwise land outside
snap_dir under sudo). Test: satisfy_rootfs_rejects_unsafe_target_paths.

Signed-off-by: jrimmer <jason@rimmer.net>
jrimmer added a commit to jrimmer/forkd that referenced this pull request Aug 30, 2026
…deeplethe#295 r9)

Addresses the 2026-08-22T20:28 review round on PR deeplethe#295.

Blocker 1 — a failed re-bake destroyed the previously published tag:
the old rootfs was remove_file'd before the VM booted, so any later
failure (boot timeout, snapshot error, publish error, interrupt) left
old metadata frozen against a replaced, dirtied rootfs (deeplethe#296 EBADMSG).
The previous rootfs is now renamed aside to <tag>.rootfs.ext4.prev-<pid>
and a RootfsRollback guard restores it on every failure path (Drop =
rollback, covers ?-propagation and panic unwinding); the backup is
deleted only after publish_snapshot_metadata commits the new snapshot.

Blocker 2 — snap_dir-relative target_path broke cross-host restore:
Firecracker freezes the drive path into the vmstate and reopens it
verbatim at /snapshot/load, so placement must not depend on the
destination snap_dir. manifest.rootfs.target_path records the absolute
path again (un-breaking published packs whose absolute target_path the
r6 code rejected outright), and satisfy_rootfs validates it against
forkd-managed roots — the forkd data dir, its snapshots/ tree, and the
rootfs cache (, default /var/cache/forkd) — with
traversal/backslash rejection and existing-prefix canonicalization
(symlink escape hardening) before any privileged write. A missing or
unfetchable sidecar is now a hard error: a warn-and-continue produced a
snapshot that could never restore.

Nits: duplicated src==dst comment line removed; unpack_chain_into tail
bails instead of returning an empty PathBuf that would resolve
target_path against the CWD under sudo.

Tests: RootfsRollback armed/disarmed unit test; satisfy_rootfs
rejection matrix updated for the absolute-path contract plus placement
and sha-dedup assertions; cross-snap-dir verbatim placement round-trip
(reviewer's blocker-2 regression ask); validate_rootfs_target_path unit
contract; the #[ignore] KVM regression extended with a failed-re-bake
rollback and a cross-tag sidecar placement scenario (FORKD_TEST_*).
@jrimmer
jrimmer force-pushed the fix/resnapshot-e2fsck branch from f4a0151 to 3198623 Compare August 30, 2026 01:59
@jrimmer

jrimmer commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Review r9 (2026-08-22T20:28) follow-ups — both blockers, nits, and the evidence gap

All addressed on head 29138ad (rebased onto current dev — includes #302 as e2fd1a6 — and rust CI is green).

Blocker 1: a failed re-bake no longer destroys the published tag

The previous rootfs is now renamed aside to <tag>.rootfs.ext4.prev-<pid> instead of being deleted before boot, and a RootfsRollback guard (Drop = rollback) restores it on every failure path: clone error, boot timeout, snapshot failure, publish failure, ?-propagation, and panic unwinding. The guard is disarmed only after publish_snapshot_metadata commits; the backup is then deleted (best-effort, never fatal). The tag now always holds a consistent (rootfs, vmstate, memory.bin, snapshot.json) tuple — the #296 EBADMSG pairing is unreachable through re-bake failure.

On the suggested --force gate: I've held off for now, since re-running snapshot on an existing tag is the documented recovery flow when a bake produced a broken snapshot, and this rollback removes the data-loss reason for gating it. Happy to add --force if you'd still prefer the explicit opt-in — say the word and it's a small change.

Blocker 2: target_path is absolute again — validated against forkd-managed roots

I took your option (A): keep the FC-visible absolute path and validate it. The r6 snap-dir-relative design was the actual regression — Firecracker reopens the vmstate-frozen path at /snapshot/load, so placement cannot depend on the destination snap_dir (different --tag or data dir mis-placed the sidecar), and it rejected already-published packs outright. Now:

  • emit_rootfs_sidecar records the packing host's absolute path verbatim (legacy packs work again; no format break).
  • satisfy_rootfs resolves the target through validate_rootfs_target_path: non-empty, absolute, no ../backslash components, and lexically inside a forkd-managed root — the forkd data dir, its snapshots/ tree, or the rootfs cache ($FORKD_RUN_CACHE, default /var/cache/forkd) — so sudo forkd unpack/pull can no longer be aimed at /etc/cron.d (your blocker-4 concern is preserved, generalized beyond snap_dir). When the containing root exists, the longest existing prefix must canonicalize inside the root's canonical form, closing symlink-escape writes.
  • A missing or unfetchable sidecar is now a hard error instead of a warn-and-continue — the resulting snapshot could never restore, so failing at pull time with the sidecar name is the actionable moment.
  • Placement is fully snap_dir-independent, so sha-based dedup works host-wide again (a chain of packs sharing a base fetches it once) — this also restores the behavior documented in docs/HUB.md, which I've updated (the doc had always specified recorded-absolute placement; the code was the outlier).

Nits

Duplicated src == dst comment line removed; unpack_chain_into tail now bail!s instead of returning an empty PathBuf (which would have resolved target_path against the CWD under sudo).

Tests (52 unit + the KVM regression)

  • rootfs_rollback_guard_restores_previous_rootfs_on_failure: armed drop swaps the partial clone back to the previous rootfs; disarmed drop is a no-op.
  • satisfy_rootfs_rejects_unsafe_target_paths: rejection matrix updated for the absolute contract (relative/r6-format rejected too), plus placement + sha-dedup assertions for an in-roots absolute path.
  • rootfs_target_path_places_verbatim_regardless_of_snap_dir: the round-trip you asked for — placement lands at the recorded path even when the destination snap dir differs.
  • validate_rootfs_target_path_allows_managed_roots_only: unit contract for the root allowlist.
  • snapshot_stable_rootfs_publishes_and_restores (#[ignore], KVM) extended: after restore it parks the published rootfs aside, dirties a partial clone, drops an armed guard, asserts rollback restored the rootfs, and restores again; then places a sidecar from a different snap_dir and asserts the bytes landed at the recorded path.

Evidence gap — KVM run output

Attaching the output of snapshot_stable_rootfs_publishes_and_restores from a KVM host in a follow-up comment (needs FORKD_TEST_KERNEL/FORKD_TEST_ROOTFS + /dev/kvm; I don't want to gate this round's review on it landing first, but it's coming).

jrimmer added 10 commits August 29, 2026 19:19
deeplethe#296)

The rootfs corruption issue (deeplethe#296) was caused by vm.kill() SIGKILLing
firecracker without a clean ext4 unmount, leaving the rootfs dirty.
The previous approach (PR deeplethe#295) ran e2fsck -fy before each boot to
repair the dirty journal — but the reviewer flagged a TOCTOU race:
the /proc/*/fd scan is point-in-time, so another VM could open the
rootfs during e2fsck.

The immutable-baseline approach eliminates the race entirely:

1. The original rootfs is the IMMUTABLE BASELINE — never mounted RW.
2. Before each boot, forkd creates a reflink copy (FICLONE ioctl,
   instant on btrfs/xfs/overlayfs; falls back to full copy on ext4/tmpfs)
   in the snapshot directory.
3. The VM boots from the clone and writes to it; the baseline stays clean.
4. After vm.kill(), the clone persists as the snapshot's rootfs (needed
   for restores — Firecracker re-opens the rootfs from the path in the
   vmstate).
5. The next forkd snapshot --rootfs <baseline> boots from a fresh clone
   of the still-clean baseline — no e2fsck needed, no TOCTOU race.

The reflink copy is exposed as pub fn reflink_copy in chain.rs (wraps
the existing copy_base_memory which already has FICLONE + stream
fallback). The snapshot_cmd function in forkd-cli creates the clone at
<snapshot_dir>/rootfs.ext4 and records its path in snap.rootfs.

Signed-off-by: jrimmer <jason@rimmer.net>
…sport

Review deeplethe#295 r6 (WaylandYang 2026-08-14): three correctness/portability
blockers on the immutable-baseline clone design. All three closed, plus
the four requested regression tests.

Blocker 1 — cache versioning (crates/forkd-cli/src/main.rs):
A cached rootfs produced or dirtied by an older forkd version was
reused solely because the path exists, then cloned as the supposedly
immutable baseline. Now each built rootfs gets a `.cache-meta.json`
sidecar recording schema_version + sha256 + image + size + forkd
version. `validate_cached_rootfs` trusts a cache entry only when the
meta exists, the schema version matches ROOTFS_CACHE_SCHEMA_VERSION
(=1), and the live sha256 still matches. Legacy entries (no meta),
schema mismatches, or sha mismatches (truncation/mutation) force a
rebuild. Wired into both from_image_cmd and run_cmd cache-hit paths;
write_rootfs_cache_meta is called after every build.

Blocker 2 — atomic snapshot staging (snapshot_cmd + publish_snapshot):
snapshot_cmd removed snap_dir/rootfs.ext4 BEFORE clone/boot/snapshot
succeeded, so re-running a tag destroyed the last usable snapshot and
(src==dst) could unlink its own source. The entire new snapshot
(rootfs clone + vmstate + memory.bin + snapshot.json) is now built
under a distinct staging dir (<snap_dir>.staging-<pid>) and only
published via publish_snapshot() after boot + warmup + snapshot +
metadata write all succeed. publish_snapshot does a safe two-step
shuffle: move the old snap_dir aside, rename staging into place (the
commit point), then drop the old. On commit-point failure the old
snapshot is restored from the aside, so a crash at any point leaves
either the new OR the old snapshot, never neither. A src==dst guard
rejects cloning the baseline into the snapshot's own rootfs.ext4 path.

Blocker 3 — portable rootfs transport (crates/forkd-cli/src/hub.rs):
The rootfs was shipped TWICE — tarred into the pack via SNAPSHOT_FILES
AND emitted as a content-addressed .rootfs.zst sidecar — duplicating a
potentially huge image. pack() now records rootfs.ext4 in the manifest
files list (for integrity accounting + list_local) but skips appending
it to the tar body when a portable sidecar is emitted, so there is ONE
rootfs transport. RootfsRef.target_path is now a PORTABLE relative
filename (e.g. "rootfs.ext4") instead of the packing host's absolute
path; satisfy_rootfs resolves it against the destination snapshot dir
(absolute paths from legacy packs still work). unpack_into now
returns the dest snapshot dir so the relative target_path can be
resolved; unpack_chain_into returns the head link's dest.

Tests (crates/forkd-cli/src/main.rs):
- validate_cached_rootfs_rejects_legacy_entry_without_meta (upgrade/dirty-cache)
- validate_cached_rootfs_rejects_wrong_schema_version (upgrade/dirty-cache)
- validate_cached_rootfs_rejects_sha_mismatch_after_mutation (dirty-cache)
- validate_cached_rootfs_accepts_fresh_valid_entry
- validate_cached_rootfs_misses_on_missing_file
- publish_snapshot_atomically_replaces_existing (same-tag failure)
- publish_snapshot_into_nonexistent_snap_dir
- publish_snapshot_preserves_existing_when_staging_missing (same-tag failure recovery)

Signed-off-by: jrimmer <jason@rimmer.net>
Rebase resolution left a stray closing brace after the
validate_cached_rootfs match block in run_cmd (the original
if/else's trailing brace was not removed). cargo fmt the rebased
diff.

Signed-off-by: jrimmer <jason@rimmer.net>
write_rootfs_cache_meta and validate_cached_rootfs called sha256_file
without the hub:: prefix; the function lives in forkd-cli::hub, not
main.rs. This was a compile error (E0425 cannot find function
sha256_file) on Linux clippy CI.

Signed-off-by: jrimmer <jason@rimmer.net>
unwrap_or_else(|| std::path::PathBuf::new()) -> unwrap_or_else(std::path::PathBuf::new)

Signed-off-by: jrimmer <jason@rimmer.net>
Rebased onto dev (d2d238a, incl. deeplethe#312). Addresses all four review
blocks on the staging/transport flow while keeping cache-versioning and
failure-rollback.

Blocker 1 — FC-visible rootfs path unstable across publish:
Firecracker serializes the drive path_on_host INTO the binary vmstate
and REOPENS it on restore (no PUT /drives override is accepted before
/snapshot/load). Previously snapshot_cmd cloned rootfs to
staging_dir/rootfs.ext4 and booted the VM from it, then publish_snapshot
renamed the whole dir to snap_dir — leaving the vmstate's recorded path
nonexistent, so the first restore after publish failed. Fix: clone+boot
from the STABLE final path snap_dir/rootfs.ext4 from the start; only
vmstate/memory/snapshot.json are staged. publish_snapshot is replaced by
publish_snapshot_metadata, which renames the 3 metadata files into
snap_dir (snapshot.json LAST = commit marker) and never moves the rootfs.
Tests: metadata-replace-keeps-rootfs, into-nonexistent-snap_dir,
preserves-existing-on-missing-metadata.

Blocker 2 — pack/unpack sidecar never placable:
pack listed rootfs.ext4 in manifest.files but omitted it from the tar
body; unpack verified EVERY declared file before satisfy_rootfs, hashing
the missing extracted rootfs and failing. Fix: when a portable sidecar
is emitted, retain rootfs.ext4 out of manifest.files (sidecar carries
sha integrity). Test: pack_unpack_roundtrip_with_sidecar_rootfs.

Blocker 3 — src==dst guard compared staging path, not final:
extracted to rootfs_clone_into_self(src, snap_dir) comparing canonical
against the FINAL snap_dir/rootfs.ext4, so re-snapshotting a tag whose
baseline is its own rootfs.ext4 is rejected. Test: same-tag regression.

Blocker 4 — path traversal via RootfsRef.target_path:
satisfy_rootfs now REQUIRES a safe single-component relative filename
(no absolute / no separators / no ..), rejecting malicious ../../../ or
legacy absolute paths (fail-closed; writes would otherwise land outside
snap_dir under sudo). Test: satisfy_rootfs_rejects_unsafe_target_paths.

Signed-off-by: jrimmer <jason@rimmer.net>
…deeplethe#295 r9)

Addresses the 2026-08-22T20:28 review round on PR deeplethe#295.

Blocker 1 — a failed re-bake destroyed the previously published tag:
the old rootfs was remove_file'd before the VM booted, so any later
failure (boot timeout, snapshot error, publish error, interrupt) left
old metadata frozen against a replaced, dirtied rootfs (deeplethe#296 EBADMSG).
The previous rootfs is now renamed aside to <tag>.rootfs.ext4.prev-<pid>
and a RootfsRollback guard restores it on every failure path (Drop =
rollback, covers ?-propagation and panic unwinding); the backup is
deleted only after publish_snapshot_metadata commits the new snapshot.

Blocker 2 — snap_dir-relative target_path broke cross-host restore:
Firecracker freezes the drive path into the vmstate and reopens it
verbatim at /snapshot/load, so placement must not depend on the
destination snap_dir. manifest.rootfs.target_path records the absolute
path again (un-breaking published packs whose absolute target_path the
r6 code rejected outright), and satisfy_rootfs validates it against
forkd-managed roots — the forkd data dir, its snapshots/ tree, and the
rootfs cache (, default /var/cache/forkd) — with
traversal/backslash rejection and existing-prefix canonicalization
(symlink escape hardening) before any privileged write. A missing or
unfetchable sidecar is now a hard error: a warn-and-continue produced a
snapshot that could never restore.

Nits: duplicated src==dst comment line removed; unpack_chain_into tail
bails instead of returning an empty PathBuf that would resolve
target_path against the CWD under sudo.

Tests: RootfsRollback armed/disarmed unit test; satisfy_rootfs
rejection matrix updated for the absolute-path contract plus placement
and sha-dedup assertions; cross-snap-dir verbatim placement round-trip
(reviewer's blocker-2 regression ask); validate_rootfs_target_path unit
contract; the #[ignore] KVM regression extended with a failed-re-bake
rollback and a cross-tag sidecar placement scenario (FORKD_TEST_*).

Signed-off-by: jrimmer <jason@rimmer.net>
…n KVM test

Signed-off-by: jrimmer <jason@rimmer.net>
…absence

Signed-off-by: jrimmer <jason@rimmer.net>
…ing them

Signed-off-by: jrimmer <jason@rimmer.net>
@jrimmer
jrimmer force-pushed the fix/resnapshot-e2fsck branch from 77a8a6f to 8a36fe8 Compare August 30, 2026 02:19
The staging-flow snapshot.json was serialized with staging-dir vmstate/
memory paths, and publish_snapshot_metadata renamed the files without
fixing up the paths inside — so every staged snapshot published since
the atomic-staging change recorded a vmstate path in a directory that
is deleted right after publish. Restore then fails with 'Failed to
open snapshot file' (caught by the first real-KVM run of the
snapshot_stable_rootfs_publishes_and_restores regression on a KVM
host; CI could never see it because the test is #[ignore]-gated).

publish_snapshot_metadata now rewrites the staged snapshot.json's
vmstate/memory keys to the snap_dir paths BEFORE installing it (still
snapshot.json-last, so the commit marker is never a half-fixed file).
This is safe precisely because vmstate/mem are /snapshot/load request
arguments, unlike the drive path frozen into the binary vmstate.

Publish unit tests now stage real JSON (as snapshot_cmd does) and
assert the repoint; the KVM regression asserts per-child restore
failures are empty so a dead child can no longer hide behind an Ok.

Signed-off-by: jrimmer <jason@rimmer.net>
@jrimmer
jrimmer force-pushed the fix/resnapshot-e2fsck branch from cf6693d to 7a73fa3 Compare August 30, 2026 02:28
@jrimmer

jrimmer commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

KVM evidence: snapshot_stable_rootfs_publishes_and_restores passing on real hardware

Run on a dedicated KVM host (Proxmox VM, Linux 6.17.13-2-pve x86_64, /dev/kvm present, firecracker on PATH, rustc 1.97.1), branch head 7a73fa3, release build:

FORKD_TEST_KERNEL=/var/lib/forkd/kernels/vmlinux \
FORKD_TEST_ROOTFS=/var/cache/forkd/dev-base-rootfs.ext4 \  # 4 GiB ext4
cargo test -p forkd-cli --bin forkd --release \
  snapshot_stable_rootfs_publishes_and_restores -- --ignored --nocapture
running 1 test
    re-bake failed — restored previous rootfs from /tmp/forkd-snapshot-stable-rootfs-1748489/snap.rootfs.ext4.prev-x
==> placing rootfs → /tmp/forkd-snapshot-stable-rootfs-1748489/snap/rootfs.ext4 (from /tmp/forkd-snapshot-stable-rootfs-1748489/216908786ceb14ee.rootfs.zst)
✓ rootfs ready at /tmp/forkd-snapshot-stable-rootfs-1748489/snap/rootfs.ext4
test tests::snapshot_stable_rootfs_publishes_and_restores ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 53 filtered out; finished in 16.08s

All three phases exercised on real Firecracker + KVM: publish→restore of the published tag, the failed-re-bake rollback (guard drop restored the previous rootfs; the post-rollback restore succeeded), and cross-snap_dir sidecar placement (--tag-override simulation placed bytes at the recorded absolute path, then a verified restore).

Running it on KVM caught two real bugs CI never could

You were right to insist on this — the #[ignore]d test had never actually been executed, and it failed immediately:

  1. publish_snapshot_metadata left staging-dir paths inside the published snapshot.json. The staged metadata is serialized with <staging>/vmstate / <staging>/memory.bin paths, and publish renamed the files without re-pointing the keys — so every snapshot published since the atomic-staging change recorded a vmstate path in a directory deleted moments later. Restore failed with Load snapshot error: Failed to open snapshot file: No such file or directory. Fixed in cf6693d: publish rewrites the staged snapshot.json's vmstate/memory keys to the final snap_dir paths before installing it (still snapshot.json-last, so the commit marker is never a half-fixed file). This is safe precisely because vmstate/mem are /snapshot/load request arguments, unlike the drive path frozen into the binary vmstate (blocker 1's constraint) — and it's why the test now asserts snap.vmstate == snap_dir/vmstate after load_snapshot_meta.

  2. The test itself was restoring from the in-memory Snapshot (stale staging paths) instead of reloading the published metadata — i.e. it wasn't exercising the production forkd fork --tag path (load_snapshot_meta) at all, which is why the first bug was invisible. The test now reloads from the published snapshot.json before restoring, and asserts per-child failures are empty so a dead child can't hide behind an Ok (restore is partial-success by contract — the first run returned "0 children" with the error silently tucked away).

Publish unit tests were updated to stage real JSON (as snapshot_cmd does) and assert the re-point; both fixes are in the CI-green head 7a73fa3.

@WaylandYang WaylandYang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both blockers are resolved, and I checked the implementation rather than the summary.

Rollback guard. The previous rootfs is renamed aside before the clone and the guard is armed at that moment, so every failure between there and publish — including ?-propagation and unwinding — restores it; disarm() happens only after publish_snapshot_metadata returns, and the backup delete is best-effort afterwards. Removing the partial clone before the restore-rename is the right ordering. The tag now always holds a consistent tuple, which is what #296 needed.

Absolute target_path with root validation. This is the right call: placement cannot depend on the destination snap_dir when Firecracker reopens the vmstate-frozen path verbatim, and the relative format also rejected already-published packs. validate_rootfs_target_path covers the sudo concern better than my original scoping did — lexical containment in a managed root, then canonicalizing the longest existing prefix to catch a symlink planted inside one. Worth noting for the record that Path::starts_with compares componentwise, so /var/cache/forkd-evil does not slip past the /var/cache/forkd root; that is the failure mode this kind of check usually has, and it is not present here.

On the KVM run. This is exactly why I asked for it, and the outcome makes the point better than I did: an #[ignore]d test that has never been executed is not evidence. It caught publish_snapshot_metadata leaving staging paths in the published snapshot.json — which means every snapshot published since the atomic-staging change could not restore, through a code path CI cannot reach — and it caught the test restoring from the in-memory Snapshot rather than through load_snapshot_meta, which is why the first bug was invisible. Re-pointing vmstate/memory before the renames is correct and safe for the reason you give: those are /snapshot/load arguments, unlike the drive path frozen into the binary vmstate.

On --force: agreed, don't add it. Re-running snapshot on an existing tag is the documented recovery flow, and the rollback removes the data-loss reason for gating it. A prompt there would sit in front of the exact operation someone reaches for when a bake went wrong.

Approving. Two things to pick up separately, neither worth holding this:

  1. satisfy_rootfs's doc comment still says it "warns (does not fail) when the sidecar can't be found" — that is now a bail!. One line, and the new behavior is the right one.
  2. The stale-prev cleanup only removes the backup matching the current pid, so a kill -9 between the preserve-rename and publish leaves <tag>.rootfs.ext4.prev-<other-pid> — potentially several GB — that nothing ever collects, while the tag itself has no rootfs.ext4 until someone renames it back by hand. The doc acknowledges the window; the cleanup just cannot act on it. Globbing <tag>.rootfs.ext4.prev-* on entry and, when rootfs.ext4 is missing, restoring the newest backup instead of only deleting a same-pid one would close it.

One mechanical thing before this can land: #299 merged as 041c904e and both PRs added a section under CHANGELOG "Unreleased", so this now conflicts there. It is the only conflict — everything else merges clean — and both sections should survive. Rebase and I will merge, or say the word and I will resolve that one file on the branch myself.

Resolves the CHANGELOG.md conflict with deeplethe#299 (merged as 041c904).
Both PRs appended a section under "Unreleased"; the conflict was
purely additive, so all three sections are kept — the controller
upgrade note first (operator-facing behavior change), then this
branch's re-snapshot and sidecar-placement entries. No other file
conflicted.

Signed-off-by: WaylandYang <wayland0916@gmail.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@WaylandYang
WaylandYang merged commit 07a1ffb into deeplethe:dev Sep 2, 2026
6 checks passed
WaylandYang pushed a commit that referenced this pull request Sep 14, 2026
A re-bake parks the previous published rootfs as
`<tag>.rootfs.ext4.prev-<pid>`. The pid belongs to the run that parked
it, so the same-pid check could only ever clean up after a run whose
pid happened to match. A `kill -9` between the preserve-rename and the
publish stranded the file under a pid no later run would look for —
several GB that nothing collected — while the tag was left with no
`rootfs.ext4` until someone renamed the backup back by hand.

Sweep every `<tag>.rootfs.ext4.prev-*` sibling instead. When the
published rootfs is missing, the newest backup is renamed back into
place; the remaining ones are scratch from runs that never published
and are dropped best-effort — a stale file that cannot be removed no
longer aborts the bake, since the preserve-rename would overwrite it
anyway.

Also corrects the `satisfy_rootfs` doc comment, which still described
the missing-sidecar case as a warning when it is now a `bail!`.

Both are follow-ups from the #295 approval.

Signed-off-by: jrimmer <jason@rimmer.net>
keeganmccallum added a commit to urun-sh/forkd that referenced this pull request Oct 15, 2026
…n on the Ubuntu 22.04 sandbox hosts (#6)

* fix(vmm): best-effort guest clock sync after snapshot restore (Option B)

After snapshot restore, the guest's KVM clock reflects the snapshot's
saved time, not the host's current wall time. For snapshots created
minutes or hours ago, the guest clock starts in the past, causing TLS
certificate validation failures, incorrect timestamps, and token
expiry issues inside the guest.

This adds Phase 2.5 to restore_many_with: after the parallel restore
PUT completes, poll each child's guest agent (forkd-agent.py on port
8888) until it responds, then exec `date -s @<host_unix_ts>` to set
the guest clock to the host's current wall time. All children in a
batch are synced in parallel.

This is the cross-platform, cross-kernel-version alternative to the
`clocksource=kvm-clock` boot arg (PR deeplethe#289), which is x86-only and
forward-only (requires re-baking existing snapshots). This approach
works on any architecture, any Linux kernel, and any existing
snapshot without re-baking.

Best-effort and non-fatal: if the guest agent doesn't come up within
10 seconds, or `date` is unavailable, the sandbox remains usable with
a stale clock. Failures are logged as warnings.

References deeplethe#288 (stale sandbox VM clock problem).

Signed-off-by: jrimmer <jrimmer@users.noreply.github.com>
Signed-off-by: jrimmer <jason@rimmer.net>

* fix: clippy doc_lazy_continuation in sync_guest_clocks doc comment

Signed-off-by: jrimmer <jason@rimmer.net>

* test(vmm): add integration test for guest clock sync + fix exec args

Fix a bug in sync_guest_clocks: the exec args were ["-c", cmd] which
causes subprocess.run to try executing a program named "-c" (fails with
FileNotFoundError). Changed to ["sh", "-c", cmd] so the date command runs
through the shell as intended. The bug was silently masked because clock
sync is best-effort (failures logged as warnings, not propagated) and
the clocksource=kvm-clock boot arg (deeplethe#289) was correcting the clock on
modern kernels.

Add sync_guest_clocks_corrects_wrong_clock integration test:
- Boots a fresh VM via Vm::boot
- Deliberately sets the guest clock to 2001-09-09 (old_ts=1000000000)
- Calls sync_guest_clocks on the single VM
- Verifies the guest clock is corrected to host wall time (within 5s)
- Verifies the corrected time is NOT the mock old time

This isolates the scripting solution: even if KVM_CLOCK_REALTIME already
corrected the clock, the test forces a wrong clock and verifies the
sync function fixes it. The mock clock underneath is fine — we're
testing the mechanism (date -s via guest agent exec), not the
environment (kernel clock behavior).

The test is #[ignore] (requires Linux + KVM + firecracker + rootfs)
and #[cfg(target_os = "linux")].

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(vmm): per-child timestamp, opt-in clock sync, reject n>1 shared-IP

Three findings addressed:

1. Host timestamp captured before polling agent — a child that became
   ready near the 10-second deadline was set ~10 seconds in the past.
   Fix: capture SystemTime::now() per child AFTER ping succeeds,
   immediately before the date -s exec. This minimizes the gap between
   timestamp capture and clock correction.

2. restore_many_with unconditionally performed the agent protocol —
   custom/no-agent snapshots added up to 10 seconds to every restore
   even though failure is only a warning.
   Fix: clock sync is now opt-in via ForkOpts::sync_guest_clocks
   (default false). The daemon's spawn paths (create_sandbox,
   spawn_one_for_workspace) set it to true because all forkd images
   contain forkd-agent. CLI paths keep it false — the CLI doesn't
   know whether the image has an agent.

3. For n>1 with per_child_netns=false, every worker contacted the
   same 10.42.0.2:8888 — the shared IP/MAC topology is fundamentally
   conflicting.
   Fix: restore_many_with now rejects n>1 with per_child_netns=false
   with a clear error message directing callers to use
   per_child_netns=true for multi-child spawns.

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(vmm): expose per-child clock sync outcomes for fail-closed callers

sync_guest_clocks now returns (u128, Vec<ClockSyncOutcome>) instead
of just u128. ClockSyncOutcome is an enum with Synced{child_index}
and Failed{child_index, error} variants, exposed via a new
ForkResult::clock_sync_outcomes field.

Callers (e.g. the controller's create_sandbox) can inspect the
per-child outcomes to decide whether to fail-closed or degrade when
some children have unsynchronized clocks. The controller now logs
clock_sync_failures alongside the existing timing fields.

Also fixes the unused child_index warning from the thread closure
(the index is used in the collection loop, not inside the thread).

Tests:
- clock_sync_outcome_construction_and_matching: verifies enum
  discriminants, field access, and the filter-for-failures pattern
  callers use to decide fail-closed behavior.
- sync_guest_clocks_corrects_wrong_clock (#[ignore] integration):
  now also asserts the outcome vec contains exactly one Synced
  entry for the single child.
- fork_result_has_clock_sync_ms_field: updated to include the new
  clock_sync_outcomes field.

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(vmm): clippy unused-enumerate-index + behavioral clock sync tests

Two CI/coverage gaps from the review:

1. Clippy (rust CI was red): the sync_guest_clocks child loop used
   .enumerate() and discarded the index (clippy::unused_enumerate_index).
   Drop the .enumerate() and iterate children directly.

2. Behavioral coverage for the three review asks:
   - multi-child address/mode: restore_many_with_rejects_multi_child_shared_ip
     unit test proves n>1 with per_child_netns=false is rejected before any
     Firecracker spawn or filesystem side effect.
   - no-agent/disabled-sync latency: fork_opts_sync_guest_clocks_defaults_disabled
     unit test proves the opt-in default keeps sync off (no added latency for
     custom/no-agent images).
   - delayed agent readiness (timestamp freshness):
     sync_guest_clocks_delayed_agent_readiness (#[ignore] integration) boots a
     VM, calls sync_guest_clocks immediately (no pre-wait) so it must poll for
     the not-yet-ready agent, then asserts the guest clock matches host wall
     time within 3s — a stale pre-poll timestamp would drift by the poll
     duration.

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(vmm): deterministic delayed-agent-readiness test for clock sync

WaylandYang's final review round (round 5, 2026-08-14) flagged the
delayed-agent-readiness integration test as non-deterministic: it
called sync_guest_clocks immediately and only checked the final drift
plus an upper elapsed bound, so it never guaranteed that agent
readiness was actually delayed beyond the accepted 3-second drift. If
the agent came up quickly, the old pre-poll-timestamp implementation
still passed the test.

Extract the timestamp-placement logic from sync_guest_clocks into
sync_one_child_clock(ping, now, set_clock), which accepts injectable
readiness-probe / timestamp-source / clock-setter closures. The
production call site wires these to ping_at/ping_in_netns,
SystemTime::now, and the date -s exec; the test wires them to a
deterministic probe that fails for 4 seconds (> the 3s accepted drift)
then succeeds.

The new unit test sync_one_child_clock_timestamp_fresh_after_delayed_ping:
  1. asserts the induced poll delay (4s) exceeds the accepted drift (3s),
     so the freshness assertion is meaningful,
  2. asserts the timestamp passed to set_clock was captured AFTER ping
     succeeded (now_called_at >= probe_ready_at, freshness_gap < 3s) --
     a pre-poll-timestamp implementation would fail by ~4s,
  3. asserts set_clock was actually invoked with that fresh timestamp.

No VM, guest agent, or KVM is required -- it runs in the unit-test suite
on every platform. The end-to-end mechanism (real agent + date -s) is
still covered by the #[ignore] integration test
sync_guest_clocks_corrects_wrong_clock.

Verified on Linux (rust 1.83, x86_64): cargo clippy clean for
forkd-vmm/controller/cli/uffd; cargo test -p forkd-vmm --lib passes
48 passed, 0 failed, 3 ignored.

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(vmm): address ce-code-review on clock sync refactor

Apply the three cross-reviewer findings from the ce-code-review pass
(5 reviewers: correctness, testing, maintainability, reliability,
adversarial):

1. Timeout error dropped the agent address (P2, safe_auto) — flagged by
   correctness, testing, and adversarial. In multi-child restores the
   operator could not tell which child's agent failed to come up. Now
   wrapped at the call site: `.map_err(|e| e.context(format!(
   "guest agent at {addr} did not come up")))`.

2. No unit test for the timeout path (P2, manual) — flagged by testing,
   reliability, and adversarial. Only the #[ignore] integration tests
   exercised it, and the 10s production timeout made a dedicated unit
   test impractical. The timeout is now an injected parameter on
   sync_one_child_clock, and sync_one_child_clock_times_out_when_probe_
   never_succeeds uses a 500ms injected timeout to verify the bail
   message and that now()/set_clock are never called on the timeout
   path.

3. No unit test for set_clock failure propagation (P2, manual) —
   flagged by testing, reliability, and adversarial.
   sync_one_child_clock_propagates_set_clock_failure verifies a
   set_clock error propagates verbatim and that now() was still called
   (timestamp captured before the failed set).

Acknowledged (P3 advisory, no action): the 4s induced delay makes the
freshness test the slowest non-ignored unit test; wall-clock second
granularity in the freshness assertions is robust given the 4s>3s
headroom and the compile-time invariant guard.

Verified on Linux (rust 1.83, x86_64): cargo clippy clean for
forkd-vmm/controller/cli/uffd; cargo test -p forkd-vmm --lib passes
50 passed, 0 failed, 3 ignored.

Signed-off-by: jrimmer <jason@rimmer.net>

* style: cargo fmt on clock sync refactor

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(controller): run Registry::reconcile under a single lock

reconcile() took K+4 separate registry-mutex acquisitions for a pass
that prunes K stale sandboxes: one lock per removed id, plus one each
for the stale scan, the live-id snapshot, and the workspace walk. Two
handler calls interleaved between the scan and the per-id removal
could make the reported prune count diverge from what was actually
removed.

Collect the stale ids, remove them, and walk the workspaces for the
Running -> Stale transition all under one `self.inner.lock()`
acquisition. `pid_alive` is a `/proc` stat and takes no lock we care
about, so holding the registry mutex across it is fine. flush()
behavior is unchanged: it still runs once, after the lock is dropped,
only when something changed.

Adds coverage for reconcile()'s prune-count and workspace-transition
behavior (previously untested): a dead sandbox is pruned and its
Running workspace flips to Stale with live_sandbox_id cleared; a
sandbox with no recorded pid is left alone and its workspace stays
Running; a Suspended workspace is never touched; and a no-op pass
does not rewrite state.json.

Fixes deeplethe#310

Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>

* fix(vmm): restore-path resilience for issue deeplethe#301 (squashed)

Squash of 10 commits on fix/restore-path-resilience for rebasing onto
current dev (which now contains deeplethe#281's shared-TAP lifecycle).

Original commits:
- 858f63b fix(vmm): restore-path resilience for issue deeplethe#301
- ce3d800 fix(vmm): apply review fixes for deeplethe#301
- 0d73362 fix(vmm): make DEFAULT_SOCKET_WAIT_SECS pub for cross-crate use
- e820848 fix(controller): use Vm::pid() accessor, not private field
- 1ddf40c fix(controller): drop needless borrow in workspace orphan scan call
- ca52820 fix(vmm): partial-success contract + concurrent socket wait for restore_many_with
- 9e863de fix(controller): expose orphan-firecracker detection as a metric
- 2aca840 test(vmm): integration test for partial-success restore
- 9342e7a test(vmm): accept Restore or SocketWait phase in partial-success test
- f2751b9 fix(vmm): review fixes for deeplethe#301 — indexed success, valid test topology, docs

Signed-off-by: jrimmer <jason@rimmer.net>

* style: cargo fmt on rebase resolution

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(vmm): preserve original child slot in clock-sync outcomes

Review deeplethe#302: restore_many_with flattening children via
children::flatten() renumbered ClockSyncOutcome.child_index, so a
survivor past a failed hole (spawn/socket-wait) was reported as
0..n instead of its original 1-based within-batch slot, breaking
the resource correlation that ForkChild/RestoreFailure promise.

- sync_guest_clocks now takes &[(usize, &Vm)] where the usize is the
  original 1-based within-batch slot; outcomes echo that slot in both
  ClockSyncOutcome::Synced/Failed and the tracing child spans.
- Add live_children_with_indices(): the single index-preserving
  mapping (enumerate + filter_map, i+1) used by restore_many_with,
  extracted so the invariant is unit-testable without KVM.
- Regression: live_children_with_indices_preserves_survivor_slot_  across_hole proves [None,Some,None,Some] -> slots [2,4], not [0,1].
- Update the single-child clock-sync integration test to pass slot 1
  and assert child_index == 1.

Signed-off-by: jrimmer <jason@rimmer.net>

* style: fix clippy doc-lazy-continuation in sync docs

Map a line directly following the #Cons list was parsed as an
unindented list continuation (-D warnings / clippy::doc-lazy-continuation).
Separate it with a blank doc line.

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(controller): kill orphaned Firecracker processes on startup

Issue deeplethe#298: when the controller crashes or restarts while Firecracker
processes are still alive, the restarted controller cannot reconstruct
resource ownership for orphaned VMs. Both the NetnsAllocator (active
set) and shared_tap_owner are initialized empty, so a new spawn can
reserve an orphaned VM's netns index or claim the shared tap even
though the orphan still owns it.

Fix: add `Registry::kill_orphans()` called after `reconcile()` on
startup. After reconcile() prunes dead-PID entries, any remaining
sandbox entries have alive PIDs but no live_vms handle — they are
unmanageable orphans. kill_orphans() kills each orphan (after
verifying the PID belongs to a Firecracker process via /proc/<pid>/comm
to guard against PID reuse), prunes the registry entry, and marks
workspaces whose sandbox was killed as Stale.

After this, the NetnsAllocator (empty active set) and shared_tap_owner
(None) are safe: no orphaned VM holds a netns index or the shared tap.

Closes deeplethe#298

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(controller): address ce review findings for orphan recovery

Address all P0 and P1 findings from the 7-reviewer ce-* panel:

P0: kill_pid failure no longer prunes the registry entry. ESRCH (process
already dead from TOCTOU race) is distinguished from real errors (EPERM
etc.). On ESRCH, the entry is safely pruned. On real errors, the entry
is kept in the registry to prevent the exact resource collision issue

P1 fixes:
- Wait for process death after SIGKILL (5s bounded timeout polling
  /proc/<pid> disappearance). A D-state process stuck on I/O can hold
  netns/tap resources past the kill return; if it doesn't exit within
  the timeout, the registry entry is kept.
- pid_is_firecracker uses exact match (== "firecracker") instead of
  substring match to avoid false-positive kills of unrelated processes.
- Separate KillOrphansResult struct (killed, pruned_stale, kill_failed)
  replaces the misleading single usize counter. The startup log now
  reports all three counts.
- Extract mark_stale_workspaces() helper to eliminate the ~22-line
  duplicated workspace stale-marking block between reconcile() and
  kill_orphans().
- kill_orphans is pub(crate) with a doc invariant: "MUST only be called
  before any Vm handle is held" — prevents future post-startup misuse.
- Defensive pid <= 1 guard in pid_is_firecracker (defense-in-depth
  against corrupted state.json).
- ESRCH downgraded from error! to debug! (benign TOCTOU race).
- SAFETY comment added on unsafe libc::kill.

New tests: pid:None entries skipped, multiple workspaces same sandbox,
persistence to disk after kill_orphans, reconcile+kill_orphans integration.

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(controller): abort startup on kill_failed, document PID-reuse limit

WaylandYang round 4 found that kill_orphans correctly keeps registry
entries on EPERM/timeout (kill_failed > 0), but run_daemon only logs
the count and continues. The NetnsAllocator active set and
shared_tap_owner start empty, so the next spawn can reuse the failed
orphan's netns index or tap lease — the exact collision deeplethe#298 prevents.

Fix: run_daemon now aborts startup when kill_failed > 0, forcing
operator intervention rather than silently admitting conflicting
spawns. The error message names the count and tells the operator to
kill the orphans manually and restart.

Also updated the pid_is_firecracker doc comment to be honest about the
PID-reuse limitation: the comm check is a defense-in-depth heuristic,
not complete PID-reuse protection. A robust fix would use pidfd
signaling plus durable identity validation (process start time recorded
with the registry entry at spawn time, compared on startup). That is
tracked as a follow-up.

Added test: kill_orphans_result_kill_failed_contract verifies the
KillOrphansResult contract (kill_failed == 0 on empty registry, so
baseline startup does NOT abort).

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(controller): extract check_orphan_kill_result for testable abort decision

The startup abort logic was inline in run_daemon, making it
impossible to unit-test the kill_failed > 0 → bail decision path.
Extracted into pub(crate) check_orphan_kill_result(&KillOrphansResult)
-> Result<()> so the abort contract is directly testable.

Tests:
- check_orphan_kill_result_aborts_on_kill_failure: verifies that
  kill_failed=1 returns Err with "aborting startup" message, and
  kill_failed=0 returns Ok even with killed/pruned entries.
- check_orphan_kill_result_error_contains_count: verifies the error
  message contains the exact kill_failed count so operators know
  how many orphans need manual intervention.

The existing kill_orphans_result_kill_failed_contract test
(zero-baseline) is retained as documentation of the baseline
contract. The new tests exercise the actual abort decision logic.

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(controller): remove orphaned last_err assignment after rebase

The dead-last_err cleanup dropped the declaration and the unreachable
expect, but the `last_err = Some(e)` assignment (guarded by the
tap/cgroup-busy warn block on main) survived the rebase and referenced a
now-missing binding. Remove it — the loop already returns on the final
attempt, so the assignment is dead.

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(controller): durable process identity for orphan recovery

Replace the comm=="firecracker" check with durable process identity
verification and pidfd signaling, addressing review r6 (WaylandYang)
and the ce-code-review security findings.

The comm check was spoofable (prctl PR_SET_NAME) and could not
distinguish two Firecracker processes sharing a PID over time — a
same-name PID-reuse attack would kill a legitimate new Firecracker,
and on a multi-tenant host a cross-tenant DoS was possible. The
TOCTOU window between the check and kill(2) was also acknowledged.

Changes:

1. SandboxInfo::proc_starttime (serde default) — persists the process
   start time (field 22 of /proc/<pid>/stat, clock ticks since boot)
   captured at VM registration. Backward-compatible: old state.json
   files without this field deserialize to None.

2. read_proc_starttime — robust parse of /proc/<pid>/stat that handles
   the parenthesized comm field (field 2, can contain spaces/parens) by
   splitting at the last ')' and indexing field 22 in the remainder.

3. process_identity_matches — compares the live start time against the
   recorded one. Returns Match / PidReuse / Dead / Unknown. PidReuse
   means the original process exited and the PID was recycled — prune
   the stale entry without killing. Unknown (no recorded start time,
   /proc unreadable, or off-Linux) fails closed.

4. pidfd_open + pidfd_send_kill — signal through a pidfd opened AFTER
   the identity check, closing the TOCTOU window: the fd is pinned to
   the verified process, so a PID reuse between check and signal cannot
   redirect the kill. Uses raw libc::syscall with SYS_pidfd_open /
   SYS_pidfd_send_signal (Linux 5.3+; libc 0.2.x exposes the syscall
   numbers but not named fns).

5. comm_is_firecracker — retained as a secondary confirmation after
   the primary start-time check passes, to catch corrupted state.json.
   Not a security boundary.

6. kill_orphans reworked: Match → pidfd kill + wait_for_death; PidReuse
   → prune stale (no kill); Dead → prune stale; Unknown → fail closed
   (keep entry, increment kill_failed so run_daemon aborts startup).

Tests updated to reflect the new fail-closed semantics:
- kill_orphans_prunes_alive_pid_entries now uses a mismatched start
  time (u64::MAX) to trigger PidReuse → prune, not the old None path.
- kill_orphans_fails_closed_on_unknown_identity (new): alive PID with
  proc_starttime: None → fail closed, entry kept, kill_failed=1.
- kill_orphans_prunes_same_name_pid_reuse_without_killing (new
  regression): the same-name Firecracker PID-reuse attack the reviewer
  flagged — a mismatched start time must prune without killing, even
  if comm were "firecracker".
- All other kill_orphans tests updated to use mismatched start times
  where they previously relied on the comm-check prune path.

Verified on Linux (rust 1.83, x86_64) via Docker: clippy clean for
forkd-controller; cargo test --all-features passes 104 unit + 8
integration tests, 0 failed.

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(controller): close pidfd TOCTOU + ce-code-review r6 follow-ups

Address ce-code-review findings (security P1, testing P1, adversarial
P2/P3, plus the cross-reviewer coverage gaps) on the durable-identity
rework from 2e275fe.

1. Residual TOCTOU window (security P1, adversarial ADV-1): pidfd
   closes only the open→signal window, NOT the check→open window. The
   original could exit and the PID could be reused by another
   Firecracker between process_identity_matches (which reads
   /proc/<pid>/stat) and pidfd_open. Fix: after pidfd_open succeeds,
   re-read the live start time and compare to the recorded one. A
   mismatch means the PID was reused between the check and the open →
   prune without killing (PidReuse). The pidfd pins the process at
   open time, so /proc/<pid> reflects the pinned process. This closes
   the check→open window; the comm check is now a pure sanity check,
   not a load-bearing defense. Design comment corrected to not
   overstate the guarantee.

2. Off-Linux comment/behavior mismatch (adversarial ADV-2): the
   module comment claimed off-Linux "prunes without killing" but
   Unknown actually fails closed (keeps entry, increments kill_failed,
   run_daemon aborts). Fixed the comment to state the real behavior.

3. Cross-platform test guards (adversarial ADV-3): the PidReuse-
   asserting tests (mismatched starttime → pruned_stale) now carry
   #[cfg(target_os = "linux")] because off-Linux returns Unknown
   (kill_failed), not PidReuse. The cross-platform fail-closed test
   (proc_starttime: None → Unknown) stays ungated.

4. Match-arm coverage (testing P1, +reliability/security/adversarial):
   new kill_orphans_match_arm_kills_verified_child_via_pidfd spawns a
   real child, records its true start time (Match), and asserts the
   secondary comm_is_firecracker check fails closed for a
   non-firecracker process — proving the Match arm is entered and the
   comm gate works as defense-in-depth. (Killing a real Firecracker
   would require a Firecracker binary; the comm gate is the
   testable boundary here.)

5. start-time parser unit test (testing P2): read_proc_starttime now
   has a direct test (parses our own /proc/<pid>/stat, rejects pid<=1,
   returns None for nonexistent PID).

6. Backward-compat serde test (testing safe_auto): new
   proc_starttime_defaults_to_none_when_absent_in_old_state_json
   deserializes a hand-written pre-r6 state.json (no proc_starttime
   key) and asserts #[serde(default)] yields None — the contract that
   lets existing deployments upgrade without wiping state.

Verified on Linux (rust 1.83, x86_64) via Docker: clippy clean for
forkd-controller; cargo test --all-features passes 107 unit + 8
integration tests, 0 failed (3 new tests pass).

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(controller): add SIGKILL success-path regression + correct misleading comments

Review r8 (2026-08-20) found that
kill_orphans_match_arm_kills_verified_child_via_pidfd did not do what
its name claimed: it spawned 'sleep' (comm='sleep'), so the secondary
comm_is_firecracker check failed closed and the irreversible success
path (pidfd_send_kill -> wait_for_death -> registry removal) was never
executed.

- Rename the existing test to
  kill_orphans_match_arm_comm_mismatch_fails_closed so its name and
  doc accurately describe the comm-gate fail-closed branch it covers.
- Add kill_orphans_match_arm_kills_verified_firecracker_via_pidfd,
  which spawns a disposable process whose /proc/<pid>/comm is literally
  'firecracker' (a copy of 'sleep' renamed to 'firecracker'), records
  its true start time, and asserts killed == 1, kill_failed == 0,
  pruned_stale == 0, and the registry entry is removed — exercising the
  full irreversible pidfd kill path.

Also correct two misleading comments flagged in the same review:
- SandboxInfo::proc_starttime claimed legacy None state may use
  comm-only verification; the implementation fails closed (keeps the
  entry, increments kill_failed) and never does comm-only kill.
- IdentityCheck::Unknown claimed the caller chooses between a
  comm-only kill or prune; kill_orphans keeps the entry and aborts
  startup via kill_failed.

Signed-off-by: jrimmer <jason@rimmer.net>

* style: cargo fmt on new firecracker-named kill test

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(controller): persist boot id + fail closed on pid-less entries

Addresses review deeplethe#299 (both blockers) and rebases onto dev with deeplethe#312's
single-lock reconcile preserved.

Blocker 1 — cross-reboot false Match (PID+starttick collision):
  proc_starttime is ticks since boot, only unique within one host boot.
  The registry persists across reboots, so after a reboot an unrelated
  Firecracker can share both the numeric PID and the boot-relative start
  tick of a recorded entry, and a bare starttime check would SIGKILL the
  wrong process. Persist /proc/sys/kernel/random/boot_id at registration
  (SandboxInfo.boot_id) and gate the identity check on it:
    - different boot id -> PidReuse (prune WITHOUT signaling; the old
      process cannot still exist across a reboot)
    - missing recorded boot id (legacy state.json) -> Unknown (fail closed)
    - live boot id unreadable -> Unknown (fail closed)
  New read_boot_id() helper; recorded at all three production VM
  registration sites in http.rs.

Blocker 2 — pid: None entries recreated the deeplethe#298 collision risk:
  Such rows were skipped by both reconcile and kill_orphans, so startup
  succeeded with an empty allocator/shared-tap ownership while a live VM
  may still hold those resources. kill_orphans now counts retained rows
  without a PID into KillOrphansResult.unresolved, and
  check_orphan_kill_result aborts startup on unresolved > 0 (fail closed).

Rebase: preserved deeplethe#312's single-lock reconcile (whole in-memory pass under
one registry lock, including inline workspace-stale marking); kept
mark_stale_workspaces only for kill_orphans' use after pruning.

Tests:
  - kill_orphans_does_not_kill_across_boot_id_mismatch: matching PID +
    real start time but a DIFFERENT recorded boot id -> pruned without
    signaling (killed==0, pruned_stale==1).
  - kill_orphans_counts_pid_none_entries_as_unresolved (replaces
    kill_orphans_skips_pid_none_entries): pid:None rows surface as
    unresolved and block startup via check_orphan_kill_result.
  - check_orphan_kill_result_aborts_on_unresolved: startup-decision
    regression.
  - Existing kill-path tests set boot_id to the current boot id where
    they exercise the Match/PidReuse path; the legacy fail-closed test
    keeps boot_id: None.

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(controller): resolve use-after-move in busy-retry warn (E0382)

The rebase onto post-deeplethe#302 dev merged both sides of the busy-retry block
textually but not semantically: in the Err(e) arm, last_err = Some(e)
(and the final_attempt variant Some(e.context(...))) both move e, then the
trailing tracing::warn!(error = %e, ...) borrowed the moved value. deeplethe#302 had
moved that warn into the else branch ahead of the assignment.

The arm already computes let msg = format!("{e:#}") for busy-classification,
so use error = %msg in the warn to avoid re-borrowing e after the moves.

* test(controller): make orphan-kill SIGKILL regression robust to CI orphan-reaping

kill_orphans_match_arm_kills_verified_firecracker_via_pidfd spawned its
firecracker-named 'sleep' via sh -c '<bin> 30 & echo $!' and relied on the
backgrounded process being reparented to init, so wait_for_death would see
/proc/<pid> disappear right after SIGKILL. On the GitHub Actions runner that
detached child is reaped almost immediately (diagnostic showed state=Z ppid=1
at t=0ms, dead by 5ms), so process_identity_matches returned Dead,
kill_orphans pruned it, and the test failed with pruned_stale=1 instead of
killed=1.

Spawn the process as a DIRECT child the test owns (the runner cannot reap an
owned, non-orphaned child), and reap it via a small reaper thread that calls
Child::wait(). wait_for_death (polling /proc/<pid>) then observes the SIGKILL
reaping and the Match arm reports killed=1 as intended.

* fix(controller): distinct legacy-row diagnostics + off-Linux gate skip (review deeplethe#299 r9)

Legacy state.json rows (no recorded boot identity via serde defaults)
took the generic Unknown path on first restart after upgrade and aborted
startup with an EPERM-style message describing neither cause nor fix.

- IdentityCheck::LegacyUnverifiable: missing recorded starttime OR boot
  id is reported distinctly from a genuinely unverifiable environment.
- KillOrphansResult.legacy_unverified counts legacy rows separately
  from kill_failed; run_daemon logs include the new counter.
- check_orphan_kill_result emits a dedicated upgrade diagnostic (kill
  the recorded PID manually or remove the entry once verified dead),
  distinct from the kill-failure and pid-less-row messages.
- Off-Linux, identity can never be verified, so the gate skipped any
  registry row and aborted dev-box startup; the abort decision is now
  extracted into a pure cross-platform orphan_abort_reason(), the Linux
  gate fails closed on it, and the off-Linux gate logs the counts and
  allows startup (review deeplethe#299 non-blocking).
- Corrected the wrong pidfd comment: /proc/<pid> reflects whoever
  currently holds the PID; the post-open starttime re-read is what
  makes the reuse check safe.
- kill_orphans folds its two entry lock acquisitions into one guard,
  matching the deeplethe#312 single-lock invariant.

Tests: legacy rows count as legacy_unverified (not kill_failed); abort
distinctness (legacy vs kill-failure diagnostics); Linux-gated gate
wiring test; existing abort-contract tests retargeted to the pure
decision function so they run on every platform.

* fix(controller): fail spawn loudly when boot id is unreadable at registration

read_boot_id() returning None at registration was recorded silently,
producing a sandbox row that can never pass startup identity
verification — bricking every subsequent controller start until
state.json was hand-edited (review deeplethe#299).

registration_boot_id() treats an unreadable boot id as a spawn-blocking
error. Both production registration paths call it BEFORE any VM work:
the /v1/fork handler returns a 500 up front, and
spawn_one_for_workspace bails before snapshot lookup. Off-Linux it
returns Ok(None) so dev-box spawns keep working.

* fix(controller): type alias for orphan row to satisfy clippy type_complexity

* fix(vmm): immutable baseline rootfs — reflink clone before boot (closes deeplethe#296)

The rootfs corruption issue (deeplethe#296) was caused by vm.kill() SIGKILLing
firecracker without a clean ext4 unmount, leaving the rootfs dirty.
The previous approach (PR deeplethe#295) ran e2fsck -fy before each boot to
repair the dirty journal — but the reviewer flagged a TOCTOU race:
the /proc/*/fd scan is point-in-time, so another VM could open the
rootfs during e2fsck.

The immutable-baseline approach eliminates the race entirely:

1. The original rootfs is the IMMUTABLE BASELINE — never mounted RW.
2. Before each boot, forkd creates a reflink copy (FICLONE ioctl,
   instant on btrfs/xfs/overlayfs; falls back to full copy on ext4/tmpfs)
   in the snapshot directory.
3. The VM boots from the clone and writes to it; the baseline stays clean.
4. After vm.kill(), the clone persists as the snapshot's rootfs (needed
   for restores — Firecracker re-opens the rootfs from the path in the
   vmstate).
5. The next forkd snapshot --rootfs <baseline> boots from a fresh clone
   of the still-clean baseline — no e2fsck needed, no TOCTOU race.

The reflink copy is exposed as pub fn reflink_copy in chain.rs (wraps
the existing copy_base_memory which already has FICLONE + stream
fallback). The snapshot_cmd function in forkd-cli creates the clone at
<snapshot_dir>/rootfs.ext4 and records its path in snap.rootfs.

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(snapshot): cache versioning, atomic staging, portable rootfs transport

Review deeplethe#295 r6 (WaylandYang 2026-08-14): three correctness/portability
blockers on the immutable-baseline clone design. All three closed, plus
the four requested regression tests.

Blocker 1 — cache versioning (crates/forkd-cli/src/main.rs):
A cached rootfs produced or dirtied by an older forkd version was
reused solely because the path exists, then cloned as the supposedly
immutable baseline. Now each built rootfs gets a `.cache-meta.json`
sidecar recording schema_version + sha256 + image + size + forkd
version. `validate_cached_rootfs` trusts a cache entry only when the
meta exists, the schema version matches ROOTFS_CACHE_SCHEMA_VERSION
(=1), and the live sha256 still matches. Legacy entries (no meta),
schema mismatches, or sha mismatches (truncation/mutation) force a
rebuild. Wired into both from_image_cmd and run_cmd cache-hit paths;
write_rootfs_cache_meta is called after every build.

Blocker 2 — atomic snapshot staging (snapshot_cmd + publish_snapshot):
snapshot_cmd removed snap_dir/rootfs.ext4 BEFORE clone/boot/snapshot
succeeded, so re-running a tag destroyed the last usable snapshot and
(src==dst) could unlink its own source. The entire new snapshot
(rootfs clone + vmstate + memory.bin + snapshot.json) is now built
under a distinct staging dir (<snap_dir>.staging-<pid>) and only
published via publish_snapshot() after boot + warmup + snapshot +
metadata write all succeed. publish_snapshot does a safe two-step
shuffle: move the old snap_dir aside, rename staging into place (the
commit point), then drop the old. On commit-point failure the old
snapshot is restored from the aside, so a crash at any point leaves
either the new OR the old snapshot, never neither. A src==dst guard
rejects cloning the baseline into the snapshot's own rootfs.ext4 path.

Blocker 3 — portable rootfs transport (crates/forkd-cli/src/hub.rs):
The rootfs was shipped TWICE — tarred into the pack via SNAPSHOT_FILES
AND emitted as a content-addressed .rootfs.zst sidecar — duplicating a
potentially huge image. pack() now records rootfs.ext4 in the manifest
files list (for integrity accounting + list_local) but skips appending
it to the tar body when a portable sidecar is emitted, so there is ONE
rootfs transport. RootfsRef.target_path is now a PORTABLE relative
filename (e.g. "rootfs.ext4") instead of the packing host's absolute
path; satisfy_rootfs resolves it against the destination snapshot dir
(absolute paths from legacy packs still work). unpack_into now
returns the dest snapshot dir so the relative target_path can be
resolved; unpack_chain_into returns the head link's dest.

Tests (crates/forkd-cli/src/main.rs):
- validate_cached_rootfs_rejects_legacy_entry_without_meta (upgrade/dirty-cache)
- validate_cached_rootfs_rejects_wrong_schema_version (upgrade/dirty-cache)
- validate_cached_rootfs_rejects_sha_mismatch_after_mutation (dirty-cache)
- validate_cached_rootfs_accepts_fresh_valid_entry
- validate_cached_rootfs_misses_on_missing_file
- publish_snapshot_atomically_replaces_existing (same-tag failure)
- publish_snapshot_into_nonexistent_snap_dir
- publish_snapshot_preserves_existing_when_staging_missing (same-tag failure recovery)

Signed-off-by: jrimmer <jason@rimmer.net>

* style: cargo fmt + fix stray brace in run_cmd cache validation

Rebase resolution left a stray closing brace after the
validate_cached_rootfs match block in run_cmd (the original
if/else's trailing brace was not removed). cargo fmt the rebased
diff.

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(cli): qualify sha256_file calls with hub:: prefix

write_rootfs_cache_meta and validate_cached_rootfs called sha256_file
without the hub:: prefix; the function lives in forkd-cli::hub, not
main.rs. This was a compile error (E0425 cannot find function
sha256_file) on Linux clippy CI.

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(cli): clippy redundant closure in destination fallback

unwrap_or_else(|| std::path::PathBuf::new()) -> unwrap_or_else(std::path::PathBuf::new)

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(snapshot): 4 blockers from review deeplethe#295 (2026-08-22)

Rebased onto dev (d2d238a, incl. deeplethe#312). Addresses all four review
blocks on the staging/transport flow while keeping cache-versioning and
failure-rollback.

Blocker 1 — FC-visible rootfs path unstable across publish:
Firecracker serializes the drive path_on_host INTO the binary vmstate
and REOPENS it on restore (no PUT /drives override is accepted before
/snapshot/load). Previously snapshot_cmd cloned rootfs to
staging_dir/rootfs.ext4 and booted the VM from it, then publish_snapshot
renamed the whole dir to snap_dir — leaving the vmstate's recorded path
nonexistent, so the first restore after publish failed. Fix: clone+boot
from the STABLE final path snap_dir/rootfs.ext4 from the start; only
vmstate/memory/snapshot.json are staged. publish_snapshot is replaced by
publish_snapshot_metadata, which renames the 3 metadata files into
snap_dir (snapshot.json LAST = commit marker) and never moves the rootfs.
Tests: metadata-replace-keeps-rootfs, into-nonexistent-snap_dir,
preserves-existing-on-missing-metadata.

Blocker 2 — pack/unpack sidecar never placable:
pack listed rootfs.ext4 in manifest.files but omitted it from the tar
body; unpack verified EVERY declared file before satisfy_rootfs, hashing
the missing extracted rootfs and failing. Fix: when a portable sidecar
is emitted, retain rootfs.ext4 out of manifest.files (sidecar carries
sha integrity). Test: pack_unpack_roundtrip_with_sidecar_rootfs.

Blocker 3 — src==dst guard compared staging path, not final:
extracted to rootfs_clone_into_self(src, snap_dir) comparing canonical
against the FINAL snap_dir/rootfs.ext4, so re-snapshotting a tag whose
baseline is its own rootfs.ext4 is rejected. Test: same-tag regression.

Blocker 4 — path traversal via RootfsRef.target_path:
satisfy_rootfs now REQUIRES a safe single-component relative filename
(no absolute / no separators / no ..), rejecting malicious ../../../ or
legacy absolute paths (fail-closed; writes would otherwise land outside
snap_dir under sudo). Test: satisfy_rootfs_rejects_unsafe_target_paths.

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(snapshot): re-bake rollback + validated absolute rootfs placement (deeplethe#295 r9)

Addresses the 2026-08-22T20:28 review round on PR deeplethe#295.

Blocker 1 — a failed re-bake destroyed the previously published tag:
the old rootfs was remove_file'd before the VM booted, so any later
failure (boot timeout, snapshot error, publish error, interrupt) left
old metadata frozen against a replaced, dirtied rootfs (deeplethe#296 EBADMSG).
The previous rootfs is now renamed aside to <tag>.rootfs.ext4.prev-<pid>
and a RootfsRollback guard restores it on every failure path (Drop =
rollback, covers ?-propagation and panic unwinding); the backup is
deleted only after publish_snapshot_metadata commits the new snapshot.

Blocker 2 — snap_dir-relative target_path broke cross-host restore:
Firecracker freezes the drive path into the vmstate and reopens it
verbatim at /snapshot/load, so placement must not depend on the
destination snap_dir. manifest.rootfs.target_path records the absolute
path again (un-breaking published packs whose absolute target_path the
r6 code rejected outright), and satisfy_rootfs validates it against
forkd-managed roots — the forkd data dir, its snapshots/ tree, and the
rootfs cache (, default /var/cache/forkd) — with
traversal/backslash rejection and existing-prefix canonicalization
(symlink escape hardening) before any privileged write. A missing or
unfetchable sidecar is now a hard error: a warn-and-continue produced a
snapshot that could never restore.

Nits: duplicated src==dst comment line removed; unpack_chain_into tail
bails instead of returning an empty PathBuf that would resolve
target_path against the CWD under sudo.

Tests: RootfsRollback armed/disarmed unit test; satisfy_rootfs
rejection matrix updated for the absolute-path contract plus placement
and sha-dedup assertions; cross-snap-dir verbatim placement round-trip
(reviewer's blocker-2 regression ask); validate_rootfs_target_path unit
contract; the #[ignore] KVM regression extended with a failed-re-bake
rollback and a cross-tag sidecar placement scenario (FORKD_TEST_*).

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(snapshot): clippy needless-question-mark + restore opts rebuild in KVM test

Signed-off-by: jrimmer <jason@rimmer.net>

* test(snapshot): rollback restores content at the published path, not absence

Signed-off-by: jrimmer <jason@rimmer.net>

* test(snapshot): surface per-child restore failures instead of swallowing them

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(snapshot): repoint staged snapshot.json at final paths on publish

The staging-flow snapshot.json was serialized with staging-dir vmstate/
memory paths, and publish_snapshot_metadata renamed the files without
fixing up the paths inside — so every staged snapshot published since
the atomic-staging change recorded a vmstate path in a directory that
is deleted right after publish. Restore then fails with 'Failed to
open snapshot file' (caught by the first real-KVM run of the
snapshot_stable_rootfs_publishes_and_restores regression on a KVM
host; CI could never see it because the test is #[ignore]-gated).

publish_snapshot_metadata now rewrites the staged snapshot.json's
vmstate/memory keys to the snap_dir paths BEFORE installing it (still
snapshot.json-last, so the commit marker is never a half-fixed file).
This is safe precisely because vmstate/mem are /snapshot/load request
arguments, unlike the drive path frozen into the binary vmstate.

Publish unit tests now stage real JSON (as snapshot_cmd does) and
assert the repoint; the KVM regression asserts per-child restore
failures are empty so a dead child can no longer hide behind an Ok.

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(cli): sweep every rootfs .prev backup on re-bake

A re-bake parks the previous published rootfs as
`<tag>.rootfs.ext4.prev-<pid>`. The pid belongs to the run that parked
it, so the same-pid check could only ever clean up after a run whose
pid happened to match. A `kill -9` between the preserve-rename and the
publish stranded the file under a pid no later run would look for —
several GB that nothing collected — while the tag was left with no
`rootfs.ext4` until someone renamed the backup back by hand.

Sweep every `<tag>.rootfs.ext4.prev-*` sibling instead. When the
published rootfs is missing, the newest backup is renamed back into
place; the remaining ones are scratch from runs that never published
and are dropped best-effort — a stale file that cannot be removed no
longer aborts the bake, since the preserve-rename would overwrite it
anyway.

Also corrects the `satisfy_rootfs` doc comment, which still described
the missing-sidecar case as a warning when it is now a `bail!`.

Both are follow-ups from the deeplethe#295 approval.

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(vmm): reap the Firecracker child on every failed boot

`Vm::boot` spawned Firecracker and then drove `/boot-source`,
`/drives/rootfs`, `/entropy` and `/actions` with a bare
`std::process::Child` in hand. `Child` has no kill-on-drop, so any `?`
in that window — `wait_for_sock` timing out, or an API call failing
after the process was up — returned an error and abandoned a *live*
Firecracker.

The orphan keeps the sandbox's tap device and rootfs fd open, and the
tap name is frozen into the vmstate, so every later spawn of that
snapshot dies inside Firecracker with `Open tap device failed` before
it can create its API socket. Callers see
`socket .../child-N.sock never appeared` and the snapshot stays
unusable until the orphan is killed by hand — the permanent-failure
mode in deeplethe#301.

Own the child in a `PendingFirecracker` guard that reaps it on every
early return and hands it to the `Vm` only on success. `restore_many_with`
already wrapped its children immediately, so `boot` was the only window
in the crate that could orphan a process with no handle left.

Two unit tests cover the drop path (the pid must be gone, not just
untracked) and the take path (the handed-over child must still be live).

Signed-off-by: jrimmer <jason@rimmer.net>

* fix(cli): gate bakes on free space, and always clean up the staging dir

Both halves of one failure mode: a bake that runs out of space does not
stop, it produces a *corrupt* artifact — a rootfs whose package files
carry other files' bytes, or a truncated `memory.bin`. Inside a guest
that surfaces much later as `uname: option requires an argument`,
`Exec format error`, or `EBADMSG` on a `/var/lib/dpkg` entry, so it
reads like a broken build rather than a broken image.

- `forkd parent build` (conversion) and `forkd snapshot` (bake) check
  free space on the target filesystem before the first write and refuse
  below a 5 GiB reserve (`FORKD_MIN_FREE_GIB` overrides). Conversion is
  the first multi-GB write of the pipeline, and the snapshot dir holds
  the clone plus a fresh `memory.bin`. The probe stays advisory: if
  `statvfs` cannot run, warn and continue rather than block work on a
  broken measurement. `doctor::available_bytes(path)` is extracted from
  the check's hardcoded-path statvfs so both callers share one probe.

- `forkd snapshot` removes its staging dir on every exit. Only the
  success path did, so any `?` after the volatile artifacts were written
  — boot timeout, snapshot error, publish error, interrupt — left a
  fully written `memory.bin` (GBs) beside the snapshot dir that nothing
  ever collected.

Tests cover the guard (removal, and tolerance of an already-removed dir —
the success path renames files out and a stale sweep may beat it) and the
probe's ancestor walk, which is what makes it usable before the snapshot
dir exists.

Signed-off-by: jrimmer <jason@rimmer.net>

* test(cli): tolerate free-space drift between the two statvfs probes

Parallel tests write to the same filesystem between the two calls, so
exact equality flakes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): restore the .prev backup unless the tag was republished

A rootfs.ext4 at the published path is not evidence the tag is whole:
the interrupted run clones its fresh rootfs there right after parking
the backup and then boots and writes to it for the whole warmup. The
sweep discarded the backup in that case, leaving the old metadata
frozen against a dirtied, unpublished clone (deeplethe#296) with the last good
rootfs deleted.

Decide by the publish commit marker instead: restore the newest backup
unless snapshot.json is newer than the backup's park ctime. Backups
whose pid is still running a bake are left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(vmm): use the real FICLONE ioctl number

0x40209409 encodes a 32-byte payload; FICLONE is _IOW(0x94, 9, int) =
0x40049409. The kernel answered ENOTTY, which the fallback reads as no
reflink, so every reflink_copy streamed a full copy on every
filesystem. Reported by @jrimmer in deeplethe#321.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style: rustfmt the FICLONE derivation test

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style(cli): name the backup tuple for clippy::type_complexity

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(vmm): compare FICLONE without a cast

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(release): build the release binaries against jammy (glibc 2.35) — ubuntu-latest's glibc 2.39 binary dies on the sandbox host VMs (#5)

LIVE refusal (urun dev-usw2 2026-10-15, cpu-sbx node boot, stage
forkd-daemon): the freshly-installed forkd-controller crash-looped with
  /usr/local/forkd/forkd-controller: /lib/x86_64-linux-gnu/libc.so.6:
  version GLIBC_2.38 not found (required by forkd-controller)
The release workflow builds on ubuntu-latest (24.04, glibc 2.39) while
the sandbox host VM image is Ubuntu 22.04 (glibc 2.35) — a binary built
on newer glibc can never run there.

The binaries job now builds inside an ubuntu:22.04 container (all-TLS
via rustls; no openssl native deps), keeping every other step identical.
The docker image job is untouched (it runs the binary inside its own
container image).

---------

Signed-off-by: jrimmer <jrimmer@users.noreply.github.com>
Signed-off-by: jrimmer <jason@rimmer.net>
Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>
Co-authored-by: jrimmer <jason@rimmer.net>
Co-authored-by: WaylandYang <145302500+WaylandYang@users.noreply.github.com>
Co-authored-by: Amir Fathi <amirfathi.me@gmail.com>
Co-authored-by: WaylandYang <wayland0916@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Re-snapshot corruption: ext4 rootfs left dirty by vm.kill() produces EBADMSG and broken binaries

2 participants