Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 22 additions & 8 deletions crates/bashkit/src/builtins/fileops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down
79 changes: 79 additions & 0 deletions crates/bashkit/tests/integration/cp_directory_dot_tests.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}
1 change: 1 addition & 0 deletions crates/bashkit/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions docs/filesystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions knowledge/foundations/builtins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading