A hardlink clone corrupts its source's linked executables: the linker writes through the shared inode #17

Closed
opened 2026-08-27 19:16:07 +00:00 by claude · 0 comments
Collaborator

A hardlink clone shares artifact inodes with the cache it was cloned from, on the premise — stated at unshare_mutable_paths in scripts/cache-lib.sh — that "rustc and the linker replace deps/*.rlib, *.rmeta, and binaries rather than truncating them in place".

That is true of rustc's own outputs and false of the linker's. rustc writes an .rlib/.rmeta to a temporary and renames it over the path. A linked executable is written through whatever inode is already at the path, so a build inside a cp -al clone rewrites the source's copy.

Measured

One bin crate with a unit test, cargo test --no-run, warm target dir, cp -al clone, change the source, build in the clone, compare the source. Measured 2026-08-27 on daniel-desktop, Linux/ext4:

toolchain cargo layout file source after
stable 1.93.1 (083ac5135 2025-12-15) v1 debug/deps/bprobe-0ecbea0cebf59df5 rewritten
nightly 1.96.0-nightly (f298b8c82 2026-02-24) v1 debug/deps/bprobe-6a091d813b2be60d rewritten
nightly-2026-07-02 1.98.0-nightly (a335d47ff 2026-06-26) v1 debug/deps/bprobe-6a091d813b2be60d rewritten
nightly-2026-08-25 1.100.0-nightly (e8cb624d5 2026-08-22) v2 debug/build/bprobe/6a091d813b2be60d/out/bprobe-6a091d813b2be60d rewritten

In every case the clone's file and the source's file still share one inode afterwards, and both hold the clone's content. Both build-dir layouts, stable and nightly alike — this is not a layout-v2 problem, and deps/ is shared today.

Consequence

The same class of failure the hardlink scheme exists to prevent, one artifact family over. Branch B clones base's cache and builds; base's cached test binary now holds B's code. Base's fingerprint files are privately owned and therefore intact, so base's next build compares them against its own unchanged sources, reports Fresh, does not relink, and runs B's binary. Silent wrong-artifact reuse — and unlike the dep-info case it is the executable that actually runs.

Latent in practice only to the extent that a consuming repo's cached test binaries get reused across branches, which is precisely what the cache is for.

The trigger is NOT fully characterised

Cargo re-creates the path before linking when it also has to uplift the result — a bin target's deps/<bin>-<hash> has a hardlink twin at <profile>/<bin> — and those cases were measured safe: the clone's link produced a fresh inode with different content while the source's copy survived.

Every executable measured intact was of that shape, and every one measured rewritten had no twin. Whether the twin is the mechanism or merely a correlate of it was not determined.

Correction, 2026-08-27. An earlier revision of this paragraph claimed hardlink count at link time was ruled out as the discriminator, on the strength of a lib+bin crate whose two test binaries were both nlink == 1 and appeared to behave differently. Re-checked on review: the "intact" one had not been rebuilt at all — same content, same inode, the unit was never exercised — so it demonstrated nothing, and forcing both to rebuild rewrote both. That claim asserted more than the measurement showed and is withdrawn. Output size growth and the linker in use (mold via the machine's global ~/.cargo/config.toml, and the default linker) were genuinely ruled out.

So the safe cases cannot presently be enumerated. That is the stated limitation of this ticket, not a footnote: any fix that shares some linker outputs needs a rule that can tell a rustc-renamed artifact from a linker-written one at selection time, and no such rule has been demonstrated.

Fix as shipped

gitdan-actions#16 treats every executable regular file as mutable, under both layouts, because the executable bit is the only property that reliably separates linker output from rustc output. .rlib, .rmeta and incremental/ stay shared and are the bytes worth sharing.

That is deliberately broad, and it costs. On a real 5.5 GB Bevy target directory the real-copied share goes 8.98% → 14.08% whole-tree, and 36.4% → 57.0% excluding incremental/ — the latter being the CI-shaped figure, since CI sets CARGO_INCREMENTAL=0. The copy is unconditional per clone (a fresh cp -al leaves every file with nlink >= 2, so _unshare_files' -links +1 filter cannot skip anything) and a clone happens twice per job, on seed and on publish. It does not amortise across runs.

What a narrower fix would need

A selection-time discriminator between "rustc renamed this into place" and "the linker wrote this". The obvious candidate — is_executable AND has no in-tree hardlink twin — is consistent with every measurement here, and it is not worth implementing: measured on a real 5.5 GB tree it would unshare exactly the same 283 MB, because the 489 MB of twinned executables are build-script binaries under build/ that the old selection already real-copied in full. Exempting them would reduce the status-quo cost on files about whose safety there is no evidence, not this rule's cost.

So the prize is not there. A rule that recovers real bytes would have to share some untwinned executable, which is precisely the set every rewrite was measured in — it needs the mechanism established first, not another filesystem heuristic. Reading Cargo's own unit metadata rather than inferring from the filesystem is the other direction worth trying. Correctness is not negotiable against either.

A hardlink clone shares artifact inodes with the cache it was cloned from, on the premise — stated at `unshare_mutable_paths` in `scripts/cache-lib.sh` — that "rustc and the linker replace `deps/*.rlib`, `*.rmeta`, and binaries rather than truncating them in place". **That is true of rustc's own outputs and false of the linker's.** rustc writes an `.rlib`/`.rmeta` to a temporary and renames it over the path. A linked executable is written through whatever inode is already at the path, so a build inside a `cp -al` clone rewrites the *source's* copy. ## Measured One bin crate with a unit test, `cargo test --no-run`, warm target dir, `cp -al` clone, change the source, build in the clone, compare the source. Measured 2026-08-27 on daniel-desktop, Linux/ext4: | toolchain | cargo | layout | file | source after | |---|---|---|---|---| | stable | 1.93.1 (083ac5135 2025-12-15) | v1 | `debug/deps/bprobe-0ecbea0cebf59df5` | **rewritten** | | nightly | 1.96.0-nightly (f298b8c82 2026-02-24) | v1 | `debug/deps/bprobe-6a091d813b2be60d` | **rewritten** | | nightly-2026-07-02 | 1.98.0-nightly (a335d47ff 2026-06-26) | v1 | `debug/deps/bprobe-6a091d813b2be60d` | **rewritten** | | nightly-2026-08-25 | 1.100.0-nightly (e8cb624d5 2026-08-22) | v2 | `debug/build/bprobe/6a091d813b2be60d/out/bprobe-6a091d813b2be60d` | **rewritten** | In every case the clone's file and the source's file still share one inode afterwards, and both hold the clone's content. Both build-dir layouts, stable and nightly alike — **this is not a layout-v2 problem**, and `deps/` is shared today. ## Consequence The same class of failure the hardlink scheme exists to prevent, one artifact family over. Branch B clones base's cache and builds; base's cached test binary now holds B's code. Base's fingerprint files are privately owned and therefore *intact*, so base's next build compares them against its own unchanged sources, reports `Fresh`, does not relink, and runs B's binary. Silent wrong-artifact reuse — and unlike the dep-info case it is the executable that actually runs. Latent in practice only to the extent that a consuming repo's cached test binaries get reused across branches, which is precisely what the cache is for. ## The trigger is NOT fully characterised Cargo re-creates the path before linking when it also has to uplift the result — a bin target's `deps/<bin>-<hash>` has a hardlink twin at `<profile>/<bin>` — and those cases were measured *safe*: the clone's link produced a fresh inode with different content while the source's copy survived. **Every executable measured intact was of that shape, and every one measured rewritten had no twin.** Whether the twin is the mechanism or merely a correlate of it was not determined. **Correction, 2026-08-27.** An earlier revision of this paragraph claimed hardlink count at link time was *ruled out* as the discriminator, on the strength of a lib+bin crate whose two test binaries were both `nlink == 1` and appeared to behave differently. Re-checked on review: the "intact" one **had not been rebuilt at all** — same content, same inode, the unit was never exercised — so it demonstrated nothing, and forcing both to rebuild rewrote both. That claim asserted more than the measurement showed and is withdrawn. Output size growth and the linker in use (mold via the machine's global `~/.cargo/config.toml`, and the default linker) *were* genuinely ruled out. **So the safe cases cannot presently be enumerated.** That is the stated limitation of this ticket, not a footnote: any fix that shares *some* linker outputs needs a rule that can tell a rustc-renamed artifact from a linker-written one at selection time, and no such rule has been demonstrated. ## Fix as shipped gitdan-actions#16 treats every executable regular file as mutable, under both layouts, because the executable bit is the only property that reliably separates linker output from rustc output. `.rlib`, `.rmeta` and `incremental/` stay shared and are the bytes worth sharing. That is deliberately broad, and it costs. On a real 5.5 GB Bevy target directory the real-copied share goes 8.98% → 14.08% whole-tree, and **36.4% → 57.0%** excluding `incremental/` — the latter being the CI-shaped figure, since CI sets `CARGO_INCREMENTAL=0`. The copy is unconditional per clone (a fresh `cp -al` leaves every file with `nlink >= 2`, so `_unshare_files`' `-links +1` filter cannot skip anything) and a clone happens twice per job, on seed and on publish. It does not amortise across runs. ## What a narrower fix would need A selection-time discriminator between "rustc renamed this into place" and "the linker wrote this". The obvious candidate — `is_executable AND has no in-tree hardlink twin` — is consistent with every measurement here, **and it is not worth implementing**: measured on a real 5.5 GB tree it would unshare exactly the same 283 MB, because the 489 MB of twinned executables are build-script binaries under `build/` that the *old* selection already real-copied in full. Exempting them would reduce the status-quo cost on files about whose safety there is no evidence, not this rule's cost. So the prize is not there. A rule that recovers real bytes would have to share some *untwinned* executable, which is precisely the set every rewrite was measured in — it needs the mechanism established first, not another filesystem heuristic. Reading Cargo's own unit metadata rather than inferring from the filesystem is the other direction worth trying. Correctness is not negotiable against either.
claude added the bug label 2026-08-27 19:16:07 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: daniel/gitdan-actions#17