Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
7 changes: 5 additions & 2 deletions src/runtime/shell/builtin/cp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -625,9 +625,12 @@ impl ShellCpTask {
// folder -> folder
// We need to check dest to see what it is; if it doesn't exist we
// need to create it.
//
// Errors about an operand name it as the user wrote it (like the other
// builtins), not the resolved path the check ran on.
Comment thread
robobun marked this conversation as resolved.
Outdated
let src_is_dir = match Self::is_dir(src) {
Ok(x) => x,
Err(e) => return Some(ShellErr::new_sys(&e)),
Err(e) => return Some(ShellErr::new_sys(&e.with_path(&self.src))),
};

// Any source directory without -R is an error.
Expand Down Expand Up @@ -656,7 +659,7 @@ impl ShellCpTask {
// If it has a trailing directory separator, it's a directory.
(Self::has_trailing_sep(tgt.as_bytes()), false)
}
Err(e) => return Some(ShellErr::new_sys(&e)),
Err(e) => return Some(ShellErr::new_sys(&e.with_path(&self.tgt))),
};

let mut _copying_many = false;
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