diff --git a/src/runtime/shell/builtin/cp.rs b/src/runtime/shell/builtin/cp.rs index 9fb085e4538b..f20a932845ad 100644 --- a/src/runtime/shell/builtin/cp.rs +++ b/src/runtime/shell/builtin/cp.rs @@ -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> { - None + fn parse_long<'a>(&mut self, flag: &'a [u8]) -> Option> { + 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>( @@ -881,17 +891,19 @@ impl FlagParser for Opts { i: usize, ) -> Option> { match ch { - b'R' => self.recursive = true, + b'R' | b'r' => self.recursive = true, 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' => {} + // 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 diff --git a/test/js/bun/shell/commands/cp.test.ts b/test/js/bun/shell/commands/cp.test.ts index a4f9969530da..0e4d5f673f16 100644 --- a/test/js/bun/shell/commands/cp.test.ts +++ b/test/js/bun/shell/commands/cp.test.ts @@ -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"; @@ -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("/a -> /b\n") }; + const tree = { ...kept, "e/f": "tree" }; + const treeLines = p("/d -> /e\n/d/f -> /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 = {}; + 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, ""), + 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(), @@ -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 }; @@ -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 = {}; + 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), ""), + 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("/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);