Conversation
|
GNU testsuite comparison: |
Merging this PR will not alter performance
Comparing Footnotes
|
aa8d8dc to
99e6268
Compare
|
@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
Fix. The chmod is now issued against Verification. Reproduced the report locally in CPU simulation mode against
The One drive-by: the new function had been inserted between Summary
Was this response helpful? React with 👍 or 👎 to help us improve CodSpeedBot. |
|
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 |
There was a problem hiding this comment.
🟡 Changes recommended
Address the Redox build issue, ENOTSUP handling, and remaining xattr TOCTOU race.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Hardens cp permission finalization against destination symlink replacement races.
Changes:
- Adds shared no-follow chmod support with platform fallbacks.
- Uses it during file and attribute copying.
- Adds unit and integration tests for mode preservation and symlink safety.
File summaries
| File | Summary | Review findings |
|---|---|---|
tests/by-util/test_cp.rs |
Adds cp permission and symlink-safety tests. |
— |
src/uucore/src/lib/features/safe_traversal.rs |
Provides shared no-follow chmod functionality and tests. | — |
src/uu/cp/src/cp.rs |
Applies no-follow permission updates during copying. | Critical (3 votes): Redox compilation gating is missing. Moderate (1 vote): Handle ENOTSUP as well as EOPNOTSUPP. Critical (2 votes): Extended-attribute permission transitions remain vulnerable to symlink races. |
Review details
Suppressed comments (2)
src/uu/cp/src/cp.rs:2515
- The shared helper treats both
EOPNOTSUPPandENOTSUPas the unsupported no-follow result (lines 464-477), and the repository notes that these errno values are distinct on BSDs (src/uucore/src/lib/features/fsxattr.rs:16-23). Filtering onlyEOPNOTSUPPmeans a final symlink encountered during the race can makecopy_attributesfail on those platforms instead of taking the intended no-op path; handleENOTSUPhere too.
Err(e) if matches!(e.raw_os_error(), Some(libc::EOPNOTSUPP) | Some(libc::ELOOP)) => Ok(()),
src/uu/cp/src/cp.rs:2515
- This converts every
EOPNOTSUPPinto success, even when the final component is a regular file and the platform simply does not supportAT_SYMLINK_NOFOLLOW;chmod_at_fdexplicitly returns that error without a fallback on non-Linux targets. In that casecp -preports success while leaving the destination mode unchanged. The no-op should be limited to a confirmed symlink case, or the regular-file path needs a safe fd-based fallback.
Err(e) if matches!(e.raw_os_error(), Some(libc::EOPNOTSUPP) | Some(libc::ELOOP)) => Ok(()),
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| let source_perms = source_metadata.permissions(); | ||
|
|
||
| fs::set_permissions(dest, source_perms) | ||
| chmod_nofollow(dest, &source_perms) |
4398381 to
714091b
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Critical portability and test issues, plus unresolved safety and error-handling problems, remain.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
src/uu/cp/src/cp.rs:1943
- This mode branch does not cover the read-only setup in
copy_extended_attrs: it still snapshotssymlink_metadata(dest)and then calls path-basedfs::set_permissionsto make and restore the destination writable. If a regular destination is swapped for a symlink after that snapshot, those calls still chmod the link target, so thecopy_attributesrace remains for xattr-preserving copies. Route those temporary changes through the same no-follow/inode-pinned operation.
chmod_nofollow(dest, &source_perms)
src/uu/cp/src/cp.rs:2580
- The shared helper treats both
EOPNOTSUPPandENOTSUPas unsupported no-follow operations, and this repository notes that those errno values are distinct on BSDs. If a destination is swapped to a symlink and the platform returnsENOTSUP, this guard propagates the error fromcopy_attributesinstead of taking the intended no-op path, socp -pcan fail; includelibc::ENOTSUPin this check.
Err(e) if matches!(e.raw_os_error(), Some(libc::EOPNOTSUPP) | Some(libc::ELOOP)) => Ok(()),
- Files reviewed: 3/3 changed files
- Comments generated: 4
- Review effort level: Lite
| // set_permissions goes through the libc chmod() symbol. | ||
| fs::set_permissions( | ||
| format!("/proc/self/fd/{}", fd.as_raw_fd()), | ||
| fs::Permissions::from_mode(mode), | ||
| ) |
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings affect symlink safety, portability, and test correctness.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (4)
src/uu/cp/src/cp.rs:2592
- On non-Linux targets,
chmod_at_fddeliberately returnsENOSYSwhen no-followfchmodatis unsupported, but this filter omits it. If the destination is swapped to a symlink in that case,copy_attributespropagates the error instead of taking the documented no-op path; classifyENOSYSwith the other refusal codes.
let refused_nofollow = matches!(
e.raw_os_error(),
Some(code) if code == libc::EOPNOTSUPP || code == libc::ENOTSUP || code == libc::ELOOP
);
src/uu/cp/src/cp.rs:2838
- This only protects the normal final chmod. The SELinux-error recovery below still does a path-based
symlink_metadatafollowed byFile::createandset_permissions(lines 2885-2889); a symlink swap there can truncate and chmod its target, so thiscopy_filepath retains the TOCTOU escape this change is meant to close.
chmod_nofollow(dest, &dest_permissions).ok();
src/uucore/src/lib/features/safe_traversal.rs:531
- On Linux without
fchmodat2, this is the only fallback and it assumes/proc/self/fdis mounted. In a chroot or container without procfs,cp -pnow fails its attribute step for every destination, while ordinary copies silently keep the initial 0600 mode becausecopy_fileignores this error. Avoid making the cp path depend on procfs or handle that environment explicitly.
// set_permissions goes through the libc chmod() symbol.
fs::set_permissions(
format!("/proc/self/fd/{}", fd.as_raw_fd()),
fs::Permissions::from_mode(mode),
)
tests/by-util/test_cp.rs:7653
- This
cp -ptest runs undercfg(unix)but lacks thecfg_attr(wasi_runner, ignore = ...)used by the surrounding preserve-mode tests; WASI has no chmod syscall, so it will fail instead of being skipped. Add the same WASI ignore attribute.
#[test]
#[cfg(unix)]
fn test_cp_preserve_mode_via_nofollow_chmod() {
for mode in [0o644, 0o600, 0o755, 0o444, 0o4755] {
- Files reviewed: 3/3 changed files
- Comments generated: 3
- Review effort level: Lite
| fn chmod_nofollow(dest: &Path, permissions: &Permissions) -> io::Result<()> { | ||
| fs::set_permissions(dest, permissions.clone()) |
| // SAFETY: `AT_FDCWD` is the sentinel the `*at` syscalls accept to resolve a | ||
| // relative path against the current working directory. It is not a real | ||
| // descriptor, so it is always valid and is never closed. | ||
| let cwd = unsafe { BorrowedFd::borrow_raw(libc::AT_FDCWD) }; |
| // set_permissions goes through the libc chmod() symbol. | ||
| fs::set_permissions( | ||
| format!("/proc/self/fd/{}", fd.as_raw_fd()), | ||
| fs::Permissions::from_mode(mode), |
00cf274 to
7994a46
Compare
`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. Apply the mode with a single `fchmodat(AT_FDCWD, dest, AT_SYMLINK_NOFOLLOW)` instead. It refuses a symlink at the final component, which is the property the guards are after, and it costs exactly the `chmod(2)` it replaces: resolving the parent to a descriptor first would add an `openat(2)` and a `close(2)` for every copied file and directory, and pins nothing, since the parent would be resolved immediately before the chmod either way. `DirFd::chmod_at`'s implementation becomes an associated function taking the directory descriptor, so the new `safe_traversal::chmod_nofollow` shares it by passing `AT_FDCWD`; the libc-first ordering that lets fakeroot and friends interpose the call is untouched. A symlink is the callers' "do nothing" case rather than an error, but the errno cannot distinguish it from a platform that cannot chmod without following, so the destination is confirmed to be a symlink before the refusal is swallowed - otherwise `cp -p` would report success on a mode it never applied. The helper is gated on the targets `safe_traversal` is compiled for, aix, hurd and redox excluded.
7994a46 to
a7a5fa1
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved platform, extended-attribute, file-descriptor, and WASI test issues block approval.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (4)
src/uu/cp/src/cp.rs:2599
fs::set_permissionsfollows the final symlink on Unix, so this fallback leaves the original check-then-chmod race on AIX, Hurd, and Redox—the exact platforms excluded fromsafe_traversal. Implement a platform-specific no-follow operation or explicitly mark those targets unsupported; otherwise this wrapper does not provide the guarantee its name and callers claim.
fn chmod_nofollow(dest: &Path, permissions: &Permissions) -> io::Result<()> {
fs::set_permissions(dest, permissions.clone())
src/uu/cp/src/cp.rs:1943
- This hardens the mode-preservation call, but
copy_attributesthen reachescopy_extended_attrsfor-a; that helper still changes permissions with path-basedset_permissionsafter a separate metadata check. A destination symlink swap in that block can still chmod the link target, so the reported TOCTOU fix is incomplete.
chmod_nofollow(dest, &source_perms)
src/uucore/src/lib/features/safe_traversal.rs:525
AT_FDCWDis -100, not a live file descriptor; wrapping it withBorrowedFd::borrow_rawviolates that API's safety precondition even though the sentinel is accepted by *at syscalls. Keep this path on a raw-dirfd API or pass an actual cwd directory fd instead of constructing an invalidBorrowedFd.
// SAFETY: `AT_FDCWD` is the sentinel the `*at` calls accept to resolve a
// relative path against the current working directory. It is not a real
// descriptor, so it is always valid and is never closed.
let cwd = unsafe { BorrowedFd::borrow_raw(libc::AT_FDCWD) };
tests/by-util/test_cp.rs:7652
AtPath::set_modeand thecp -pmode assertion require chmod, but thiscfg(unix)test is not ignored underwasi_runner; it will panic or fail on WASI. Add the same guard used bytest_preserve_modeat lines 3616-3619.
#[cfg(unix)]
fn test_cp_preserve_mode_via_nofollow_chmod() {
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
| #[cfg(unix)] | ||
| fn test_cp_existing_dest_keeps_its_mode() { |
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved platform, procfs, file-descriptor safety, and WASI test issues remain.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (4)
src/uu/cp/src/cp.rs:2599
- This fallback reintroduces the original symlink TOCTOU on AIX, Hurd, and Redox:
fs::set_permissionsfollows the final symlink after the caller's separateis_symlinkcheck. The PR therefore does not close the reported escape on these supported Unix targets; provide a platform-specific no-follow operation (or an explicit safe failure) instead of this path-based fallback.
fs::set_permissions(dest, permissions.clone())
src/uucore/src/lib/features/safe_traversal.rs:517
- This documentation is not true on the Linux kernels that take the fallback below:
chmod_at_fdthen performsopenatplus a/proc/self/fdchmod rather than onefchmodatcall, so the claimed syscall cost and resolution behavior are unconditional when they are not. Qualify the description to cover the fallback path.
/// The `AT_FDCWD` counterpart of [`DirFd::chmod_at`]: the whole path goes to one
/// `fchmodat` call, so it costs the same as the `chmod(2)` it replaces rather than
/// also opening the parent directory. Parent components are resolved by the kernel
/// inside that single call, so there is no check-then-use window of our own.
src/uucore/src/lib/features/safe_traversal.rs:525
BorrowedFd::borrow_rawis for a borrowed real file descriptor, butAT_FDCWDis the negative sentinel-100, not an open fd. Passing it through this type violates its safety contract; pass the sentinel through an API that accepts a rawdirfd, or use a real cwd directory fd instead of constructingBorrowedFdfrom it.
// SAFETY: `AT_FDCWD` is the sentinel the `*at` calls accept to resolve a
// relative path against the current working directory. It is not a real
// descriptor, so it is always valid and is never closed.
let cwd = unsafe { BorrowedFd::borrow_raw(libc::AT_FDCWD) };
tests/by-util/test_cp.rs:7673
- This test also calls
set_modeon the fixture, but has nowasi_runnerguard. The existing cp mode tests skip under WASI because chmod is unavailable; add the same guard here so the test does not fail before it can exercise the overwrite behavior.
#[test]
#[cfg(unix)]
fn test_cp_existing_dest_keeps_its_mode() {
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Lite
| let fd = openat( | ||
| &self.fd, | ||
| dirfd, | ||
| name, | ||
| OFlag::O_PATH | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC, | ||
| Mode::empty(), |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Unresolved portability, safety, mode-preservation, and WASI test issues remain.
Review details
Suppressed comments (5)
src/uu/cp/src/cp.rs:2599
- This fallback still calls path-based
set_permissions, which follows a final symlink. On AIX/Hurd/Redox (and non-Unix targets), the callers' pre-check remains racy, so a symlink swapped in during the copy can still chmod its target—the security fix is not applied on those targets. Provide a target-appropriate no-follow implementation or do not expose this aschmod_nofollow.
fs::set_permissions(dest, permissions.clone())
src/uu/cp/src/cp.rs:2827
- If the no-follow chmod is unsupported on a Unix target, this silently drops
EOPNOTSUPPfor a regular destination. Fresh destinations are created restrictively at 0600, so plaincpthen leaves them at 0600 instead of applyingdest_permissions; add a platform-safe chmod fallback or otherwise preserve normal-copy mode semantics without following symlinks.
chmod_nofollow(dest, &dest_permissions).ok();
src/uucore/src/lib/features/safe_traversal.rs:527
- This new path is used by
cp -pon every non-excluded Unix target, but the shared implementation has no non-Linux fd fallback whenfchmodat(..., AT_SYMLINK_NOFOLLOW)is unsupported. On OpenBSD, where the existing permission-preservation tests are explicitly excluded, that leaves regular-file mode preservation returning the unsupported error and makes the newchmod_nofollow_changes_a_regular_fileassumption invalid. Add a supported non-Linux implementation or guard unsupported targets before enabling this API.
pub fn chmod_nofollow(path: &Path, mode: u32) -> io::Result<()> {
let path_cstr = CString::new(path.as_os_str().as_bytes())
.map_err(|_| SafeTraversalError::PathContainsNull)?;
// SAFETY: `AT_FDCWD` is the sentinel the `*at` calls accept to resolve a
// relative path against the current working directory. It is not a real
// descriptor, so it is always valid and is never closed.
let cwd = unsafe { BorrowedFd::borrow_raw(libc::AT_FDCWD) };
DirFd::chmod_at_fd(cwd, path_cstr.as_c_str(), mode, SymlinkBehavior::NoFollow)
src/uucore/src/lib/features/safe_traversal.rs:525
BorrowedFd::borrow_rawrequires an actual open descriptor, butAT_FDCWDis the negative sentinel accepted by *at syscalls, not a valid descriptor. Passing it asBorrowedFdviolates the type's safety contract; keep the cwd case separate or use an API that models the sentinel instead of manufacturing this value.
let cwd = unsafe { BorrowedFd::borrow_raw(libc::AT_FDCWD) };
tests/by-util/test_cp.rs:7677
- This test changes and asserts POSIX mode bits but has no
wasi_runnerguard. WASI has no chmod syscall, so it will fail in the WASI test job; add the same ignore used by the preceding mode-preservation test.
fn test_cp_existing_dest_keeps_its_mode() {
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Lite
copy_fileandcopy_attributesboth decide whether to chmod the destination by testing it for being a symlink, but the test happens long before the chmod. Incopy_filethelstatis 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 intodestduring that gap redirects the mode change onto the link's target, outside the destination tree.