Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
12 changes: 7 additions & 5 deletions src/runtime/shell/builtin/cp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -569,20 +569,22 @@ impl ShellCpTask {
.is_some_and(|&c| resolve_path::Platform::AUTO.is_separator(c))
}

fn is_dir(path: &bun_core::ZStr) -> bun_sys::Maybe<bool> {
/// `path` is `operand` resolved against the shell's cwd; the error names
/// `operand` as the user wrote it, which is what the message prints.
fn is_dir(path: &bun_core::ZStr, operand: &[u8]) -> bun_sys::Maybe<bool> {
#[cfg(windows)]
{
match bun_sys::get_file_attributes(path) {
Some(attrs) => Ok(attrs.is_directory),
None => Err(
bun_sys::Error::from_code(bun_sys::E::ENOENT, bun_sys::Tag::copyfile)
.with_path(path.as_bytes()),
.with_path(operand),
),
}
}
#[cfg(not(windows))]
{
let st = bun_sys::lstat(path)?;
let st = bun_sys::lstat(path).map_err(|e| e.with_path(operand))?;
Ok(bun_sys::S::ISDIR(st.st_mode as _))
}
}
Expand Down Expand Up @@ -625,7 +627,7 @@ impl ShellCpTask {
// folder -> folder
// We need to check dest to see what it is; if it doesn't exist we
// need to create it.
let src_is_dir = match Self::is_dir(src) {
let src_is_dir = match Self::is_dir(src, &self.src) {
Ok(x) => x,
Err(e) => return Some(ShellErr::new_sys(&e)),
};
Expand All @@ -650,7 +652,7 @@ impl ShellCpTask {
));
}

let (tgt_is_dir, tgt_exists) = match Self::is_dir(tgt) {
let (tgt_is_dir, tgt_exists) = match Self::is_dir(tgt, &self.tgt) {
Ok(is_dir) => (is_dir, true),
Err(e) if e.get_errno() == bun_sys::E::ENOENT => {
// If it has a trailing directory separator, it's a directory.
Expand Down
82 changes: 80 additions & 2 deletions test/js/bun/shell/commands/cp.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { $ } from "bun";
import { shellInternals } from "bun:internal-for-testing";
import { describe, expect } from "bun:test";
import { tempDirWithFiles } from "harness";
import { describe, expect, test } from "bun:test";
import { bunEnv, isWindows, tempDir, tempDirWithFiles } from "harness";
import { bunExe, createTestBuilder } from "../test_builder";
import { sortedShellOutput } from "../util";
const { builtinDisabled } = shellInternals;
Expand Down Expand Up @@ -173,6 +173,84 @@ describe.if(!builtinDisabled("cp"))("bunshell cp", async () => {
});
});

// The builtin stats each operand after resolving it against the cwd and used to
// put the resolved path in the error; the message has to name the operand as
// written, like ls/rm/mv and the system cp do. Runs in a child process because
// the builtin is off by default on POSIX (builtinDisabled above), where the env
// var turns it on.
test("stat errors name the operand as written", async () => {
using dir = tempDir("cp-operand-as-written", {
"f.txt": "source",
"g.txt": "not a directory",
"sub": {},
"dir": {},
});
const cases = {
missingSource: ["missing.txt", "dest.txt"],
missingSourceInSubdir: ["sub/missing.txt", "dest.txt"],
unnormalizedSource: ["./missing.txt", "dest.txt"],
missingSourceRecursive: ["-R", "missing", "dest"],
// Each source is its own task and reports its own operand; f.txt is still copied.
missingSourcesAmongOthers: ["missing1.txt", "f.txt", "missing2.txt", "dir"],
// A target is only an error when the failure is something other than
// ENOENT (a missing target is created). On Windows the builtin reports
// every stat failure as ENOENT, so these two exist on POSIX only.
...(isWindows
? {}
: {
fileAsSourceDirectory: ["f.txt/x", "dest.txt"],
fileAsTargetDirectory: ["f.txt", "g.txt/x"],
}),
};
const script = /* ts */ `
import { $ } from "bun";
import { existsSync } from "node:fs";
$.nothrow();
const results = {};
for (const [name, args] of Object.entries(${JSON.stringify(cases)})) {
const { exitCode, stderr } = await $\`cp \${args}\`.quiet();
results[name] = { exitCode, stderr: stderr.toString() };
}
results.missingSourcesAmongOthers.otherCopied = existsSync("dir/f.txt");
console.log(JSON.stringify(results));
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: { ...bunEnv, BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS: "1" },
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
const results = JSON.parse(stdout);
// The two failing sources finish in whichever order the pool runs them.
results.missingSourcesAmongOthers.stderr = sortedShellOutput(results.missingSourcesAmongOthers.stderr);

const failed = (operand: string, message = "No such file or directory") => ({
exitCode: 1,
stderr: `cp: ${message}: ${operand}\n`,
});
expect(results).toEqual({
missingSource: failed("missing.txt"),
missingSourceInSubdir: failed("sub/missing.txt"),
unnormalizedSource: failed("./missing.txt"),
missingSourceRecursive: failed("missing"),
missingSourcesAmongOthers: {
exitCode: 1,
stderr: sortedShellOutput(failed("missing1.txt").stderr + failed("missing2.txt").stderr),
otherCopied: true,
},
...(isWindows
? {}
: {
fileAsSourceDirectory: failed("f.txt/x", "Not a directory"),
fileAsTargetDirectory: failed("g.txt/x", "Not a directory"),
}),
});
expect(exitCode).toBe(0);
});

function expectSortedOutput(expected: string) {
return (stdout: string, tempdir: string) =>
expect(sortedShellOutput(stdout).join("\n")).toEqual(
Expand Down
Loading