Skip to content

cp -r mutates symlink targets' permissions on macOS (chmod follows symlink to source) #12191

@maxim-uvarov-ai-assistant

Description

cp -r mutates symlink targets' permissions on macOS (chmod follows symlink to source)

Summary

When cp -r SRC DEST recursively copies a directory tree containing symlinks, uutils correctly creates the symlinks at DEST (without dereferencing) — but then calls set_permissions(dest_symlink_path, ...). On platforms where chmod() follows symlinks (macOS / BSD by default; Linux too unless lchmod/fchmodat(AT_SYMLINK_NOFOLLOW) is used), this set_permissions call traverses the new symlink and mutates the original target file's mode — silently, with no error.

This affected files outside the cp source tree entirely. In our case, a routine cp -r client_data client_data_backup invisibly chmoded ~9 files in a different repository (the symlink targets), causing a "phantom" mode-only diff on next git status.

The bug is present regardless of --preserve value:

Command Source mode after copy (started at 0600)
nu -c 'cp -r src dst' (default) 0755 (preserves symlink lstat mode)
nu -c 'cp --preserve [] -r src dst' 0644 (umask-derived default)
/bin/cp -R src dst (BSD/macOS) 0600 (correct)
/bin/cp -RP src dst 0600 (correct)
/bin/cp -Rp src dst 0600 (correct)
nu -c '^cp -R src dst' (external) 0600 (correct)

Reproducer

TMPDIR=$(mktemp -d) && cd "$TMPDIR"
mkdir target_dir
echo "secret" > target_dir/file.txt
chmod 0600 target_dir/file.txt

mkdir src
ln -s "$TMPDIR/target_dir/file.txt" src/link_to_file

stat -f "%Sp before: %N" target_dir/file.txt
# -rw------- before: target_dir/file.txt

nu -c 'cp -r src dst'

stat -f "%Sp after:  %N" target_dir/file.txt
# -rwxr-xr-x after:  target_dir/file.txt   ← BUG: source mutated

The destination is created correctly (a symlink to the same target). The unintended side-effect is on the source target file, which lives outside src/ and outside dst/.

Why this happens (hypothesis)

uutils cp -r on a symlink:

  1. Creates the destination symlink via symlink(src_target, dst_path)
  2. Calls something like set_permissions(dst_path, mode) to "preserve" attributes
  3. Rust's std::fs::set_permissions ultimately invokes chmod() (POSIX), which follows symlinks by default on macOS/BSD
  4. The chmod hits the file the new symlink points to — i.e., the original source target

The mode applied depends on what uutils computed:

  • --preserve mode (default): uses the symlink's lstat mode, which on macOS is always 0755 regardless of the actual target's permissions
  • --preserve []: uses an apparent fallback default of umask & 0666 = 0644

Either way, the original target's mode is replaced — irrespective of what it was before.

The fix would be to either (a) call fchmodat(AT_SYMLINK_NOFOLLOW) / lchmod for symlink destinations, or (b) skip set_permissions entirely for symlinks (since POSIX symlinks don't carry meaningful mode bits anyway).

Severity

This is silent data corruption of files outside the copy operation's stated scope. It's particularly nasty because:

  • No error or warning is printed
  • The destination tree looks correct
  • The damage is in the source-of-symlink target, which the user typically didn't even open
  • It's discovered later via git status showing inexplicable mode changes — or worse, isn't discovered at all

Real-world trigger: any "backup-by-copy" workflow over a workspace containing symlinks into another repo (e.g. monorepo-style external storage layouts, dotfile farms, dev workspace links).

Environment

  • nushell 0.112.2, build macos-aarch64, rustc 1.94.1
  • macOS 26.4.1, Darwin Kernel 25.4.0, ARM64

Workaround

Use external BSD /bin/cp -R (via ^cp in nushell) when copying trees that contain symlinks. The bundled cp is unsafe for this case until uutils chmods through lchmod/fchmodat.

Related

  • cp wrong permission of source directory instead of umask #10862cp -r doesn't intersect copied permissions with the process umask. Different symptom (wrong perms on destination, sometimes EPERM mid-copy) and different reproducer (no symlinks needed, requires custom umask), but likely shares infrastructure: both look like consequences of cp calling std::fs::set_permissions without the flags it needs. cp wrong permission of source directory instead of umask #10862 is "wrong mode value chosen"; this one is "right mode value applied to wrong file (followed symlink to source)". A fix that audits every set_permissions call site in uutils cp would likely address both.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions