From a39ac26bb59b15c8c8488b7672b393474931cc29 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:27:19 +0000 Subject: [PATCH 1/4] shell(cp): accept -f/--force, -r/--recursive, --verbose and --no-clobber The cp builtin rejected `-f` as the first flag of a group, had no arm for `-r`, and took no long option. `-f` is now accepted and sets nothing: the copy already overwrites unless `-n` is given, and `-n` wins over `-f` in any order, as in GNU cp. `-r` means `-R` in any position. `--recursive`, `--verbose`, `--no-clobber` and `--force` run the arm of their letter. A flag that cp does not implement is still ignored after a leading `R`, `v` or `n`, as in the released versions. After `r` or `f` it is an error, except `p`, which is ignored after any flag. `-p` and `-a` name themselves in the unsupported-option error. Fixes #14595 --- src/runtime/shell/builtin/cp.rs | 34 +++-- test/js/bun/shell/commands/cp.test.ts | 209 +++++++++++++++++++++++++- 2 files changed, 230 insertions(+), 13 deletions(-) diff --git a/src/runtime/shell/builtin/cp.rs b/src/runtime/shell/builtin/cp.rs index 9fb085e4538b..8faeba43212c 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,21 @@ 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' => {} + // Scripts use `-Rp` and `-rp`. + b'p' if i > 0 => {} + // A group led by `R`, `v` or `n` has never failed on a later flag, and scripts rely on it. + _ 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..c98667721bc6 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,170 @@ 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" }; + // macOS clones a directory in one call and prints no line for it. + const treeVerbose = { ...tree, stdout: isMacOS ? "" : p("/d -> /e\n/d/f -> /e/f\n") }; + 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 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, + + // p is ignored when it is not the first option of its group. + "cp -Rp a b": overwritten, + "cp -rp d e": tree, + "cp -rfp d e": tree, + "cp -fp a b": overwritten, + "cp -p a b": unsupported("-p"), + "cp -pR a b": unsupported("-p"), + "cp -R -p a b": unsupported("-p"), + "cp -P a b": unsupported("-P"), + + // After R, v or n, an option that cp does not implement is ignored, as in the released versions. + "cp -RH 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, + // After r or f, it fails. + "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 +421,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 +470,47 @@ describe.concurrent("bunshell cp src/dest validation", () => { expect(read("fresh")).toBe("hi"); }); + // Copies onto a mode 444 file. Root ignores file modes, so the child runs unprivileged. + async function cpOntoReadOnly(size: number, command: string) { + using dir = tempDir("cp-read-only", { a: Buffer.alloc(size, "n").toString(), b: "old" }); + const b = join(String(dir), "b"); + chmodSync(b, 0o444); + using unprivileged = unprivilegedSpawnOptions(String(dir)); + try { + const { stderr, exitCode } = await cp(String(dir), command, unprivileged); + return { + stderr: stderr.replaceAll(String(dir), ""), + exitCode, + replaced: readFileSync(b, "utf8") !== "old", + }; + } finally { + // A read-only file must not stop the removal of the directory. + chmodSync(b, 0o644); + } + } + + // POSIX and GNU cp -f remove a destination that cannot be opened and create it again (uutils test_cp_arg_force). + test.each([3, 200 * 1024])("-f copies onto a read-only file as plain cp does (%d byte source)", async size => { + // Above 128 KiB the macOS copy unlinks the destination and clones the source. + const plain = + isMacOS && size > 128 * 1024 + ? { stderr: "", exitCode: 0, replaced: true } + : { + stderr: `cp: ${isWindows ? "Operation not permitted" : "Permission denied"}: ${p("/b")}\n`, + exitCode: 1, + replaced: false, + }; + expect({ + "cp a b": await cpOntoReadOnly(size, "cp a b"), + "cp -f a b": await cpOntoReadOnly(size, "cp -f a b"), + "cp -n -f a b": await cpOntoReadOnly(size, "cp -n -f a b"), + }).toEqual({ + "cp a b": plain, + "cp -f a b": plain, + "cp -n -f a b": { stderr: "", exitCode: 0, replaced: false }, + }); + }); + // Directory symlinks need extra privileges on Windows. test.skipIf(isWindows)("cp -R dir link-to-dir fails", async () => { using dir = tempDir("cp-self", tree); From 7f66e258d138cf6298da7da97db36a069f3381a0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:03:17 +0000 Subject: [PATCH 2/4] test(shell): take the unprivileged spawn options once in the cp read-only test Two concurrent tests each took and released `unprivilegedSpawnOptions`. That helper changes the modes of the directories above the temporary directory, so one test could remove the access that the child of the other test needed. One test now runs the six commands under one call. A second test pins the time of a file after `cp -fp`: the `p` is ignored, and only the copy of Windows keeps the time. --- test/js/bun/shell/commands/cp.test.ts | 94 +++++++++++++++++---------- 1 file changed, 61 insertions(+), 33 deletions(-) diff --git a/test/js/bun/shell/commands/cp.test.ts b/test/js/bun/shell/commands/cp.test.ts index c98667721bc6..0ab0a6e99bc3 100644 --- a/test/js/bun/shell/commands/cp.test.ts +++ b/test/js/bun/shell/commands/cp.test.ts @@ -2,7 +2,17 @@ import { $ } from "bun"; import { shellInternals } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; import { bunEnv, isMacOS, isWindows, tempDir, tempDirWithFiles, unprivilegedSpawnOptions } from "harness"; -import { chmodSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { + chmodSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + utimesSync, + writeFileSync, +} from "node:fs"; import { join, sep } from "node:path"; import { bunExe, createTestBuilder } from "../test_builder"; import { sortedShellOutput } from "../util"; @@ -470,45 +480,63 @@ describe.concurrent("bunshell cp src/dest validation", () => { expect(read("fresh")).toBe("hi"); }); - // Copies onto a mode 444 file. Root ignores file modes, so the child runs unprivileged. - async function cpOntoReadOnly(size: number, command: string) { - using dir = tempDir("cp-read-only", { a: Buffer.alloc(size, "n").toString(), b: "old" }); - const b = join(String(dir), "b"); - chmodSync(b, 0o444); + // 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 { stderr, exitCode } = await cp(String(dir), command, unprivileged); - return { - stderr: stderr.replaceAll(String(dir), ""), - exitCode, - replaced: readFileSync(b, "utf8") !== "old", + 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. - chmodSync(b, 0o644); + rows.forEach((_, i) => chmodSync(at(i, "b"), 0o644)); } - } + }); - // POSIX and GNU cp -f remove a destination that cannot be opened and create it again (uutils test_cp_arg_force). - test.each([3, 200 * 1024])("-f copies onto a read-only file as plain cp does (%d byte source)", async size => { - // Above 128 KiB the macOS copy unlinks the destination and clones the source. - const plain = - isMacOS && size > 128 * 1024 - ? { stderr: "", exitCode: 0, replaced: true } - : { - stderr: `cp: ${isWindows ? "Operation not permitted" : "Permission denied"}: ${p("/b")}\n`, - exitCode: 1, - replaced: false, - }; - expect({ - "cp a b": await cpOntoReadOnly(size, "cp a b"), - "cp -f a b": await cpOntoReadOnly(size, "cp -f a b"), - "cp -n -f a b": await cpOntoReadOnly(size, "cp -n -f a b"), - }).toEqual({ - "cp a b": plain, - "cp -f a b": plain, - "cp -n -f a b": { stderr: "", exitCode: 0, replaced: false }, - }); + // A p that is not the first option of its group is ignored. Only CopyFileW, the copy of Windows, keeps the time. + test("-fp copies a file without its time, except on Windows", async () => { + using dir = tempDir("cp-p", { a: "new" }); + const a = join(String(dir), "a"); + const then = new Date("2001-02-03T04:05:06Z"); + utimesSync(a, then, then); + + expect(await cp(String(dir), "cp -fp a b")).toEqual({ stderr: "", exitCode: 0 }); + expect(statSync(join(String(dir), "b")).mtimeMs === statSync(a).mtimeMs).toBe(isWindows); }); // Directory symlinks need extra privileges on Windows. From f3f8d0119aecc039fda6a0c42bb01b126617aaa9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:40:34 +0000 Subject: [PATCH 3/4] shell(cp): keep `-p` an error after `-r` and `-f` The previous commit ignored a `p` that followed another flag, so `cp -rp` and `cp -fp` exited 0 without the times that `-p` asks for. They now fail with `unsupported option -- -p`, as `cp -pr` and `cp -r -p` do. `cp -Rp` keeps working, because released versions accept it. The options test accepts both outputs of a verbose directory copy on macOS: no line when clonefile copies the directory, and two lines when that call fails. --- src/runtime/shell/builtin/cp.rs | 4 +-- test/js/bun/shell/commands/cp.test.ts | 48 ++++++++------------------- 2 files changed, 14 insertions(+), 38 deletions(-) diff --git a/src/runtime/shell/builtin/cp.rs b/src/runtime/shell/builtin/cp.rs index 8faeba43212c..f20a932845ad 100644 --- a/src/runtime/shell/builtin/cp.rs +++ b/src/runtime/shell/builtin/cp.rs @@ -896,9 +896,7 @@ impl FlagParser for Opts { b'n' => self.no_clobber = true, // Overwriting is the default, and `-n` wins over `-f` in any order, as in GNU cp. b'f' => {} - // Scripts use `-Rp` and `-rp`. - b'p' if i > 0 => {} - // A group led by `R`, `v` or `n` has never failed on a later flag, and scripts rely on it. + // 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")), diff --git a/test/js/bun/shell/commands/cp.test.ts b/test/js/bun/shell/commands/cp.test.ts index 0ab0a6e99bc3..c8dad9c029c2 100644 --- a/test/js/bun/shell/commands/cp.test.ts +++ b/test/js/bun/shell/commands/cp.test.ts @@ -2,17 +2,7 @@ import { $ } from "bun"; import { shellInternals } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; import { bunEnv, isMacOS, isWindows, tempDir, tempDirWithFiles, unprivilegedSpawnOptions } from "harness"; -import { - chmodSync, - mkdirSync, - readdirSync, - readFileSync, - rmSync, - statSync, - symlinkSync, - utimesSync, - writeFileSync, -} from "node:fs"; +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"; @@ -265,8 +255,9 @@ describe.concurrent("bunshell cp options", () => { const kept = { ...overwritten, b: "old" }; const verbose = { ...overwritten, stdout: p("/a -> /b\n") }; const tree = { ...kept, "e/f": "tree" }; - // macOS clones a directory in one call and prints no line for it. - const treeVerbose = { ...tree, stdout: isMacOS ? "" : p("/d -> /e\n/d/f -> /e/f\n") }; + 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`); @@ -323,24 +314,22 @@ describe.concurrent("bunshell cp options", () => { "cp --no-clobber --force a b": kept, "cp --force --no-clobber a b": kept, - // p is ignored when it is not the first option of its group. - "cp -Rp a b": overwritten, - "cp -rp d e": tree, - "cp -rfp d e": tree, - "cp -fp a b": overwritten, - "cp -p a b": unsupported("-p"), - "cp -pR a b": unsupported("-p"), - "cp -R -p a b": unsupported("-p"), - "cp -P a b": unsupported("-P"), - // 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 -Ri a b": overwritten, "cp -Rz a b": overwritten, "cp -Ra d e": tree, "cp -vH a b": verbose, "cp -nz a b": kept, - // After r or f, it fails. + // 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"), @@ -528,17 +517,6 @@ describe.concurrent("bunshell cp src/dest validation", () => { } }); - // A p that is not the first option of its group is ignored. Only CopyFileW, the copy of Windows, keeps the time. - test("-fp copies a file without its time, except on Windows", async () => { - using dir = tempDir("cp-p", { a: "new" }); - const a = join(String(dir), "a"); - const then = new Date("2001-02-03T04:05:06Z"); - utimesSync(a, then, then); - - expect(await cp(String(dir), "cp -fp a b")).toEqual({ stderr: "", exitCode: 0 }); - expect(statSync(join(String(dir), "b")).mtimeMs === statSync(a).mtimeMs).toBe(isWindows); - }); - // Directory symlinks need extra privileges on Windows. test.skipIf(isWindows)("cp -R dir link-to-dir fails", async () => { using dir = tempDir("cp-self", tree); From 05fbdc850f211c8bc4bf576deb3d6e526ce9fc1a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:18:46 +0000 Subject: [PATCH 4/4] test(shell): pin six more `cp` option groups `-Rv`, `-vR`, `-nrv` and `-frv` are the group orders in the tests of #35616. `-RL` and `-RP` complete the groups that stay accepted after a leading `R`. --- test/js/bun/shell/commands/cp.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/js/bun/shell/commands/cp.test.ts b/test/js/bun/shell/commands/cp.test.ts index c8dad9c029c2..0e4d5f673f16 100644 --- a/test/js/bun/shell/commands/cp.test.ts +++ b/test/js/bun/shell/commands/cp.test.ts @@ -289,8 +289,12 @@ describe.concurrent("bunshell cp options", () => { "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, @@ -317,6 +321,8 @@ describe.concurrent("bunshell cp options", () => { // 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,