Linux: pin the daemon's image as a separate file, not a hard link (#1422)
Fixes #1422.
Problem
On Linux the startup pin hard-links the running daemon's executable (linkat of /proc/<pid>/exe). Each shell task then hard-links the pin into its own startup-image folder and removes that link when the task ends. Each link and unlink changes the inode change time (ctime) and the link count of the deployed binary. Thus the metadata of the deployed file changes during each command, although its bytes do not.
Some checks compare the metadata of the configured executable before and after a command: size, mode, device, inode, mtime and ctime. Such a check gives a failure on each command. File-integrity monitors and backup tools that use ctime see the same change. Measurements are in #1422.
Change
- On Linux,
link_imagenow refuses, as it already does on macOS.materializethen streams the held image into the pin (mode 0500). This is the same copy path that already serves the cross-volume case and the pin repair. - Task images still hard-link the pin. The pin is now a separate file, so the deployed binary keeps one name and is never linked.
- macOS and Windows behavior does not change. The comment on
link_imagenow gives the reason for each platform. - The integration test
pinned_executable_real_server_survives_binary_replacement_and_cleans_dead_pinsasserted on Linux that the pin is the server's own inode. It now checks the separate-file conditions that it already checked on macOS: the pin is under storage, has a different device and inode, the deployed file keeps one link, and the bytes are equal. I found this assertion by running the integration suite with--features test-hooks(results below). - Why the hard link was there: the Linux pin came with "materialize Linux image pins for confined gh shells" (5afbe0383), which gives Landlock shells an ordinary path to the running image instead of
/proc/<pid>/exe, and names an exact copy from the already-open image as the fallback. The same-inode assertion (b59175c48) checks that the shim runs the startup image, not the replaced deployment path. The copy keeps that property: it is made from the held image at startup, and the same test then replaces the deployment path with a different binary and checks that the shim still runs the original image. - Release note:
.alfonso/release-notes/next/pin-private-copy.md.
Cost: one copy of the executable into AFT's storage at each daemon start, before any child starts.
Not in this PR: a FICLONE attempt before the copy, as the APFS clone does on macOS, could make the copy free on btrfs and XFS. I did not add it, because I did not verify that a reflink leaves the metadata of the source inode unchanged on each of those file systems.
Tests
pinned_executable_pin_is_a_separate_file_from_the_running_image(before: macOS only) now also runs on Linux.- New, Linux:
pinned_executable_prepare_and_task_image_leave_the_running_image_unchanged. It runsprepareandtask_imagewith the storage on the same mount as the test binary, so that a hard link would succeed. It checks that the running image keeps its device and inode identity, its link count and its ctime.
Each command below is as run on Linux, on top of main at 80c00780d, offline, with a new empty XDG_DATA_HOME for each run, with the result line it gave.
Without the fix (only the two tests added):
cargo test -p agent-file-tools --lib pinned_executable
test pinned_executable::tests::pinned_executable_prepare_and_task_image_leave_the_running_image_unchanged ... FAILED
test pinned_executable::tests::pinned_executable_pin_is_a_separate_file_from_the_running_image ... FAILED
assertion `left != right` failed: the pin is a hard link to the running image
assertion `left != right` failed: the pin is a hard link
test result: FAILED. 13 passed; 2 failed; 0 ignored; 0 measured; 10317 filtered out
With the fix:
cargo test -p agent-file-tools --lib pinned_executable
test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 10317 filtered out
cargo test -p agent-file-tools --lib -- task_image startup_image gh_shim shims
test result: ok. 661 passed; 0 failed; 2 ignored; 0 measured; 9669 filtered out
cargo test -p agent-file-tools --locked --features test-hooks --test integration -- executable_replacement
test executable_replacement_test::pinned_executable_standalone_creates_no_pin ... ok
test executable_replacement_test::pinned_executable_daemon_creates_pin_and_removes_it_on_clean_exit ... ok
test executable_replacement_test::pinned_executable_real_server_survives_binary_replacement_and_cleans_dead_pins ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 2354 filtered out
cargo check -p agent-file-tools --all-targets --locked --features test-hooks
Finished `dev` profile [unoptimized + debuginfo] target(s) in 8.58s
(the log has 0 warning and 0 error lines)
cargo fmt --all -- --check
(no output, exit 0)
python3 scripts/landing-policy.py --base <main> --head <this commit> --branch pin-private-copy
Landing policy: 1 original commits checked
Not run: the full test suites and the macOS and Windows targets. The macOS change in the unit test only widens a cfg, and the integration-test assertion is the one that macOS already ran.