Skip to content
Open
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
32 changes: 22 additions & 10 deletions src/runtime/shell/builtin/cp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -861,17 +861,27 @@ impl crate::shell::interpreter::ShellTaskCtx for ShellCpTask {

#[derive(Clone, Copy, Default)]
pub(crate) struct Opts {
/// `-R` — copy file hierarchies
/// `-R`, `-r`, `--recursive` — copy file hierarchies
pub(crate) recursive: bool,
/// `-v` — verbose
/// `-v`, `--verbose` — verbose
pub(crate) verbose: bool,
/// `-n` — do not overwrite an existing file
/// `-n`, `--no-clobber` — do not overwrite an existing file
pub(crate) no_clobber: bool,
}

impl FlagParser for Opts {
fn parse_long<'a>(&mut self, _flag: &'a [u8]) -> Option<ParseFlagResult<'a>> {
None
fn parse_long<'a>(&mut self, flag: &'a [u8]) -> Option<ParseFlagResult<'a>> {
let short = match flag {
b"--recursive" => b'R',
b"--verbose" => b'v',
b"--no-clobber" => b'n',
b"--force" => b'f',
_ => return None,
};
Some(
self.parse_short(short, flag, 0)
.unwrap_or(ParseFlagResult::ContinueParsing),
)
}

fn parse_short<'a>(
Expand All @@ -881,17 +891,19 @@ impl FlagParser for Opts {
i: usize,
) -> Option<ParseFlagResult<'a>> {
match ch {
b'R' => self.recursive = true,
b'R' | b'r' => self.recursive = true,
Comment thread
robobun marked this conversation as resolved.
Resolved
b'v' => self.verbose = true,
b'n' => self.no_clobber = true,
// Only the first flag of a group is rejected. Scripts rely on `-Rp` and `-Rf` working.
_ if i > 0 => {}
b'f' => return Some(ParseFlagResult::Unsupported(b"-f")),
// Overwriting is the default, and `-n` wins over `-f` in any order, as in GNU cp.
b'f' => {}
Comment thread
robobun marked this conversation as resolved.
Resolved
// A group led by `R`, `v` or `n` has never failed on a later flag. Scripts rely on `-Rp` working.
_ if i > 0 && matches!(smallflags[0], b'R' | b'v' | b'n') => {}
b'a' => return Some(ParseFlagResult::Unsupported(b"-a")),
b'H' => return Some(ParseFlagResult::Unsupported(b"-H")),
b'i' => return Some(ParseFlagResult::Unsupported(b"-i")),
b'L' => return Some(ParseFlagResult::Unsupported(b"-L")),
b'P' => return Some(ParseFlagResult::Unsupported(b"-P")),
b'p' => return Some(ParseFlagResult::Unsupported(b"-P")),
b'p' => return Some(ParseFlagResult::Unsupported(b"-p")),
_ => return Some(ParseFlagResult::IllegalOption(&smallflags[i..])),
}
None
Expand Down
221 changes: 218 additions & 3 deletions test/js/bun/shell/commands/cp.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { $ } from "bun";
import { shellInternals } from "bun:internal-for-testing";
import { describe, expect, test } from "bun:test";
import { bunEnv, isMacOS, isWindows, tempDir, tempDirWithFiles } from "harness";
import { mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { bunEnv, isMacOS, isWindows, tempDir, tempDirWithFiles, unprivilegedSpawnOptions } from "harness";
import { chmodSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { join, sep } from "node:path";
import { bunExe, createTestBuilder } from "../test_builder";
import { sortedShellOutput } from "../util";
Expand Down Expand Up @@ -247,9 +247,175 @@ test("operands longer than the path buffers fail with ENAMETOOLONG", async () =>
expect(exitCode).toBe(0);
});

// The children force-enable the builtin, so these also run on POSIX.
describe.concurrent("bunshell cp options", () => {
test("-f, -r and the long names of -R, -v, -n and -f", async () => {
// Each command runs in a directory that holds the files a ("new") and b ("old"), and the directory d with f.
const overwritten = { exitCode: 0, stdout: "", stderr: "", b: "new", "e/f": null };
const kept = { ...overwritten, b: "old" };
const verbose = { ...overwritten, stdout: p("<cwd>/a -> <cwd>/b\n") };
const tree = { ...kept, "e/f": "tree" };
const treeLines = p("<cwd>/d -> <cwd>/e\n<cwd>/d/f -> <cwd>/e/f\n");
// macOS prints no line when it clones the directory in one call (clonefile). It prints both when that call fails.
const treeVerbose = { ...tree, stdout: isMacOS ? expect.stringMatching(`^(${treeLines})?$`) : treeLines };
const failed = (stderr: string) => ({ ...kept, exitCode: 1, stderr });
const notCopied = failed("cp: d is a directory (not copied)\n");
const unsupported = (option: string) => failed(`cp: unsupported option, please open a GitHub issue -- ${option}\n`);
const illegal = (option: string) => failed(`cp: illegal option -- ${option}\n`);
const usage = failed(
"usage: cp [-R [-H | -L | -P]] [-fi | -n] [-aclpsvXx] source_file target_file\n" +
" cp [-R [-H | -L | -P]] [-fi | -n] [-aclpsvXx] source_file ... target_directory\n",
);
const expected = {
"cp -f a b": overwritten,
"cp --force a b": overwritten,
"cp -ff a b": overwritten,
"cp -fv a b": verbose,
"cp -v -f a b": verbose,
"cp -vf a b": verbose,
"cp -fR d e": tree,
"cp -R -f d e": tree,
"cp -Rf d e": tree,
"cp -Rv -f d e": treeVerbose,
// -f does not turn on -R.
"cp d e": notCopied,
"cp -f d e": notCopied,
"cp --force d e": notCopied,
"cp -vf d e": notCopied,
"cp -f": usage,
"cp -f a": usage,

"cp -r d e": tree,
"cp -rR d e": tree,
"cp -rf d e": tree,
"cp -nr d e": tree,
"cp -Rv d e": treeVerbose,
"cp -vR d e": treeVerbose,
"cp -rv d e": treeVerbose,
"cp -vr d e": treeVerbose,
"cp -nrv d e": treeVerbose,
"cp -frv d e": treeVerbose,
"cp -rv a b": verbose,

"cp --recursive d e": tree,
"cp --verbose a b": verbose,
"cp --no-clobber a b": kept,
// Only the whole name is accepted. GNU cp also takes a prefix such as --rec.
"cp --rec d e": illegal("-rec"),
"cp --recursivex d e": illegal("-recursivex"),
"cp --Force a b": illegal("-Force"),

// -n wins over -f in every order, as in GNU cp.
"cp -fn a b": kept,
"cp -nf a b": kept,
"cp -n -f a b": kept,
"cp -f -n a b": kept,
"cp -Rnf a b": kept,
"cp -Rfn a b": kept,
"cp -vnf a b": kept,
"cp -n -Rf a b": kept,
"cp -Rf -n a b": kept,
"cp --no-clobber --force a b": kept,
"cp --force --no-clobber a b": kept,

// After R, v or n, an option that cp does not implement is ignored, as in the released versions.
"cp -Rp a b": overwritten,
"cp -RH a b": overwritten,
"cp -RL a b": overwritten,
"cp -RP a b": overwritten,
"cp -Ri a b": overwritten,
"cp -Rz a b": overwritten,
"cp -Ra d e": tree,
"cp -vH a b": verbose,
"cp -nz a b": kept,
// As the first option of a group, and after r or f, it fails.
"cp -p a b": unsupported("-p"),
"cp -pR a b": unsupported("-p"),
"cp -R -p a b": unsupported("-p"),
"cp -rp d e": unsupported("-p"),
"cp -rfp d e": unsupported("-p"),
"cp -fp a b": unsupported("-p"),
"cp -P a b": unsupported("-P"),
"cp -rT d e": illegal("T"),
"cp -ru a b": illegal("u"),
"cp -rL d e": unsupported("-L"),
"cp -fi a b": unsupported("-i"),
"cp -ra d e": unsupported("-a"),
"cp -a d e": unsupported("-a"),
"cp -ar d e": unsupported("-a"),
// So does a word that only starts like -f or -r, such as a file name that a glob gives.
"cp -force a b": illegal("orce"),
"cp -recursive d e": illegal("ecursive"),
"cp *.css b": illegal("onts.css"),
"cp ./*.css b": overwritten,
};

using dir = tempDir("cp-options", {
"a": "new",
"d": { "f": "tree" },
"-fonts.css": "new",
"commands.json": JSON.stringify(Object.keys(expected)),
"fixture.ts": /* ts */ `
import { $ } from "bun";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import commands from "./commands.json";

$.nothrow();
const cwd = process.cwd();
const read = (path: string) => (existsSync(path) ? readFileSync(path, "utf8") : null);

const results: Record<string, unknown> = {};
for (const command of commands) {
writeFileSync("b", "old");
rmSync("e", { recursive: true, force: true });
const { exitCode, stdout, stderr } = await $\`\${{ raw: command }}\`.quiet();
results[command] = {
exitCode,
stdout: stdout.toString().replaceAll(cwd, "<cwd>"),
stderr: stderr.toString(),
b: read("b"),
"e/f": read("e/f"),
};
}
console.log(JSON.stringify(results));
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "fixture.ts"],
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("");
expect(JSON.parse(stdout)).toEqual(expected);
expect(exitCode).toBe(0);
});

// https://github.com/oven-sh/bun/issues/14595
test.each(["script.ts", "script.sh"])("cp -r folder dest in %s", async script => {
using dir = tempDir("cp-r", {
"folder": { "file": "hi" },
"script.ts": 'import { $ } from "bun";\n\nawait $`cp -r folder dest`;\n',
"script.sh": "#!/bin/bash -e\ncp -r folder dest\n",
});
await using proc = Bun.spawn({
cmd: [bunExe(), "run", 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({ stdout, stderr, exitCode }).toEqual({ stdout: "", stderr: "", exitCode: 0 });
expect(readFileSync(join(String(dir), "dest", "file"), "utf8")).toBe("hi");
});
});

// The child force-enables the builtin, so these also run on POSIX.
describe.concurrent("bunshell cp src/dest validation", () => {
async function cp(cwd: string, command: string) {
async function cp(cwd: string, command: string, options: { uid?: number; gid?: number } = {}) {
await using proc = Bun.spawn({
cmd: [
bunExe(),
Expand All @@ -260,6 +426,7 @@ describe.concurrent("bunshell cp src/dest validation", () => {
env: { ...bunEnv, BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS: "1" },
stdout: "pipe",
stderr: "inherit",
...options,
});
const [stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
return { stderr, exitCode };
Expand Down Expand Up @@ -308,6 +475,54 @@ describe.concurrent("bunshell cp src/dest validation", () => {
expect(read("fresh")).toBe("hi");
});

// POSIX and GNU cp -f remove a destination that cannot be opened and create it again (uutils test_cp_arg_force).
test("-f copies onto a read-only file as plain cp does", async () => {
const sizes = [3, 200 * 1024];
const commands = ["cp a b", "cp -f a b", "cp -n -f a b"];
const rows = sizes.flatMap(size => commands.map(command => ({ size, command })));
const tree: Record<string, { a: string; b: string }> = {};
rows.forEach(({ size }, i) => (tree[i] = { a: Buffer.alloc(size, "n").toString(), b: "old" }));
using dir = tempDir("cp-read-only", tree);
const at = (i: number, ...file: string[]) => join(String(dir), String(i), ...file);
rows.forEach((_, i) => chmodSync(at(i, "b"), 0o444));
// Root ignores file modes, so the children run unprivileged. One call serves every row:
// it changes the modes of the directories above, and a second call would race with it.
using unprivileged = unprivilegedSpawnOptions(String(dir));
try {
const results = await Promise.all(
rows.map(async ({ size, command }, i) => {
const { stderr, exitCode } = await cp(at(i), command, unprivileged);
const result = {
stderr: stderr.replaceAll(at(i), "<dir>"),
exitCode,
replaced: readFileSync(at(i, "b"), "utf8") !== "old",
};
return [`${size} bytes: ${command}`, result] as const;
}),
);

const denied = {
stderr: `cp: ${isWindows ? "Operation not permitted" : "Permission denied"}: ${p("<dir>/b")}\n`,
exitCode: 1,
replaced: false,
};
// Above 128 KiB the macOS copy unlinks the destination and clones the source.
const large = isMacOS ? { stderr: "", exitCode: 0, replaced: true } : denied;
const skipped = { stderr: "", exitCode: 0, replaced: false };
expect(Object.fromEntries(results)).toEqual({
"3 bytes: cp a b": denied,
"3 bytes: cp -f a b": denied,
"3 bytes: cp -n -f a b": skipped,
"204800 bytes: cp a b": large,
"204800 bytes: cp -f a b": large,
"204800 bytes: cp -n -f a b": skipped,
});
} finally {
// A read-only file must not stop the removal of the directory.
rows.forEach((_, i) => chmodSync(at(i, "b"), 0o644));
}
});

// Directory symlinks need extra privileges on Windows.
test.skipIf(isWindows)("cp -R dir link-to-dir fails", async () => {
using dir = tempDir("cp-self", tree);
Expand Down
Loading