Skip to content

fix: make cp -r with a trailing /. copy directory contents like GNU cp#185

Merged
bartlomieju merged 5 commits into
denoland:mainfrom
dsherret:cp-trailing-dot
Jul 14, 2026
Merged

fix: make cp -r with a trailing /. copy directory contents like GNU cp#185
bartlomieju merged 5 commits into
denoland:mainfrom
dsherret:cp-trailing-dot

Conversation

@dsherret

Copy link
Copy Markdown
Contributor

Fixes #176.

cp -r public/. dist/ created dist/public instead of merging the contents of public into dist. The destination was computed with Path::file_name(), which normalizes a trailing . component away (Path::new("public/.").file_name() is Some("public")). GNU cp uses the final component of the path as written — for public/. that's ., so the target resolves to dist/. = dist itself.

Changes:

  • the destination path is now computed from the final component of the path as the user specified it, so a trailing . merges directory contents into the destination (hidden files included)
  • this also fixes a panic: file_name() returns None for a source ending in .. and it was unwrapped
  • since the destination logic is shared with mv, added a guard so mv public/. dist errors with refusing to move '.' or '..' like GNU mv instead of potentially renaming over the destination directory

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DIsclosure: All AI generated, but i reviewed it.

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. It looks mostly good, but I did second pass with an agent and got this:

cp doesn't get the same ./.. guard that mv does. The mv path bails on ./.., but the shared cp path still resolves .. through calculate_destination_path:

b".." => destination.join(".."),

So cp -r foo/.. dist (with dist an existing dir) computes to_path = dist/.. → cwd, and from_path = foo/.. → cwd. do_copy_operation then runs copy_dir_recursively(cwd, cwd). Since std::fs::copy opens the destination with truncate, same-path copies zero the files out, so this is potential data loss across the whole directory.

Before this PR the same command panicked on file_name().unwrap() (aborted, no data touched), so this is a behavior change worth closing off. Could we give cp the same guard as mv (bail on a trailing ./..), rather than resolving .. to destination.join("..")?. A test along the lines of the mv one would lock it in.

@dsherret

Copy link
Copy Markdown
Contributor Author

Updated.

@dsherret
dsherret requested a review from bartlomieju July 13, 2026 18:03
@bartlomieju
bartlomieju merged commit 12366f0 into denoland:main Jul 14, 2026
4 checks passed
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.

Inconsistent behavior with GNU/POSIX cp with trailing /.

2 participants