Skip to content

cp: apply the finalize chmod without following a symlink - #13630

Open
sylvestre wants to merge 2 commits into
uutils:mainfrom
sylvestre:cp-finalize-chmod-nofollow
Open

cp: apply the finalize chmod without following a symlink#13630
sylvestre wants to merge 2 commits into
uutils:mainfrom
sylvestre:cp-finalize-chmod-nofollow

Conversation

@sylvestre

@sylvestre sylvestre commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

copy_file and copy_attributes both decide whether to chmod the destination by testing it for being a symlink, but the test happens long before the chmod. In copy_file the lstat is taken at function entry, before the copy starts, so the gap spans the entire file copy - proportional to file size, not a few instructions. A symlink swapped into dest during that gap redirects the mode change onto the link's target, outside the destination tree.

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

GNU testsuite comparison:

Skipping an intermittent issue tests/cut/bounded-memory (passes in this run but fails in the 'main' branch)
Skipping an intermittent issue tests/date/resolution (passes in this run but fails in the 'main' branch)
Skipping an intermittent issue tests/tail/retry (passes in this run but fails in the 'main' branch)
Congrats! The gnu test tests/tail/tail-n0f is now passing!

@codspeed-hq

codspeed-hq Bot commented Jul 29, 2026

Copy link
Copy Markdown

Merging this PR will regress 5 benchmarks

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 3 improved benchmarks
❌ 5 regressed benchmarks
✅ 337 untouched benchmarks
⏩ 46 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation cp_preserve_metadata[(5, 4, 10)] 161.8 ms 170.5 ms -5.1%
Simulation cp_recursive_balanced_tree[(5, 4, 10)] 147.4 ms 155.2 ms -5.03%
Simulation cp_archive_balanced_tree[(5, 4, 10)] 173.8 ms 182.8 ms -4.92%
Simulation cp_recursive_wide_tree[(6000, 800)] 374.6 ms 393.4 ms -4.76%
Simulation cp_recursive_deep_tree[(120, 4)] 24.9 ms 25.6 ms -3.05%
Simulation df_with_path 698.8 µs 570.8 µs +22.43%
Simulation cksum_crc32b 41.9 ms 40.2 ms +4.31%
Simulation numfmt_to_si_precision[10000] 94.9 ms 92 ms +3.2%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing sylvestre:cp-finalize-chmod-nofollow (2c4eb31) with main (0e791b1)2

Open in CodSpeed

Footnotes

  1. 46 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (7c87ab0) during the generation of this report, so 0e791b1 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

Both chmod sites in copy_file/copy_attributes decide whether to act by testing
dest for being a symlink, but the test happens long before the chmod: in
copy_file the lstat is taken before the copy starts, so the gap spans the whole
file copy. A symlink swapped into dest during that gap redirected the mode
change onto the link's target, off the destination tree.

Measured with a tight unlink/symlink loop against a 20MB copy, 40 rounds each,
an unrelated 0600 file taken to 0777:

  before   12/40 and 13/40 rounds hit
  after     0/40
  GNU 9.10  2/40 and 3/40

Anchor the chmod to the parent directory fd with NoFollow, reusing
DirFd::chmod_at. EOPNOTSUPP/ELOOP mean dest is now a symlink, which is exactly
the "do nothing" case both guards already describe, so it maps to success.

Note GNU is affected by the same class at a lower rate, so this is hardening
beyond GNU rather than a compatibility fix. The remaining path-based steps in
copy_attributes (timestamps, xattr, ACL, SELinux) are unchanged.
@sylvestre
sylvestre force-pushed the cp-finalize-chmod-nofollow branch from aa8d8dc to 99e6268 Compare July 29, 2026 10:40
@sylvestre

Copy link
Copy Markdown
Contributor Author

@codspeedbot fix this regression

@codspeed-hq

codspeed-hq Bot commented Aug 1, 2026

Copy link
Copy Markdown

@codspeedbot fix this regression

Fixed and pushed to this branch.

Cause. The new no-follow chmod opened the destination's parent directory to obtain a dirfd, then chmod'ed the final component through it. That is one extra openat(2) and one extra close(2) for every copied file and directory, on top of the chmod itself — which is why only the mode-preserving benchmarks (-a, --preserve=mode) moved. Measured with strace -c on a 341-directory / 3410-file tree:

cp -a openat close total syscalls
main 4098 24558 175055
this branch (before) 11259 31719 182217
this branch (after) 4098 24558 167894

Fix. The chmod is now issued against AT_FDCWD with the whole path: a single fchmodat2/fchmodat with AT_SYMLINK_NOFOLLOW, costing exactly what the chmod(2) it replaces did. The safety property is unchanged — the final component is still not followed — and dropping the dirfd loses nothing, since it was opened immediately before the chmod and therefore pinned nothing across the copy gap. The fchmodat2 fast path and the musl O_PATH fallback now live in one shared helper in uucore::safe_traversal, used by both DirFd::chmod_at and the new path-based chmod_nofollow.

Verification. Reproduced the report locally in CPU simulation mode against main, then re-measured with the fix:

Benchmark before after
cp_archive_balanced_tree[(5, 4, 10)] -3.8% unchanged
cp_preserve_metadata[(5, 4, 10)] -4.0% unchanged

The uucore safe_traversal suite passes, with a new test_chmod_nofollow_preserves_target_mode covering the path-based helper (the symlink target's mode stays untouched), and the cp test suite passes — the permission-denied tests fail in my sandbox only because it runs as root, identically on main.

One drive-by: the new function had been inserted between copy_file's doc comment and copy_file itself, so that doc had become the helper's. It is back on copy_file.

Summary
  • Warming up the performance wizard...
  • Investigating the cp regression — Root cause: the new nofollow chmod opened the parent directory for every copied entry
  • Validating the fix — uucore safe_traversal tests and cp tests pass (pre-existing root-only permission test failures aside)
  • Re-running the cp benchmarks — Regression is gone: cp_archive_balanced_tree and cp_preserve_metadata back to main's level
  • Pushing the fix — Committed and pushed the single-syscall chmod fix to the PR branch
  • Handling comment — Comment handling finished successfully

Was this response helpful? React with 👍 or 👎 to help us improve CodSpeedBot.

@SuperMuel

Copy link
Copy Markdown

Hello @sylvestre, sorry about the failed request. I fixed the bug that prevented the agent from replying here and restarted the latest request. It opened a PR at #13683

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.

2 participants