diff --git a/CHANGELOG.md b/CHANGELOG.md index af27ac18e..eac213fee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### What's Changed +- fix(cp): copy directory contents for `source/.` operands without nesting the source directory in an existing destination. - fix(http): preserve binary curl downloads and curl/wget stdout through redirections and pipelines, including compressed responses and byte counts. ## [0.20.1] - 2026-10-25 diff --git a/crates/bashkit/src/builtins/fileops.rs b/crates/bashkit/src/builtins/fileops.rs index b2ea8a2d2..f8423e763 100644 --- a/crates/bashkit/src/builtins/fileops.rs +++ b/crates/bashkit/src/builtins/fileops.rs @@ -335,13 +335,23 @@ impl Builtin for Cp { let mut stderr = String::new(); for source in sources { let src_path = resolve_path(ctx.cwd, source); + // Path::file_name normalizes away a trailing dot, but cp treats + // source/. as the directory's contents, including with a final slash. + let contents_only = source + .rsplit(std::path::is_separator) + .find(|component| !component.is_empty()) + == Some("."); let final_dest = if dest_is_dir { // Copy into directory - let filename = Path::new(source) - .file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| source.to_string()); + let filename = if contents_only { + ".".to_string() + } else { + Path::new(source) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| source.to_string()) + }; vfs_join(&dest_path, &filename) } else { dest_path.clone() @@ -361,10 +371,14 @@ impl Builtin for Cp { } if final_dest.starts_with(&src_path) { let shown = if dest_is_dir { - let name = Path::new(source.as_str()) - .file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_default(); + let name = if contents_only { + ".".to_string() + } else { + Path::new(source.as_str()) + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default() + }; format!("{}/{name}", dest.trim_end_matches('/')) } else { dest.to_string() diff --git a/crates/bashkit/tests/integration/cp_directory_dot_tests.rs b/crates/bashkit/tests/integration/cp_directory_dot_tests.rs new file mode 100644 index 000000000..6c6850f30 --- /dev/null +++ b/crates/bashkit/tests/integration/cp_directory_dot_tests.rs @@ -0,0 +1,79 @@ +//! Directory-dot operands copy contents; compare observable layout with real bash. +use bashkit::Bash; + +async fn check(script: &str, expected: &str) { + let result = Bash::new().exec(script).await.unwrap(); + assert_eq!(result.exit_code, 0, "{}", result.stderr); + assert_eq!(result.stdout, expected, "{}", result.stderr); + + #[cfg(unix)] + { + let directory = tempfile::tempdir().unwrap(); + // BSD cp lacks the GNU long option; compare equivalent recursive mode. + let oracle_script = script.replace("cp --recursive ", "cp -R "); + let oracle = std::process::Command::new("bash") + .args(["--noprofile", "--norc", "-c", &oracle_script]) + .current_dir(directory.path()) + .output() + .unwrap(); + assert_eq!( + oracle.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&oracle.stderr) + ); + assert_eq!(oracle.stdout, result.stdout.as_bytes()); + } +} + +#[tokio::test] +async fn cp_directory_dot_copies_contents_into_existing_directory() { + for source in ["source/.", "source/./", "source/./."] { + for flags in ["-r", "-R", "-a", "--recursive"] { + check( + &format!( + "mkdir -p source/nested destination; printf 'payload\\n' > source/nested/item; \ + printf 'hidden\\n' > source/.hidden; ln -s nested source/link; \ + cp {flags} {source} destination; cat destination/nested/item destination/.hidden; \ + test -L destination/link && readlink destination/link; \ + test ! -e destination/source && echo no-wrapper" + ), + "payload\nhidden\nnested\nno-wrapper\n", + ).await; + } + } +} + +#[tokio::test] +async fn cp_directory_dot_can_create_destination() { + check("mkdir -p source/nested; echo value > source/nested/item; cp -r source/. destination; cat destination/nested/item", "value\n").await; +} + +#[tokio::test] +async fn cp_directory_without_dot_retains_basename() { + for source in ["source/", ".cache", "cache."] { + let basename = source.trim_end_matches('/'); + check(&format!("mkdir -p {source} destination; echo value > {source}/item; cp -r {source} destination; cat destination/{basename}/item"), "value\n").await; + } +} + +#[tokio::test] +async fn cp_directory_dot_respects_no_clobber() { + check("mkdir -p source/nested destination; echo replacement > source/item; echo original > destination/item; echo fresh > source/nested/item; cp -rn source/. destination; cat destination/item destination/nested/item", "original\nfresh\n").await; +} + +#[tokio::test] +async fn cp_directory_dot_rejects_descendant_and_missing_source() { + check("mkdir -p source/inside destination; echo keep > source/item; cp -r source/. source/inside 2>/dev/null; echo $?; test ! -e source/inside/item && echo unchanged; cp -r missing/. destination 2>/dev/null; echo $?", "1\nunchanged\n1\n").await; + + // Host cp diagnostics vary (GNU/BSD); compare only status/layout above. + let result = Bash::new() + .exec("mkdir -p source/inside; cp -r source/. source/inside") + .await + .unwrap(); + assert_eq!(result.exit_code, 1); + assert_eq!( + result.stderr, + "cp: cannot copy a directory, 'source/.', into itself, 'source/inside/.'\n" + ); +} diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index 4cf3b8c74..80e68d20f 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -48,6 +48,7 @@ pub mod compgen_tests; pub mod coproc_tests; pub mod core_extension_point_tests; pub mod coreutils_differential_tests; +pub mod cp_directory_dot_tests; pub mod credential_injection_tests; pub mod curl_data_compat_tests; pub mod custom_builtins_tests; diff --git a/docs/filesystem.md b/docs/filesystem.md index c30c78b07..d70ec263c 100644 --- a/docs/filesystem.md +++ b/docs/filesystem.md @@ -10,6 +10,21 @@ default; you grant access deliberately, never by accident. This is the foundation of the [security model](security.md): there is no real filesystem to escape to unless you mount one. +## Copying directory contents + +Use `cp -r source/. destination` to merge a directory's contents into an existing +destination, including hidden files and nested directories. The destination does +not gain an extra `source` directory. A plain `cp -r source/ destination` keeps +that directory name instead. No-clobber (`-n`), symlink preservation and filesystem +limits still apply. + +```bash +mkdir -p package/nested workspace +echo content > package/nested/file.txt +cp -r package/. workspace +cat workspace/nested/file.txt +``` + ## The layering stack A `Bash` instance composes its filesystem from layers. Each layer wraps the one diff --git a/knowledge/foundations/builtins.md b/knowledge/foundations/builtins.md index 579d9fdd3..c51dd390e 100644 --- a/knowledge/foundations/builtins.md +++ b/knowledge/foundations/builtins.md @@ -48,6 +48,16 @@ option spelling matches GNU; builtins on a ported clap surface (see Obsolete forms GNU still accepts (`head -5`, `fold -5`) are rewritten to the modern option before getopt runs, never when they are an option's value. +### Directory copy operands + +`cp` preserves an explicit final `.` component before path normalization: +`cp -r source/. destination` merges the complete contents, while `source/` +retains its basename inside an existing destination. This uses the existing +budgeted recursive walker, including hidden entries, symlink preservation, +no-clobber and self/descendant-copy rejection. Behavioral and GNU differential +regressions live in `cp_directory_dot_tests`; the public +[filesystem guide](../../docs/filesystem.md) shows the layout. + ### Navigation resource accounting `cd` borrows its target and `CDPATH` rather than cloning them. It searches diff --git a/knowledge/log.md b/knowledge/log.md index 114cf1977..cbab86070 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,8 @@ ## 2026-10-29 +* **Directory copy**: Preserve final-dot operands so `cp -r source/. destination` copies contents without an extra source directory; ordinary directory operands retain their basename. Regression fixtures compare nested/hidden entries, symlinks, no-clobber and rejection paths with GNU bash. See [Builtins](foundations/builtins.md). + * **npm release recovery**: `publish-js.yml` and `publish-wasm.yml` accept a `tag` input on manual dispatch to re-publish an existing release tag's exact commit as `latest`, from the current workflow definition. Added after 0.20.1 never reached npm (revoked token in the tag's workflow, Rosetta test timeout). See [Release Process](operations/release-process.md). ## 2026-10-27