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
55 changes: 23 additions & 32 deletions src/runtime/shell/builtin/cp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ use bun_paths::resolve_path;
use crate::shell::builtin::{Builtin, BuiltinState, IoKind, Kind};
use crate::shell::interpreter::{
EventLoopHandle, FlagParser, Interpreter, NodeId, OutputSrc, OutputTask, OutputTaskVTable,
ParseFlagResult, ShellTask, parse_flags, unsupported_flag,
ParseFlagResult, ShellTask, parse_flags, shell_join_path, shell_resolve_operand,
unsupported_flag,
};
use crate::shell::io_writer::{ChildPtr, WriterTag};
use crate::shell::yield_::Yield;
Expand Down Expand Up @@ -397,8 +398,8 @@ pub struct ShellCpTask {
pub(crate) operands: usize,
pub(crate) src: Vec<u8>,
pub(crate) tgt: Vec<u8>,
pub(crate) src_absolute: Option<Vec<u8>>,
pub(crate) tgt_absolute: Option<Vec<u8>>,
pub(crate) src_absolute: Option<bun_core::ZBox>,
pub(crate) tgt_absolute: Option<bun_core::ZBox>,
pub(crate) cwd_path: Vec<u8>,
/// `cp_on_copy` is invoked from work-pool threads (concurrently per
/// copied file) while the directory walk is still fanning out, so the
Expand Down Expand Up @@ -586,26 +587,16 @@ impl ShellCpTask {
/// (<https://man7.org/linux/man-pages/man1/cp.1p.html>), then hands off to
/// the node:fs async cp implementation.
fn run_from_thread_pool_impl(&mut self) -> Option<ShellErr> {
use resolve_path::{Platform, platform};

let mut buf2 = bun_paths::PathBuffer::uninit();
let mut buf3 = bun_paths::PathBuffer::uninit();
// We have to give an absolute path to our cp implementation for it to
// work with cwd.
let src: &bun_core::ZStr = if Platform::AUTO.is_absolute(&self.src) {
// `self.src` is the bare argv bytes (no NUL); re-terminate via
// the thread-local join buffer.
resolve_path::join_z::<platform::Auto>(&[&self.src])
} else {
resolve_path::join_z::<platform::Auto>(&[&self.cwd_path, &self.src])
let src = match shell_resolve_operand(bun_sys::Tag::copyfile, &self.cwd_path, &self.src) {
Ok(path) => path,
Err(e) => return Some(ShellErr::new_sys(&e)),
};
let mut tgt: &bun_core::ZStr = if Platform::AUTO.is_absolute(&self.tgt) {
resolve_path::join_z_buf::<platform::Auto>(buf2.as_mut_slice(), &[&self.tgt])
} else {
resolve_path::join_z_buf::<platform::Auto>(
buf2.as_mut_slice(),
&[&self.cwd_path, &self.tgt],
)
let mut tgt = match shell_resolve_operand(bun_sys::Tag::copyfile, &self.cwd_path, &self.tgt)
{
Ok(path) => path,
Err(e) => return Some(ShellErr::new_sys(&e)),
};

// Cases:
Expand All @@ -616,7 +607,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) {
Ok(x) => x,
Err(e) => return Some(ShellErr::new_sys(&e)),
};
Expand All @@ -641,7 +632,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) {
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 All @@ -659,10 +650,10 @@ impl ShellCpTask {
// 2nd synopsis: -R source_files... -> target.
if tgt_exists {
let basename = resolve_path::basename(src.as_bytes());
tgt = resolve_path::join_z_buf::<platform::Auto>(
buf3.as_mut_slice(),
&[tgt.as_bytes(), basename],
);
tgt = match shell_join_path(bun_sys::Tag::copyfile, &[tgt.as_bytes(), basename]) {
Ok(path) => path,
Err(e) => return Some(ShellErr::new_sys(&e)),
};
} else if self.operands == 2 {
// source_dir -> new_target_dir.
} else {
Expand Down Expand Up @@ -690,15 +681,15 @@ impl ShellCpTask {
));
}
let basename = resolve_path::basename(src.as_bytes());
tgt = resolve_path::join_z_buf::<platform::Auto>(
buf3.as_mut_slice(),
&[tgt.as_bytes(), basename],
);
tgt = match shell_join_path(bun_sys::Tag::copyfile, &[tgt.as_bytes(), basename]) {
Ok(path) => path,
Err(e) => return Some(ShellErr::new_sys(&e)),
};
_copying_many = true;
}

self.src_absolute = Some(src.as_bytes().to_vec());
self.tgt_absolute = Some(tgt.as_bytes().to_vec());
self.src_absolute = Some(src);
self.tgt_absolute = Some(tgt);

let args = crate::node::fs::args::Cp {
src: bun_jsc::node::PathLike::String(bun_ptr::cow_slice::CowSlice::init_unchecked(
Expand Down
31 changes: 31 additions & 0 deletions src/runtime/shell/interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2165,6 +2165,37 @@ pub(crate) fn shell_dup(fd: Fd) -> bun_sys::Result<Fd> {
}
}

/// Joins argv-sized `parts` into an owned, normalized path. The fs layers the
/// builtins hand their paths to copy them into `MAX_PATH_BYTES` buffers, so a
/// result that would not fit one is `ENAMETOOLONG` (tagged `syscall`, naming
/// the path) here instead.
Comment thread
robobun marked this conversation as resolved.
pub(crate) fn shell_join_path(
syscall: bun_sys::Tag,
parts: &[&[u8]],
) -> bun_sys::Result<bun_core::ZBox> {
let mut spill = Vec::new();
let joined =
bun_paths::resolve_path::join_spill::<bun_paths::platform::Auto>(&mut spill, parts);
if joined.len() >= bun_paths::MAX_PATH_BYTES {
return Err(bun_sys::Error::from_code(bun_sys::E::ENAMETOOLONG, syscall).with_path(joined));
}
Ok(bun_core::ZBox::from_bytes(joined))
}

/// A builtin's path `operand` made absolute against the shell's `cwd`, via
/// [`shell_join_path`].
Comment thread
robobun marked this conversation as resolved.
pub(crate) fn shell_resolve_operand(
syscall: bun_sys::Tag,
cwd: &[u8],
operand: &[u8],
) -> bun_sys::Result<bun_core::ZBox> {
if bun_paths::Platform::AUTO.is_absolute(operand) {
shell_join_path(syscall, &[operand])
} else {
shell_join_path(syscall, &[cwd, operand])
}
}

/// Windows-only: rewrite shell paths so POSIX-absolute `/foo` resolves onto
/// `dirfd`'s drive root, `/dev/null` maps to `NUL`, and relative paths are
/// joined against `dirfd`'s real path. Returns a NUL-terminated slice that
Expand Down
113 changes: 111 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,115 @@ describe.if(!builtinDisabled("cp"))("bunshell cp", async () => {
});
});

// The builtin resolved each operand (and `target/basename` when copying into a
// directory) into fixed-size path buffers without checking that the result fit,
// so an operand longer than the buffer aborted the whole process. Runs in a
// child process so the abort shows up as a failed assertion; on POSIX the cp
// builtin is experimental and must be enabled explicitly.
test("operands longer than the path buffers are reported as ENAMETOOLONG", async () => {
using dir = tempDir("cp-long-operands", {
"f": "source",
"target": {},
"cp-long-operands-fixture.ts": /* ts */ `
import { $ } from "bun";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";

$.nothrow();
const cwd = process.cwd();
// Longer than the path buffer on every platform (1024 bytes on macOS,
// 4096 on Linux, 98302 on Windows).
const over = Buffer.alloc(100_000, "a").toString();
// Applied in order; the deep directory below goes in front because its
// path starts with cwd.
const placeholders: [string, string][] = [[over, "<over>"], [cwd, "<cwd>"]];

async function cp(...args: string[]) {
const { exitCode, stderr } = await $\`cp \${args}\`.quiet();
let message = stderr.toString();
for (const [value, placeholder] of placeholders) message = message.replaceAll(value, placeholder);
return { exitCode, stderr: message };
}

const names: Record<string, string> = {};
const results: Record<string, unknown> = {};
results.longSource = await cp(over, "target");
results.longTarget = await cp("f", over);
results.longAbsoluteTarget = await cp("f", join(cwd, over));
results.longSourceAmongOthers = { ...(await cp("f", over, "target")), otherCopied: existsSync("target/f") };

// Copying into a directory appends the basename to the directory's path,
// which needs an existing directory just below the limit. Not reachable
// on Windows, where the buffer holds more than any directory path NT
// can address.
if (process.platform !== "win32") {
// A path of PATH_MAX - 1 bytes is the longest one the OS accepts.
const PATH_MAX = process.platform === "linux" ? 4096 : 1024;
// Nest directories until fewer than ~200 bytes remain below that, so
// the basenames straddling the limit are short enough to exist in the
// cwd (NAME_MAX is 255).
const segment = Buffer.alloc(200, "d").toString();
// Length of the name that makes \`dir/name\` exactly PATH_MAX - 1 bytes.
const atLimitNameLength = (dir: string) => PATH_MAX - 1 - (dir.length + 1);
let deep = join(cwd, segment);
while (atLimitNameLength(join(deep, segment)) >= 1) deep = join(deep, segment);
mkdirSync(deep, { recursive: true });
placeholders.unshift([deep, "<deep>"]);

const atLimitLength = atLimitNameLength(deep);
names.atLimit = Buffer.alloc(atLimitLength, "s").toString();
names.onePast = Buffer.alloc(atLimitLength + 1, "t").toString();
names.onePastDir = Buffer.alloc(atLimitLength + 1, "u").toString();
writeFileSync(names.atLimit, "at limit");
writeFileSync(names.onePast, "one past");
mkdirSync(names.onePastDir);

results.deepPathLengths = {
atLimit: join(deep, names.atLimit).length,
onePast: join(deep, names.onePast).length,
};
results.intoDeepDirAtLimit = {
...(await cp(names.atLimit, deep)),
copied: existsSync(join(deep, names.atLimit)),
};
results.intoDeepDirOnePast = await cp(names.onePast, deep);
results.recursiveIntoDeepDir = await cp("-R", names.onePastDir, deep);
}

console.log(JSON.stringify({ names, results }));
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "cp-long-operands-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("");
const { names, results } = JSON.parse(stdout);

const tooLong = (path: string) => ({ exitCode: 1, stderr: `cp: File name too long: ${p(path)}\n` });
const PATH_MAX = process.platform === "linux" ? 4096 : 1024;
expect(results).toEqual({
longSource: tooLong("<cwd>/<over>"),
longTarget: tooLong("<cwd>/<over>"),
longAbsoluteTarget: tooLong("<cwd>/<over>"),
longSourceAmongOthers: { ...tooLong("<cwd>/<over>"), otherCopied: true },
...(isWindows
? {}
: {
// The fixture placed deep/<name> exactly at the OS limit and one byte past it.
deepPathLengths: { atLimit: PATH_MAX - 1, onePast: PATH_MAX },
intoDeepDirAtLimit: { exitCode: 0, stderr: "", copied: true },
intoDeepDirOnePast: tooLong(`<deep>/${names.onePast}`),
recursiveIntoDeepDir: tooLong(`<deep>/${names.onePastDir}`),
}),
});
expect(exitCode).toBe(0);
});

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