diff --git a/src/bun.js/api/BunObject.zig b/src/bun.js/api/BunObject.zig index a85472f191..46d3421428 100644 --- a/src/bun.js/api/BunObject.zig +++ b/src/bun.js/api/BunObject.zig @@ -16,7 +16,6 @@ pub const BunObject = struct { pub const connect = toJSCallback(host_fn.wrapStaticMethod(api.Listener, "connect", false)); pub const createParsedShellScript = toJSCallback(bun.shell.ParsedShellScript.createParsedShellScript); pub const createShellInterpreter = toJSCallback(bun.shell.Interpreter.createShellInterpreter); - pub const traceShellScript = toJSCallback(bun.shell.TraceInterpreter.traceShellScript); pub const deflateSync = toJSCallback(JSZlib.deflateSync); pub const file = toJSCallback(WebCore.Blob.constructBunFile); pub const gunzipSync = toJSCallback(JSZlib.gunzipSync); @@ -158,7 +157,6 @@ pub const BunObject = struct { @export(&BunObject.connect, .{ .name = callbackName("connect") }); @export(&BunObject.createParsedShellScript, .{ .name = callbackName("createParsedShellScript") }); @export(&BunObject.createShellInterpreter, .{ .name = callbackName("createShellInterpreter") }); - @export(&BunObject.traceShellScript, .{ .name = callbackName("traceShellScript") }); @export(&BunObject.deflateSync, .{ .name = callbackName("deflateSync") }); @export(&BunObject.file, .{ .name = callbackName("file") }); @export(&BunObject.gunzipSync, .{ .name = callbackName("gunzipSync") }); diff --git a/src/bun.js/bindings/BunObject+exports.h b/src/bun.js/bindings/BunObject+exports.h index c366d7fb43..96f3cd8634 100644 --- a/src/bun.js/bindings/BunObject+exports.h +++ b/src/bun.js/bindings/BunObject+exports.h @@ -71,7 +71,6 @@ macro(spawn) \ macro(spawnSync) \ macro(stringWidth) \ - macro(traceShellScript) \ macro(udpSocket) \ macro(which) \ macro(write) \ diff --git a/src/bun.js/bindings/BunObject.cpp b/src/bun.js/bindings/BunObject.cpp index bd6a34802a..28f8c7da0e 100644 --- a/src/bun.js/bindings/BunObject.cpp +++ b/src/bun.js/bindings/BunObject.cpp @@ -354,14 +354,12 @@ static JSValue constructBunShell(VM& vm, JSObject* bunObject) auto* globalObject = jsCast(bunObject->globalObject()); JSFunction* createParsedShellScript = JSFunction::create(vm, bunObject->globalObject(), 2, "createParsedShellScript"_s, BunObject_callback_createParsedShellScript, ImplementationVisibility::Private, NoIntrinsic); JSFunction* createShellInterpreterFunction = JSFunction::create(vm, bunObject->globalObject(), 1, "createShellInterpreter"_s, BunObject_callback_createShellInterpreter, ImplementationVisibility::Private, NoIntrinsic); - JSFunction* traceShellScriptFunction = JSFunction::create(vm, bunObject->globalObject(), 1, "traceShellScript"_s, BunObject_callback_traceShellScript, ImplementationVisibility::Private, NoIntrinsic); JSC::JSFunction* createShellFn = JSC::JSFunction::create(vm, globalObject, shellCreateBunShellTemplateFunctionCodeGenerator(vm), globalObject); auto scope = DECLARE_THROW_SCOPE(vm); auto args = JSC::MarkedArgumentBuffer(); args.append(createShellInterpreterFunction); args.append(createParsedShellScript); - args.append(traceShellScriptFunction); JSC::JSValue shell = JSC::call(globalObject, createShellFn, args, "BunShell"_s); RETURN_IF_EXCEPTION(scope, {}); diff --git a/src/bun.js/bindings/webcore/JSWebSocket.cpp b/src/bun.js/bindings/webcore/JSWebSocket.cpp index 43b7c9d7d6..b26cc913d3 100644 --- a/src/bun.js/bindings/webcore/JSWebSocket.cpp +++ b/src/bun.js/bindings/webcore/JSWebSocket.cpp @@ -309,124 +309,6 @@ static inline JSC::EncodedJSValue constructJSWebSocket3(JSGlobalObject* lexicalG } } } - - // Parse agent option - extract proxy from agent.proxy if no explicit proxy - // This supports HttpsProxyAgent and similar agent libraries - if (proxyUrl.isNull() || proxyUrl.isEmpty()) { - auto agentValue = Bun::getOwnPropertyIfExists(globalObject, options, PropertyName(Identifier::fromString(vm, "agent"_s))); - RETURN_IF_EXCEPTION(throwScope, {}); - if (agentValue && !agentValue.isUndefinedOrNull() && agentValue.isObject()) { - if (JSC::JSObject* agentObj = agentValue.getObject()) { - // Get agent.proxy (can be URL object or string) - auto agentProxyValue = Bun::getOwnPropertyIfExists(globalObject, agentObj, PropertyName(Identifier::fromString(vm, "proxy"_s))); - RETURN_IF_EXCEPTION(throwScope, {}); - if (agentProxyValue && !agentProxyValue.isUndefinedOrNull()) { - if (agentProxyValue.isString()) { - proxyUrl = convert(*lexicalGlobalObject, agentProxyValue); - } else if (agentProxyValue.isObject()) { - // URL object - get .href property - if (JSC::JSObject* urlObj = agentProxyValue.getObject()) { - auto hrefValue = Bun::getOwnPropertyIfExists(globalObject, urlObj, PropertyName(Identifier::fromString(vm, "href"_s))); - RETURN_IF_EXCEPTION(throwScope, {}); - if (hrefValue && hrefValue.isString()) { - proxyUrl = convert(*lexicalGlobalObject, hrefValue); - } - } - } - RETURN_IF_EXCEPTION(throwScope, {}); - } - - // Get agent.proxyHeaders - auto proxyHeadersValue = Bun::getOwnPropertyIfExists(globalObject, agentObj, PropertyName(Identifier::fromString(vm, "proxyHeaders"_s))); - RETURN_IF_EXCEPTION(throwScope, {}); - if (proxyHeadersValue && !proxyHeadersValue.isUndefinedOrNull()) { - // If it's a function, call it - if (proxyHeadersValue.isCallable()) { - auto callData = JSC::getCallData(proxyHeadersValue); - proxyHeadersValue = JSC::call(lexicalGlobalObject, proxyHeadersValue, callData, agentObj, JSC::MarkedArgumentBuffer()); - RETURN_IF_EXCEPTION(throwScope, {}); - } - if (!proxyHeadersValue.isUndefinedOrNull()) { - // Check if it's already a Headers instance (like fetch does) - if (auto* jsHeaders = jsDynamicCast(proxyHeadersValue)) { - // Convert FetchHeaders to the Init variant - auto& headers = jsHeaders->wrapped(); - Vector> pairs; - auto iterator = headers.createIterator(false); - while (auto value = iterator.next()) { - pairs.append({ value->key, value->value }); - } - proxyHeadersInit = WTF::move(pairs); - } else { - // Fall back to IDL conversion for plain objects/arrays - proxyHeadersInit = convert>, IDLRecord>>(*lexicalGlobalObject, proxyHeadersValue); - RETURN_IF_EXCEPTION(throwScope, {}); - } - } - } - - // Get TLS options from agent.connectOpts or agent.options - // We build a filtered object with only supported TLS options (ca, cert, key, passphrase, rejectUnauthorized) - // to avoid passing invalid properties like ALPNProtocols to the SSL parser - if (rejectUnauthorized == -1 && !sslConfig) { - auto connectOptsValue = Bun::getOwnPropertyIfExists(globalObject, agentObj, PropertyName(Identifier::fromString(vm, "connectOpts"_s))); - RETURN_IF_EXCEPTION(throwScope, {}); - if (!connectOptsValue || connectOptsValue.isUndefinedOrNull()) { - connectOptsValue = Bun::getOwnPropertyIfExists(globalObject, agentObj, PropertyName(Identifier::fromString(vm, "options"_s))); - RETURN_IF_EXCEPTION(throwScope, {}); - } - if (connectOptsValue && !connectOptsValue.isUndefinedOrNull() && connectOptsValue.isObject()) { - if (JSC::JSObject* connectOptsObj = connectOptsValue.getObject()) { - // Extract rejectUnauthorized - auto rejectValue = Bun::getOwnPropertyIfExists(globalObject, connectOptsObj, PropertyName(Identifier::fromString(vm, "rejectUnauthorized"_s))); - RETURN_IF_EXCEPTION(throwScope, {}); - if (rejectValue && rejectValue.isBoolean()) { - rejectUnauthorized = rejectValue.asBoolean() ? 1 : 0; - } - - // Build filtered TLS options object with only supported properties - JSC::JSObject* filteredTlsOpts = JSC::constructEmptyObject(globalObject); - bool hasTlsOpts = false; - - auto caValue = Bun::getOwnPropertyIfExists(globalObject, connectOptsObj, PropertyName(Identifier::fromString(vm, "ca"_s))); - RETURN_IF_EXCEPTION(throwScope, {}); - if (caValue && !caValue.isUndefinedOrNull()) { - filteredTlsOpts->putDirect(vm, Identifier::fromString(vm, "ca"_s), caValue); - hasTlsOpts = true; - } - - auto certValue = Bun::getOwnPropertyIfExists(globalObject, connectOptsObj, PropertyName(Identifier::fromString(vm, "cert"_s))); - RETURN_IF_EXCEPTION(throwScope, {}); - if (certValue && !certValue.isUndefinedOrNull()) { - filteredTlsOpts->putDirect(vm, Identifier::fromString(vm, "cert"_s), certValue); - hasTlsOpts = true; - } - - auto keyValue = Bun::getOwnPropertyIfExists(globalObject, connectOptsObj, PropertyName(Identifier::fromString(vm, "key"_s))); - RETURN_IF_EXCEPTION(throwScope, {}); - if (keyValue && !keyValue.isUndefinedOrNull()) { - filteredTlsOpts->putDirect(vm, Identifier::fromString(vm, "key"_s), keyValue); - hasTlsOpts = true; - } - - auto passphraseValue = Bun::getOwnPropertyIfExists(globalObject, connectOptsObj, PropertyName(Identifier::fromString(vm, "passphrase"_s))); - RETURN_IF_EXCEPTION(throwScope, {}); - if (passphraseValue && !passphraseValue.isUndefinedOrNull()) { - filteredTlsOpts->putDirect(vm, Identifier::fromString(vm, "passphrase"_s), passphraseValue); - hasTlsOpts = true; - } - - // Parse the filtered TLS options - if (hasTlsOpts) { - sslConfig = Bun__WebSocket__parseSSLConfig(globalObject, JSValue::encode(filteredTlsOpts)); - RETURN_IF_EXCEPTION(throwScope, {}); - } - } - } - } - } - } - } } auto object = (rejectUnauthorized == -1) diff --git a/src/cli.zig b/src/cli.zig index dc27a8af17..e2a62330cc 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -464,6 +464,7 @@ pub const Command = struct { compile_autoload_bunfig: bool = true, compile_autoload_tsconfig: bool = false, compile_autoload_package_json: bool = false, + compile_executable_path: ?[]const u8 = null, windows: options.WindowsOptions = .{}, }; diff --git a/src/cli/Arguments.zig b/src/cli/Arguments.zig index df210067ae..b8b3033d9e 100644 --- a/src/cli/Arguments.zig +++ b/src/cli/Arguments.zig @@ -158,6 +158,7 @@ pub const build_only_params = [_]ParamType{ clap.parseParam("--no-compile-autoload-tsconfig Disable autoloading of tsconfig.json at runtime in standalone executable") catch unreachable, clap.parseParam("--compile-autoload-package-json Enable autoloading of package.json at runtime in standalone executable (default: false)") catch unreachable, clap.parseParam("--no-compile-autoload-package-json Disable autoloading of package.json at runtime in standalone executable") catch unreachable, + clap.parseParam("--compile-executable-path Path to a Bun executable to use for cross-compilation instead of downloading") catch unreachable, clap.parseParam("--bytecode Use a bytecode cache") catch unreachable, clap.parseParam("--watch Automatically restart the process on file change") catch unreachable, clap.parseParam("--no-clear-screen Disable clearing the terminal screen on reload when --watch is enabled") catch unreachable, @@ -1106,6 +1107,14 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C } } + if (args.option("--compile-executable-path")) |path| { + if (!ctx.bundler_options.compile) { + Output.errGeneric("--compile-executable-path requires --compile", .{}); + Global.crash(); + } + ctx.bundler_options.compile_executable_path = path; + } + if (args.flag("--windows-hide-console")) { // --windows-hide-console technically doesnt depend on WinAPI, but since since --windows-icon // does, all of these customization options have been gated to windows-only diff --git a/src/cli/build_command.zig b/src/cli/build_command.zig index 91ec5b2e59..73e31338d3 100644 --- a/src/cli/build_command.zig +++ b/src/cli/build_command.zig @@ -468,7 +468,7 @@ pub const BuildCommand = struct { this_transpiler.options.output_format, ctx.bundler_options.windows, ctx.bundler_options.compile_exec_argv orelse "", - null, + ctx.bundler_options.compile_executable_path, .{ .disable_default_env_files = !ctx.bundler_options.compile_autoload_dotenv, .disable_autoload_bunfig = !ctx.bundler_options.compile_autoload_bunfig, diff --git a/src/js/builtins/shell.ts b/src/js/builtins/shell.ts index 142eecccc2..40f3e7d3a0 100644 --- a/src/js/builtins/shell.ts +++ b/src/js/builtins/shell.ts @@ -1,35 +1,4 @@ -// Note: ShellTraceFlags interface documents the permission flag values returned -// by $.trace operations. These are intentionally not exported as runtime values -// to keep the trace API simple - users compare against numeric constants directly. -// The values mirror standard Unix open(2) and access(2) flags. - -interface ShellTraceOperation { - /** Permission flags (octal integer, can be combined with |) */ - flags: number; - /** Working directory at time of operation */ - cwd: string; - /** Absolute path that would be accessed (for file/execute operations) */ - path?: string; - /** Command name (for execute operations) */ - command?: string; - /** Accumulated environment variables at this point in execution */ - env?: Record; - /** Which standard stream is being redirected: "stdin", "stdout", or "stderr" */ - stream?: "stdin" | "stdout" | "stderr"; - /** Command arguments for external commands (excluding command name) */ - args?: string[]; - /** True if operation contains non-statically-analyzable values (command substitution, $1, etc.) */ - dynamic?: true; -} - -interface ShellTraceResult { - operations: ShellTraceOperation[]; - cwd: string; - success: boolean; - error: string | null; -} - -export function createBunShellTemplateFunction(createShellInterpreter_, createParsedShellScript_, traceShellScript_) { +export function createBunShellTemplateFunction(createShellInterpreter_, createParsedShellScript_) { const createShellInterpreter = createShellInterpreter_ as ( resolve: (code: number, stdout: Buffer, stderr: Buffer) => void, reject: (code: number, stdout: Buffer, stderr: Buffer) => void, @@ -39,7 +8,6 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa raw: string, args: string[], ) => $ZigGeneratedClasses.ParsedShellScript; - const traceShellScript = traceShellScript_ as (args: $ZigGeneratedClasses.ParsedShellScript) => ShellTraceResult; function lazyBufferToHumanReadableString(this: Buffer) { return this.toString(); @@ -380,22 +348,6 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa BunShell[envSymbol] = defaultEnv; BunShell[throwsSymbol] = true; - // Trace function - analyzes shell script without running it - function trace(first, ...rest): ShellTraceResult { - if (first?.raw === undefined) - throw new Error("Please use '$.trace' as a tagged template function: $.trace`cmd arg1 arg2`"); - const parsed_shell_script = createParsedShellScript(first.raw, rest); - - const cwd = BunShell[cwdSymbol]; - const env = BunShell[envSymbol]; - - // cwd must be set before env or else it will be injected into env as "PWD=/" - if (cwd) parsed_shell_script.setCwd(cwd); - if (env) parsed_shell_script.setEnv(env); - - return traceShellScript(parsed_shell_script); - } - Object.defineProperties(BunShell, { Shell: { value: Shell, @@ -409,10 +361,6 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa value: ShellError, enumerable: true, }, - trace: { - value: trace, - enumerable: true, - }, }); return BunShell; diff --git a/src/js/thirdparty/ws.js b/src/js/thirdparty/ws.js index 21620f308e..058366a1b8 100644 --- a/src/js/thirdparty/ws.js +++ b/src/js/thirdparty/ws.js @@ -15,6 +15,64 @@ const kBunInternals = Symbol.for("::bunternal::"); const readyStates = ["CONNECTING", "OPEN", "CLOSING", "CLOSED"]; const encoder = new TextEncoder(); + +/** + * Extracts TLS and proxy options from an agent object. + * @param {Object} agent The agent object to extract options from + * @returns {{ tls: Object|null, proxy: string|Object|null }} + */ +function extractAgentOptions(agent) { + const connectOpts = agent?.connectOpts || agent?.options; + let tls = null; + let proxy = null; + + if ($isObject(connectOpts)) { + // Build TLS options + const newTlsOptions = {}; + let hasTlsOptions = false; + + if (connectOpts.rejectUnauthorized !== undefined) { + newTlsOptions.rejectUnauthorized = connectOpts.rejectUnauthorized; + hasTlsOptions = true; + } + if (connectOpts.ca) { + newTlsOptions.ca = connectOpts.ca; + hasTlsOptions = true; + } + if (connectOpts.cert) { + newTlsOptions.cert = connectOpts.cert; + hasTlsOptions = true; + } + if (connectOpts.key) { + newTlsOptions.key = connectOpts.key; + hasTlsOptions = true; + } + if (connectOpts.passphrase) { + newTlsOptions.passphrase = connectOpts.passphrase; + hasTlsOptions = true; + } + + if (hasTlsOptions) { + tls = newTlsOptions; + } + } + + // Build proxy - check connectOpts.proxy first, then agent.proxy + const agentProxy = connectOpts?.proxy || agent?.proxy; + if (agentProxy) { + const proxyUrl = agentProxy?.href || agentProxy; + // Get proxy headers from agent.proxyHeaders + if (agent?.proxyHeaders) { + const proxyHeaders = $isCallable(agent.proxyHeaders) ? agent.proxyHeaders.$call(agent) : agent.proxyHeaders; + proxy = { url: proxyUrl, headers: proxyHeaders }; + } else { + proxy = proxyUrl; + } + } + + return { tls, proxy }; +} + const eventIds = { open: 1, close: 2, @@ -101,48 +159,12 @@ class BunWebSocket extends EventEmitter { // Extract from agent if provided (like HttpsProxyAgent) agent = options?.agent; if ($isObject(agent)) { - // Get proxy from agent.proxy (can be URL object or string) - if (!proxy && agent.proxy) { - const agentProxy = agent.proxy?.href || agent.proxy; - // Get proxy headers from agent.proxyHeaders - if (agent.proxyHeaders) { - const proxyHeaders = $isCallable(agent.proxyHeaders) ? agent.proxyHeaders.$call(agent) : agent.proxyHeaders; - proxy = { url: agentProxy, headers: proxyHeaders }; - } else { - proxy = agentProxy; - } + const agentOpts = extractAgentOptions(agent); + if (!proxy && agentOpts.proxy) { + proxy = agentOpts.proxy; } - // Get TLS options from agent.connectOpts or agent.options - // Only extract specific TLS options we support (not ALPNProtocols, etc.) - if (!tlsOptions) { - const agentOpts = agent.connectOpts || agent.options; - if ($isObject(agentOpts)) { - const newTlsOptions = {}; - let hasTlsOptions = false; - if (agentOpts.rejectUnauthorized !== undefined) { - newTlsOptions.rejectUnauthorized = agentOpts.rejectUnauthorized; - hasTlsOptions = true; - } - if (agentOpts.ca) { - newTlsOptions.ca = agentOpts.ca; - hasTlsOptions = true; - } - if (agentOpts.cert) { - newTlsOptions.cert = agentOpts.cert; - hasTlsOptions = true; - } - if (agentOpts.key) { - newTlsOptions.key = agentOpts.key; - hasTlsOptions = true; - } - if (agentOpts.passphrase) { - newTlsOptions.passphrase = agentOpts.passphrase; - hasTlsOptions = true; - } - if (hasTlsOptions) { - tlsOptions = newTlsOptions; - } - } + if (!tlsOptions && agentOpts.tls) { + tlsOptions = agentOpts.tls; } } } @@ -184,7 +206,7 @@ class BunWebSocket extends EventEmitter { end: () => { if (!didCallEnd) { didCallEnd = true; - this.#createWebSocket(url, protocols, headers, method, proxy, tlsOptions, agent); + this.#createWebSocket(url, protocols, headers, method, proxy, tlsOptions); } }, write() {}, @@ -213,23 +235,22 @@ class BunWebSocket extends EventEmitter { EventEmitter.$call(nodeHttpClientRequestSimulated); finishRequest(nodeHttpClientRequestSimulated); if (!didCallEnd) { - this.#createWebSocket(url, protocols, headers, method, proxy, tlsOptions, agent); + this.#createWebSocket(url, protocols, headers, method, proxy, tlsOptions); } return; } - this.#createWebSocket(url, protocols, headers, method, proxy, tlsOptions, agent); + this.#createWebSocket(url, protocols, headers, method, proxy, tlsOptions); } - #createWebSocket(url, protocols, headers, method, proxy, tls, agent) { + #createWebSocket(url, protocols, headers, method, proxy, tls) { let wsOptions; - if (headers || proxy || tls || agent) { + if (headers || proxy || tls) { wsOptions = { protocols }; if (headers) wsOptions.headers = headers; if (method) wsOptions.method = method; if (proxy) wsOptions.proxy = proxy; if (tls) wsOptions.tls = tls; - if (agent) wsOptions.agent = agent; } else { wsOptions = protocols; } diff --git a/src/libarchive/libarchive.zig b/src/libarchive/libarchive.zig index a9ffd6c6d6..72bc5d5b96 100644 --- a/src/libarchive/libarchive.zig +++ b/src/libarchive/libarchive.zig @@ -170,6 +170,41 @@ pub const BufferReadStream = struct { // } }; +/// Validates that a symlink target doesn't escape the extraction directory. +/// Returns true if the symlink is safe (target stays within extraction dir), +/// false if it would escape (e.g., via ../ traversal or absolute path). +/// +/// The check works by resolving the symlink target relative to the symlink's +/// directory location using a fake root, then checking if the result stays +/// within that fake root. +fn isSymlinkTargetSafe(symlink_path: []const u8, link_target: [:0]const u8, symlink_join_buf: *?*bun.PathBuffer) bool { + // Absolute symlink targets are never safe - they could point anywhere + if (link_target.len > 0 and link_target[0] == '/') { + return false; + } + + // Get the directory containing the symlink + const symlink_dir = std.fs.path.dirname(symlink_path) orelse ""; + + // Use a fake root to resolve the path and check if it escapes + const fake_root = "/packages/"; + + const join_buf = symlink_join_buf.* orelse join_buf: { + symlink_join_buf.* = bun.path_buffer_pool.get(); + break :join_buf symlink_join_buf.*.?; + }; + + const resolved = bun.path.joinAbsStringBuf( + fake_root, + join_buf, + &.{ symlink_dir, link_target }, + .posix, + ); + + // If the resolved path doesn't start with our fake root, it escaped + return strings.hasPrefix(resolved, fake_root); +} + pub const Archiver = struct { // impl: *lib.archive = undefined, // buf: []const u8 = undefined, @@ -315,6 +350,9 @@ pub const Archiver = struct { var count: u32 = 0; const dir_fd = dir.fd; + var symlink_join_buf: ?*bun.PathBuffer = null; + defer if (symlink_join_buf) |join_buf| bun.path_buffer_pool.put(join_buf); + var normalized_buf: bun.OSPathBuffer = undefined; var use_pwrite = Environment.isPosix; var use_lseek = true; @@ -435,6 +473,19 @@ pub const Archiver = struct { .sym_link => { const link_target = entry.symlink(); if (Environment.isPosix) { + // Validate that the symlink target doesn't escape the extraction directory. + // This prevents path traversal attacks where a malicious tarball creates a symlink + // pointing outside (e.g., to /tmp), then writes files through that symlink. + if (!isSymlinkTargetSafe(path_slice, link_target, &symlink_join_buf)) { + // Skip symlinks that would escape the extraction directory + if (options.log) { + Output.warn("Skipping symlink with unsafe target: {f} -> {s}\n", .{ + bun.fmt.fmtOSPath(path_slice, .{}), + link_target, + }); + } + continue; + } bun.sys.symlinkat(link_target, .fromNative(dir_fd), path).unwrap() catch |err| brk: { switch (err) { error.EPERM, error.ENOENT => { diff --git a/src/macho.zig b/src/macho.zig index 9e8060b6e2..50d30abacd 100644 --- a/src/macho.zig +++ b/src/macho.zig @@ -77,8 +77,8 @@ pub const MachoFile = struct { found_bun = true; original_fileoff = sect.offset; original_vmaddr = sect.addr; - original_data_end = original_fileoff + blob_alignment; - original_segsize = sect.size; + original_data_end = command.fileoff + command.filesize; + original_segsize = command.filesize; self.segment = command; self.section = sect.*; diff --git a/src/shell/TraceInterpreter.zig b/src/shell/TraceInterpreter.zig deleted file mode 100644 index 275144a4a7..0000000000 --- a/src/shell/TraceInterpreter.zig +++ /dev/null @@ -1,1252 +0,0 @@ -//! The trace interpreter simulates shell execution without actually running commands. -//! It walks the AST and collects information about what permissions would be needed -//! and what file paths would be accessed. -//! -//! This is used for a permission system where users can inspect what a shell command -//! would do before actually executing it. - -/// Unix-style permission flags using standard octal values -/// These mirror the constants used by open(2), chmod(2), and access(2) -pub const Permission = struct { - /// Standard Unix permission bits (octal) - pub const O_RDONLY: u32 = 0o0; // Read only - pub const O_WRONLY: u32 = 0o1; // Write only - pub const O_RDWR: u32 = 0o2; // Read and write - pub const O_CREAT: u32 = 0o100; // Create file if it doesn't exist - pub const O_EXCL: u32 = 0o200; // Fail if file exists (with O_CREAT) - pub const O_TRUNC: u32 = 0o1000; // Truncate file to zero length - pub const O_APPEND: u32 = 0o2000; // Append to file - - /// Extended operation flags (using higher bits to avoid conflicts) - pub const X_OK: u32 = 0o100000; // Execute permission / run command - pub const DELETE: u32 = 0o200000; // Delete file or directory - pub const MKDIR: u32 = 0o400000; // Create directory - pub const CHDIR: u32 = 0o1000000; // Change directory - pub const ENV: u32 = 0o2000000; // Modify environment - - /// Convenience combinations - pub const READ: u32 = O_RDONLY; - pub const WRITE: u32 = O_WRONLY; - pub const READ_WRITE: u32 = O_RDWR; - pub const CREATE: u32 = O_CREAT | O_WRONLY; - pub const CREATE_TRUNC: u32 = O_CREAT | O_TRUNC | O_WRONLY; - pub const APPEND: u32 = O_APPEND | O_WRONLY; - pub const EXECUTE: u32 = X_OK; -}; - -/// Standard stream identifiers for redirections -pub const Stream = enum(u8) { - none = 0, - stdin = 1, - stdout = 2, - stderr = 3, - - pub fn toJS(this: Stream, globalThis: *JSGlobalObject) JSValue { - return if (this == .none) .null else bun.String.static(@tagName(this)).toJS(globalThis); - } -}; - -/// A snapshot of environment variables at a point in execution -pub const EnvSnapshot = struct { - /// Map of variable name -> value - vars: bun.StringHashMapUnmanaged([]const u8), - /// Allocator used for this snapshot - allocator: Allocator, - - pub fn init(allocator: Allocator) EnvSnapshot { - return .{ - .vars = .{}, - .allocator = allocator, - }; - } - - pub fn clone(this: *const EnvSnapshot, allocator: Allocator) EnvSnapshot { - var new_vars: bun.StringHashMapUnmanaged([]const u8) = .{}; - var iter = this.vars.iterator(); - while (iter.next()) |entry| { - const key_copy = allocator.dupe(u8, entry.key_ptr.*) catch continue; - const val_copy = allocator.dupe(u8, entry.value_ptr.*) catch { - allocator.free(key_copy); - continue; - }; - new_vars.put(allocator, key_copy, val_copy) catch { - allocator.free(key_copy); - allocator.free(val_copy); - continue; - }; - } - return .{ - .vars = new_vars, - .allocator = allocator, - }; - } - - pub fn deinit(this: *EnvSnapshot) void { - var iter = this.vars.iterator(); - while (iter.next()) |entry| { - this.allocator.free(entry.key_ptr.*); - this.allocator.free(entry.value_ptr.*); - } - this.vars.deinit(this.allocator); - } - - pub fn toJS(this: *const EnvSnapshot, globalThis: *JSGlobalObject) JSValue { - var obj = jsc.JSValue.createEmptyObject(globalThis, @intCast(this.vars.count())); - var iter = this.vars.iterator(); - while (iter.next()) |entry| { - obj.put( - globalThis, - bun.String.init(entry.key_ptr.*), - bun.String.init(entry.value_ptr.*).toJS(globalThis), - ); - } - return obj; - } -}; - -/// Represents a single traced operation -pub const TracedOperation = struct { - /// The permission flags required (octal, like open/chmod) - flags: u32, - /// Absolute path that would be accessed (null for non-path operations) - path: ?[]const u8, - /// The command name (for execute operations) - command: ?[]const u8, - /// Working directory at time of operation - cwd: []const u8, - /// Snapshot of environment variables at this point - env: EnvSnapshot, - /// Which standard stream is being redirected (if any) - stream: Stream, - /// Command arguments (for execute operations, excluding the command name itself) - args: ?[]const []const u8, - /// Whether this operation contains dynamic/non-statically-analyzable values - dynamic: bool, - - pub fn deinit(this: *TracedOperation, allocator: Allocator) void { - if (this.path) |p| allocator.free(p); - if (this.command) |c| allocator.free(c); - allocator.free(this.cwd); - this.env.deinit(); - if (this.args) |args| { - for (args) |arg| allocator.free(arg); - allocator.free(args); - } - } - - pub fn toJS(this: *const TracedOperation, globalThis: *JSGlobalObject) bun.JSError!JSValue { - var obj = jsc.JSValue.createEmptyObject(globalThis, 6); - - // Return flags as integer (octal value) - obj.put( - globalThis, - bun.String.static("flags"), - jsc.JSValue.jsNumber(@as(i32, @intCast(this.flags))), - ); - - // cwd is always present - obj.put( - globalThis, - bun.String.static("cwd"), - bun.String.init(this.cwd).toJS(globalThis), - ); - - // Only set optional properties if they have values (otherwise undefined) - if (this.path) |p| { - obj.put( - globalThis, - bun.String.static("path"), - bun.String.init(p).toJS(globalThis), - ); - } - - if (this.command) |c| { - obj.put( - globalThis, - bun.String.static("command"), - bun.String.init(c).toJS(globalThis), - ); - } - - // Environment snapshot - only include if there are env vars - if (this.env.vars.count() > 0) { - obj.put( - globalThis, - bun.String.static("env"), - this.env.toJS(globalThis), - ); - } - - // Stream redirection (stdin, stdout, stderr) - only set if not none - if (this.stream != .none) { - obj.put( - globalThis, - bun.String.static("stream"), - this.stream.toJS(globalThis), - ); - } - - // Command arguments (for execute operations) - if (this.args) |args| { - const arr = try jsc.JSValue.createEmptyArray(globalThis, args.len); - for (args, 0..) |arg, i| { - try arr.putIndex(globalThis, @intCast(i), bun.String.init(arg).toJS(globalThis)); - } - obj.put(globalThis, bun.String.static("args"), arr); - } - - // Dynamic flag - only set if true - if (this.dynamic) { - obj.put( - globalThis, - bun.String.static("dynamic"), - .true, - ); - } - - return obj; - } -}; - -/// Result of tracing a shell script -pub const TraceResult = struct { - /// All traced operations - operations: std.array_list.Managed(TracedOperation), - /// The working directory - cwd: []const u8, - /// Whether tracing was successful - success: bool, - /// Error message if tracing failed - error_message: ?[]const u8, - /// Allocator used for this result - allocator: Allocator, - - pub fn init(allocator: Allocator, cwd: []const u8) TraceResult { - return .{ - .operations = std.array_list.Managed(TracedOperation).init(allocator), - .cwd = bun.handleOom(allocator.dupe(u8, cwd)), - .success = true, - .error_message = null, - .allocator = allocator, - }; - } - - pub fn deinit(this: *TraceResult) void { - for (this.operations.items) |*op| { - op.deinit(this.allocator); - } - this.operations.deinit(); - this.allocator.free(this.cwd); - if (this.error_message) |msg| { - this.allocator.free(msg); - } - } - - pub fn addOperation(this: *TraceResult, op: TracedOperation) void { - bun.handleOom(this.operations.append(op)); - } - - pub fn setError(this: *TraceResult, msg: []const u8) void { - this.success = false; - this.error_message = bun.handleOom(this.allocator.dupe(u8, msg)); - } - - pub fn toJS(this: *const TraceResult, globalThis: *JSGlobalObject) bun.JSError!JSValue { - var result_obj = jsc.JSValue.createEmptyObject(globalThis, 4); - - // Create operations array - const ops_array = try jsc.JSValue.createEmptyArray(globalThis, this.operations.items.len); - for (this.operations.items, 0..) |*op, i| { - const op_js = try op.toJS(globalThis); - try ops_array.putIndex(globalThis, @intCast(i), op_js); - } - result_obj.put(globalThis, bun.String.static("operations"), ops_array); - - // Add cwd - result_obj.put( - globalThis, - bun.String.static("cwd"), - bun.String.init(this.cwd).toJS(globalThis), - ); - - // Add success - result_obj.put( - globalThis, - bun.String.static("success"), - jsc.JSValue.jsBoolean(this.success), - ); - - // Add error if present - if (this.error_message) |msg| { - result_obj.put( - globalThis, - bun.String.static("error"), - bun.String.init(msg).toJS(globalThis), - ); - } else { - result_obj.put(globalThis, bun.String.static("error"), .null); - } - - return result_obj; - } -}; - -/// TraceContext holds state during trace interpretation. -/// Note: The allocator is stored because it's needed throughout traversal for allocating -/// strings, paths, etc. The result is a pointer because it's created before the context -/// and operations are added to it during traversal. -pub const TraceContext = struct { - /// Allocator used for all allocations during tracing - allocator: Allocator, - /// Output: traced operations are added here during traversal - result: *TraceResult, - /// Current working directory during trace (unmanaged, uses this.allocator) - cwd: std.ArrayListUnmanaged(u8), - /// Shell environment for variable expansion - shell_env: EnvMap, - /// Exported environment (for subprocess) - borrowed pointer, not owned (do not deinit) - export_env: ?*EnvMap, - /// Whether export_env is owned by us (should be freed on deinit) - owns_export_env: bool, - /// Accumulated traced environment variables (snapshot for each operation) - traced_env: bun.StringHashMapUnmanaged([]const u8), - /// Whether the current operation has dynamic (non-statically-analyzable) values - current_dynamic: bool, - /// JS objects from template literal interpolation, indexed by position - jsobjs: []JSValue, - globalThis: *JSGlobalObject, - - pub fn init( - allocator: Allocator, - result: *TraceResult, - cwd: []const u8, - export_env: ?*EnvMap, - jsobjs: []JSValue, - globalThis: *JSGlobalObject, - ) TraceContext { - var ctx = TraceContext{ - .allocator = allocator, - .result = result, - .cwd = .{}, - .shell_env = EnvMap.init(allocator), - .export_env = export_env, - .owns_export_env = false, // We borrow it, don't own it - .traced_env = .{}, - .current_dynamic = false, - .jsobjs = jsobjs, - .globalThis = globalThis, - }; - bun.handleOom(ctx.cwd.appendSlice(allocator, cwd)); - return ctx; - } - - pub fn deinit(this: *TraceContext) void { - this.cwd.deinit(this.allocator); - this.shell_env.deinit(); - // export_env is borrowed, not owned - never free it - // Free traced_env - var iter = this.traced_env.iterator(); - while (iter.next()) |entry| { - this.allocator.free(entry.key_ptr.*); - this.allocator.free(entry.value_ptr.*); - } - this.traced_env.deinit(this.allocator); - } - - /// Set an environment variable in the traced env - pub fn setTracedEnv(this: *TraceContext, name: []const u8, value: []const u8) void { - // If key already exists, free the old value - if (this.traced_env.get(name)) |old_val| { - this.allocator.free(old_val); - // Update in place - const key = this.traced_env.getKey(name).?; - this.traced_env.put(this.allocator, key, this.allocator.dupe(u8, value) catch return) catch return; - } else { - // New key - const key_copy = this.allocator.dupe(u8, name) catch return; - const val_copy = this.allocator.dupe(u8, value) catch { - this.allocator.free(key_copy); - return; - }; - this.traced_env.put(this.allocator, key_copy, val_copy) catch { - this.allocator.free(key_copy); - this.allocator.free(val_copy); - return; - }; - } - } - - /// Create a snapshot of the current traced environment - pub fn snapshotEnv(this: *TraceContext) EnvSnapshot { - var snapshot = EnvSnapshot.init(this.allocator); - var iter = this.traced_env.iterator(); - while (iter.next()) |entry| { - const key_copy = this.allocator.dupe(u8, entry.key_ptr.*) catch continue; - const val_copy = this.allocator.dupe(u8, entry.value_ptr.*) catch { - this.allocator.free(key_copy); - continue; - }; - snapshot.vars.put(this.allocator, key_copy, val_copy) catch { - this.allocator.free(key_copy); - this.allocator.free(val_copy); - continue; - }; - } - return snapshot; - } - - pub fn cwdSlice(this: *const TraceContext) []const u8 { - return this.cwd.items; - } - - pub fn resolvePath(this: *TraceContext, path: []const u8) []const u8 { - if (ResolvePath.Platform.auto.isAbsolute(path)) { - return bun.handleOom(this.allocator.dupe(u8, path)); - } - // Join with cwd - const parts: []const []const u8 = &.{ this.cwdSlice(), path }; - const joined = ResolvePath.joinZ(parts, .auto); - return bun.handleOom(this.allocator.dupe(u8, joined[0..joined.len])); - } - - pub fn addOperation(this: *TraceContext, flags: u32, path: ?[]const u8, command: ?[]const u8) void { - this.addOperationFull(flags, path, command, .none, null); - } - - pub fn addOperationWithStream(this: *TraceContext, flags: u32, path: ?[]const u8, command: ?[]const u8, stream: Stream) void { - this.addOperationFull(flags, path, command, stream, null); - } - - pub fn addOperationWithArgs(this: *TraceContext, flags: u32, path: ?[]const u8, command: ?[]const u8, args: ?[]const []const u8) void { - this.addOperationFull(flags, path, command, .none, args); - } - - pub fn addOperationFull(this: *TraceContext, flags: u32, path: ?[]const u8, command: ?[]const u8, stream: Stream, args: ?[]const []const u8) void { - const resolved_path = if (path) |p| this.resolvePath(p) else null; - - // Duplicate args array - const duped_args: ?[]const []const u8 = if (args) |a| blk: { - const arr = this.allocator.alloc([]const u8, a.len) catch break :blk null; - for (a, 0..) |arg, i| { - arr[i] = this.allocator.dupe(u8, arg) catch { - // Free already allocated - for (arr[0..i]) |prev| this.allocator.free(prev); - this.allocator.free(arr); - break :blk null; - }; - } - break :blk arr; - } else null; - - // Snapshot the current environment - const env_snapshot = this.snapshotEnv(); - - // Capture dynamic flag and reset it - const is_dynamic = this.current_dynamic; - this.current_dynamic = false; - - this.result.addOperation(.{ - .flags = flags, - .path = resolved_path, - .command = if (command) |c| bun.handleOom(this.allocator.dupe(u8, c)) else null, - .cwd = bun.handleOom(this.allocator.dupe(u8, this.cwdSlice())), - .env = env_snapshot, - .stream = stream, - .args = duped_args, - .dynamic = is_dynamic, - }); - } - - pub fn getVar(this: *TraceContext, name: []const u8) ?[]const u8 { - const key = EnvStr.initSlice(name); - if (this.shell_env.get(key)) |v| { - return v.slice(); - } - if (this.export_env) |env| { - if (env.get(key)) |v| { - return v.slice(); - } - } - return null; - } - - pub fn changeCwd(this: *TraceContext, new_cwd: []const u8) void { - // Just update the context's cwd - don't add an operation - // (the caller is responsible for adding the CHDIR operation if needed) - if (ResolvePath.Platform.auto.isAbsolute(new_cwd)) { - this.cwd.clearRetainingCapacity(); - bun.handleOom(this.cwd.appendSlice(this.allocator, new_cwd)); - } else { - // Join with current cwd and normalize (handles .. and .) - const parts: []const []const u8 = &.{ this.cwdSlice(), new_cwd }; - const joined = ResolvePath.joinZ(parts, .auto); - this.cwd.clearRetainingCapacity(); - bun.handleOom(this.cwd.appendSlice(this.allocator, joined[0..joined.len])); - } - } -}; - -// ============================================================================= -// AST Walking Functions -// ============================================================================= - -pub fn traceScript(ctx: *TraceContext, script: *const ast.Script) void { - for (script.stmts) |*stmt| { - traceStmt(ctx, stmt); - } -} - -fn traceStmt(ctx: *TraceContext, stmt: *const ast.Stmt) void { - // Stmt is a struct with exprs field, not a union - for (stmt.exprs) |*expr| { - traceExpr(ctx, expr); - } -} - -fn traceExpr(ctx: *TraceContext, expr: *const ast.Expr) void { - switch (expr.*) { - .cmd => |cmd| traceCmd(ctx, cmd), - .assign => |assigns| { - for (assigns) |*assign| { - traceAssign(ctx, assign); - } - }, - .binary => |binary| traceBinary(ctx, binary), - .pipeline => |pipeline| tracePipeline(ctx, pipeline), - .subshell => |subshell| traceSubshell(ctx, &subshell.script), - .@"if" => |if_clause| traceIfClause(ctx, if_clause), - .condexpr => |condexpr| traceCondExpr(ctx, condexpr), - .async => |async_expr| traceExpr(ctx, async_expr), - } -} - -fn traceSubshell(ctx: *TraceContext, script: *const ast.Script) void { - // Save current cwd - subshell changes shouldn't affect parent - const saved_cwd = bun.handleOom(ctx.allocator.dupe(u8, ctx.cwdSlice())); - defer ctx.allocator.free(saved_cwd); - - traceScript(ctx, script); - - // Restore cwd after subshell - ctx.cwd.clearRetainingCapacity(); - bun.handleOom(ctx.cwd.appendSlice(ctx.allocator, saved_cwd)); -} - -fn traceAssign(ctx: *TraceContext, assign: *const ast.Assign) void { - // Expand the value - const value = expandAtom(ctx, &assign.value); - defer ctx.allocator.free(value); - - // Set the env var in traced env - ctx.setTracedEnv(assign.label, value); - - // Add an ENV operation (the env snapshot will include this new var) - ctx.addOperation(Permission.ENV, null, null); -} - -fn traceBinary(ctx: *TraceContext, binary: *const ast.Binary) void { - traceExpr(ctx, &binary.left); - traceExpr(ctx, &binary.right); -} - -fn tracePipeline(ctx: *TraceContext, pipeline: *const ast.Pipeline) void { - for (pipeline.items) |*item| { - tracePipelineItem(ctx, item); - } -} - -fn tracePipelineItem(ctx: *TraceContext, item: *const ast.PipelineItem) void { - switch (item.*) { - .cmd => |cmd| traceCmd(ctx, cmd), - .assigns => |assigns| { - for (assigns) |*assign| { - traceAssign(ctx, assign); - } - }, - .subshell => |subshell| traceSubshell(ctx, &subshell.script), - .@"if" => |if_clause| traceIfClause(ctx, if_clause), - .condexpr => |condexpr| traceCondExpr(ctx, condexpr), - } -} - -fn traceIfClause(ctx: *TraceContext, if_clause: *const ast.If) void { - // Trace the condition statements - for (if_clause.cond.slice()) |*stmt| { - traceStmt(ctx, stmt); - } - // Trace the then branch statements - for (if_clause.then.slice()) |*stmt| { - traceStmt(ctx, stmt); - } - // Trace the else parts - // else_parts is a SmolList of SmolList(Stmt, 1) - // Length 0 = no else, length 1 = just else, length 2n = elif/then pairs, length 2n+1 = elif/then pairs + else - for (if_clause.else_parts.slice()) |*part| { - for (part.slice()) |*stmt| { - traceStmt(ctx, stmt); - } - } -} - -fn traceCondExpr(ctx: *TraceContext, cond: *const ast.CondExpr) void { - const op = cond.op; - // File test operators (single argument) - const is_file_test = op == .@"-e" or op == .@"-f" or op == .@"-d" or - op == .@"-r" or op == .@"-w" or op == .@"-x" or - op == .@"-s" or op == .@"-L" or op == .@"-h" or - op == .@"-b" or op == .@"-c" or op == .@"-g" or - op == .@"-k" or op == .@"-p" or op == .@"-u" or - op == .@"-O" or op == .@"-G" or op == .@"-S" or - op == .@"-a" or op == .@"-N"; - - // File comparison operators (two arguments) - const is_file_comparison = op == .@"-ef" or op == .@"-nt" or op == .@"-ot"; - - if (is_file_test or is_file_comparison) { - // Expand all arguments and add read operations for file paths - for (cond.args.slice()) |*arg| { - const path = expandAtom(ctx, arg); - if (path.len > 0) { - ctx.addOperation(Permission.READ, path, null); - } - ctx.allocator.free(path); - } - } -} - -/// Information about a command's redirections -const RedirectInfo = struct { - /// Path for stdin redirection (if any) - stdin_path: ?[]const u8 = null, - /// Path for stdout redirection (if any) - stdout_path: ?[]const u8 = null, - /// Flags for stdout redirection - stdout_flags: u32 = 0, - /// Path for stderr redirection (if any) - stderr_path: ?[]const u8 = null, - /// Flags for stderr redirection - stderr_flags: u32 = 0, -}; - -fn traceCmd(ctx: *TraceContext, cmd: *const ast.Cmd) void { - // First, trace any assignments - for (cmd.assigns) |*assign| { - traceAssign(ctx, assign); - } - - // Expand the command name and arguments - if (cmd.name_and_args.len == 0) { - return; - } - - const cmd_name = expandAtom(ctx, &cmd.name_and_args[0]); - defer ctx.allocator.free(cmd_name); - - if (cmd_name.len == 0) { - return; - } - - // Get redirection info first - const redir = getRedirectInfo(ctx, cmd); - defer { - if (redir.stdin_path) |p| ctx.allocator.free(p); - if (redir.stdout_path) |p| ctx.allocator.free(p); - if (redir.stderr_path) |p| ctx.allocator.free(p); - } - - // Check for known commands (builtins) and map them to permissions - // Use stringToEnum directly to recognize all known commands, even if they're - // disabled as builtins on this platform (e.g., cat/cp on POSIX) - if (std.meta.stringToEnum(Interpreter.Builtin.Kind, cmd_name)) |builtin_kind| { - traceBuiltin(ctx, builtin_kind, cmd, &redir); - } else { - // External command - needs execute permission - traceExternalCommand(ctx, cmd_name, cmd, &redir); - } -} - -/// Expand command arguments and extract file paths (skipping flags). -/// Returns a list of expanded file paths. Caller owns the returned memory. -/// Handles brace expansion ({a,b}.txt) and glob expansion (*.txt). -fn extractFileArgs(ctx: *TraceContext, cmd: *const ast.Cmd) std.array_list.Managed([]const u8) { - var file_args = std.array_list.Managed([]const u8).init(ctx.allocator); - - for (cmd.name_and_args[1..]) |*arg| { - var expanded_list = expandAtomMultiple(ctx, arg); - defer expanded_list.deinit(); - - for (expanded_list.items) |expanded| { - if (expanded.len > 0 and expanded[0] != '-') { - // Keep this path - transfer ownership - bun.handleOom(file_args.append(expanded)); - } else { - ctx.allocator.free(expanded); - } - } - } - - // Expand glob patterns (e.g., *.txt -> file1.txt, file2.txt) - expandGlobs(ctx, &file_args); - - return file_args; -} - -/// Free a list of file args -fn freeFileArgs(ctx: *TraceContext, file_args: *std.array_list.Managed([]const u8)) void { - for (file_args.items) |path| { - ctx.allocator.free(path); - } - file_args.deinit(); -} - -/// Add redirections as operations with stream info -fn traceRedirections(ctx: *TraceContext, redir: *const RedirectInfo) void { - if (redir.stdin_path) |stdin| { - ctx.addOperationWithStream(Permission.READ, stdin, null, .stdin); - } - if (redir.stdout_path) |out| { - ctx.addOperationWithStream(redir.stdout_flags, out, null, .stdout); - } - if (redir.stderr_path) |err_path| { - ctx.addOperationWithStream(redir.stderr_flags, err_path, null, .stderr); - } -} - -fn traceBuiltin(ctx: *TraceContext, kind: Interpreter.Builtin.Kind, cmd: *const ast.Cmd, redir: *const RedirectInfo) void { - // Builtins run in-process, so they don't need EXECUTE permission on a binary. - // We only trace the file operations they perform. - - switch (kind) { - .cat => { - // cat reads files and writes to stdout (or redirect) - var file_args = extractFileArgs(ctx, cmd); - defer freeFileArgs(ctx, &file_args); - - for (file_args.items) |path| { - ctx.addOperation(Permission.READ, path, null); - } - traceRedirections(ctx, redir); - }, - .touch => { - // touch creates/modifies files - var file_args = extractFileArgs(ctx, cmd); - defer freeFileArgs(ctx, &file_args); - - for (file_args.items) |path| { - ctx.addOperation(Permission.CREATE, path, null); - } - }, - .mkdir => { - // mkdir creates directories - var file_args = extractFileArgs(ctx, cmd); - defer freeFileArgs(ctx, &file_args); - - for (file_args.items) |path| { - ctx.addOperation(Permission.MKDIR, path, null); - } - }, - .rm => { - // rm deletes files/directories - var file_args = extractFileArgs(ctx, cmd); - defer freeFileArgs(ctx, &file_args); - - for (file_args.items) |path| { - ctx.addOperation(Permission.DELETE, path, null); - } - }, - .mv => { - // mv moves files (read+delete source, create dest) - // Handles: mv src dest OR mv src1 src2 ... dest_dir/ - var file_args = extractFileArgs(ctx, cmd); - defer freeFileArgs(ctx, &file_args); - - if (file_args.items.len >= 2) { - const dest = file_args.items[file_args.items.len - 1]; - // All but the last arg are sources - for (file_args.items[0 .. file_args.items.len - 1]) |src| { - ctx.addOperation(Permission.READ | Permission.DELETE, src, null); - } - ctx.addOperation(Permission.CREATE, dest, null); - } else if (file_args.items.len == 1) { - // Just one arg - read it (mv will fail but we trace the access) - ctx.addOperation(Permission.READ | Permission.DELETE, file_args.items[0], null); - } - }, - .cp => { - // cp copies files (read source, create dest) - // Handles: cp src dest OR cp src1 src2 ... dest_dir/ - var file_args = extractFileArgs(ctx, cmd); - defer freeFileArgs(ctx, &file_args); - - if (file_args.items.len >= 2) { - const dest = file_args.items[file_args.items.len - 1]; - // All but the last arg are sources - for (file_args.items[0 .. file_args.items.len - 1]) |src| { - ctx.addOperation(Permission.READ, src, null); - } - ctx.addOperation(Permission.CREATE, dest, null); - } else if (file_args.items.len == 1) { - // Just one arg - read it (cp will fail but we trace the access) - ctx.addOperation(Permission.READ, file_args.items[0], null); - } - }, - .ls => { - // ls reads directory contents and writes to stdout (or redirect) - var file_args = extractFileArgs(ctx, cmd); - defer freeFileArgs(ctx, &file_args); - - if (file_args.items.len == 0) { - // ls with no args reads current directory - ctx.addOperation(Permission.READ, ".", null); - } else { - for (file_args.items) |path| { - ctx.addOperation(Permission.READ, path, null); - } - } - traceRedirections(ctx, redir); - }, - .cd => { - // cd changes directory - takes first non-flag arg - var file_args = extractFileArgs(ctx, cmd); - defer freeFileArgs(ctx, &file_args); - - if (file_args.items.len >= 1) { - ctx.addOperation(Permission.CHDIR, file_args.items[0], null); - // Actually update the context's cwd for subsequent commands - ctx.changeCwd(file_args.items[0]); - } - }, - .@"export" => { - // export sets environment variables - // Parse arguments like FOO=bar or just FOO - var file_args = extractFileArgs(ctx, cmd); - defer freeFileArgs(ctx, &file_args); - - for (file_args.items) |arg| { - // Look for = sign - if (std.mem.indexOfScalar(u8, arg, '=')) |eq_idx| { - const name = arg[0..eq_idx]; - const value = arg[eq_idx + 1 ..]; - ctx.setTracedEnv(name, value); - } else { - // Just exporting existing var - set to empty if not already set - if (ctx.traced_env.get(arg) == null) { - ctx.setTracedEnv(arg, ""); - } - } - } - // Add ENV operation after setting all vars - if (file_args.items.len > 0) { - ctx.addOperation(Permission.ENV, null, null); - } - }, - .echo, .pwd, .which, .yes, .seq, .dirname, .basename => { - // These only write to stdout (or redirect) - no file reads - traceRedirections(ctx, redir); - }, - .exit, .true, .false => { - // These don't access any files - }, - } -} - -fn traceExternalCommand(ctx: *TraceContext, cmd_name: []const u8, cmd: *const ast.Cmd, redir: *const RedirectInfo) void { - // Resolve the command path using which - // Get PATH from environment - const path_env = ctx.getVar("PATH") orelse "/usr/bin:/bin"; - var path_buf: bun.PathBuffer = undefined; - const resolved = which(&path_buf, path_env, ctx.cwdSlice(), cmd_name); - - // Collect arguments (skip the command name itself) - var args_list = std.array_list.Managed([]const u8).init(ctx.allocator); - defer args_list.deinit(); - - if (cmd.name_and_args.len > 1) { - for (cmd.name_and_args[1..]) |*arg| { - const expanded = expandAtom(ctx, arg); - args_list.append(expanded) catch {}; - } - } - - const args: ?[]const []const u8 = if (args_list.items.len > 0) args_list.items else null; - - // Record the command execution with args - if (resolved) |exe_path| { - ctx.addOperationWithArgs(Permission.EXECUTE, exe_path, cmd_name, args); - } else { - // Command not found, but still record the execute attempt - ctx.addOperationWithArgs(Permission.EXECUTE, null, cmd_name, args); - } - - // Free the expanded args (they were duped in addOperationWithArgs) - for (args_list.items) |arg| { - ctx.allocator.free(arg); - } - - // Handle stdin redirection - if (redir.stdin_path) |stdin| { - ctx.addOperationWithStream(Permission.READ, stdin, null, .stdin); - } - - // Handle stdout redirection - if (redir.stdout_path) |out| { - ctx.addOperationWithStream(redir.stdout_flags, out, null, .stdout); - } - - // Handle stderr redirection - if (redir.stderr_path) |err_path| { - ctx.addOperationWithStream(redir.stderr_flags, err_path, null, .stderr); - } -} - -fn getRedirectInfo(ctx: *TraceContext, cmd: *const ast.Cmd) RedirectInfo { - var info = RedirectInfo{}; - - if (cmd.redirect_file) |redirect| { - switch (redirect) { - .atom => |*atom| { - const path = expandAtom(ctx, atom); - if (path.len > 0) { - if (cmd.redirect.stdin) { - info.stdin_path = path; - } else { - const flags = if (cmd.redirect.append) Permission.APPEND else Permission.CREATE_TRUNC; - // Handle stdout and stderr separately - if (cmd.redirect.stdout and cmd.redirect.stderr) { - // &> or similar - both go to same file - info.stdout_path = path; - info.stdout_flags = flags; - // Also set stderr to same path (duplicate the path) - info.stderr_path = bun.handleOom(ctx.allocator.dupe(u8, path)); - info.stderr_flags = flags; - } else if (cmd.redirect.stdout) { - info.stdout_path = path; - info.stdout_flags = flags; - } else if (cmd.redirect.stderr) { - info.stderr_path = path; - info.stderr_flags = flags; - } else { - ctx.allocator.free(path); - } - } - } else { - ctx.allocator.free(path); - } - }, - .jsbuf => { - // JS buffer redirections don't involve file paths - }, - } - } - - return info; -} - -// ============================================================================= -// Expansion (simplified for tracing) -// ============================================================================= - -/// Expand an atom, potentially returning multiple strings due to brace expansion. -/// Returns a list of expanded strings. Caller owns the memory. -fn expandAtomMultiple(ctx: *TraceContext, atom: *const ast.Atom) std.array_list.Managed([]const u8) { - var result = std.array_list.Managed(u8).init(ctx.allocator); - var has_braces = false; - - switch (atom.*) { - .simple => |*simple| { - if (simple.* == .brace_begin) has_braces = true; - expandSimple(ctx, simple, &result); - }, - .compound => |compound| { - for (compound.atoms) |*simple| { - if (simple.* == .brace_begin) has_braces = true; - expandSimple(ctx, simple, &result); - } - }, - } - - const expanded_str = result.toOwnedSlice() catch ""; - - // If there are braces, expand them - if (has_braces and expanded_str.len > 0) { - const expanded = expandBraces(ctx, expanded_str); - ctx.allocator.free(expanded_str); - return expanded; - } - - // No braces - return single result - var out = std.array_list.Managed([]const u8).init(ctx.allocator); - if (expanded_str.len > 0) { - bun.handleOom(out.append(expanded_str)); - } else { - ctx.allocator.free(expanded_str); - } - return out; -} - -/// Expand brace patterns like {a,b,c} into multiple strings -fn expandBraces(ctx: *TraceContext, input: []const u8) std.array_list.Managed([]const u8) { - // Use the shared brace expansion helper - const unmanaged = Braces.expandBracesAlloc(input, ctx.allocator); - return .{ .items = unmanaged.items, .capacity = unmanaged.capacity, .allocator = ctx.allocator }; -} - -/// Expand glob patterns like *.txt into matching file paths -fn expandGlobs(ctx: *TraceContext, patterns: *std.array_list.Managed([]const u8)) void { - var i: usize = 0; - while (i < patterns.items.len) { - const pattern = patterns.items[i]; - - // Check if this pattern contains glob syntax - if (!bun.glob.detectGlobSyntax(pattern)) { - i += 1; - continue; - } - - // This pattern has glob syntax - expand it - var arena = std.heap.ArenaAllocator.init(ctx.allocator); - defer arena.deinit(); - - var walker: GlobWalker = .{}; - const init_result = walker.initWithCwd( - &arena, - pattern, - ctx.cwdSlice(), - false, // dot - true, // absolute (return absolute paths) - false, // follow_symlinks - false, // error_on_broken_symlinks - false, // only_files (include directories too) - ) catch { - i += 1; - continue; - }; - - switch (init_result) { - .err => { - i += 1; - continue; - }, - .result => {}, - } - - var iter: GlobWalker.Iterator = .{ .walker = &walker }; - const iter_init = iter.init() catch { - i += 1; - continue; - }; - switch (iter_init) { - .err => { - i += 1; - continue; - }, - .result => {}, - } - - // Collect all matched paths - var matched_paths = std.array_list.Managed([]const u8).init(ctx.allocator); - while (true) { - const next_result = iter.next() catch break; - switch (next_result) { - .err => break, - .result => |maybe_path| { - if (maybe_path) |path| { - // Dupe the path since it's owned by the arena - const duped = ctx.allocator.dupe(u8, path) catch break; - matched_paths.append(duped) catch { - ctx.allocator.free(duped); - break; - }; - } else { - // No more matches - break; - } - }, - } - } - - // If we found matches, replace the pattern with matched paths - if (matched_paths.items.len > 0) { - // Free the original pattern - ctx.allocator.free(pattern); - - // Remove the pattern from the list - _ = patterns.orderedRemove(i); - - // Insert all matched paths at position i - for (matched_paths.items) |matched_path| { - patterns.insert(i, matched_path) catch { - ctx.allocator.free(matched_path); - continue; - }; - i += 1; - } - matched_paths.deinit(); - } else { - // No matches - keep original pattern - matched_paths.deinit(); - i += 1; - } - } -} - -/// Expand an atom to a single string (for backward compatibility). -/// For brace expansions, only returns the first result. -fn expandAtom(ctx: *TraceContext, atom: *const ast.Atom) []const u8 { - var results = expandAtomMultiple(ctx, atom); - defer { - // Free all but the first - if (results.items.len > 1) { - for (results.items[1..]) |s| { - ctx.allocator.free(s); - } - } - results.deinit(); - } - - if (results.items.len > 0) { - return results.items[0]; - } - return bun.handleOom(ctx.allocator.dupe(u8, "")); -} - -fn expandSimple(ctx: *TraceContext, simple: *const ast.SimpleAtom, out: *std.array_list.Managed(u8)) void { - switch (simple.*) { - .Text => |text| { - bun.handleOom(out.appendSlice(text)); - }, - .Var => |varname| { - if (ctx.getVar(varname)) |val| { - bun.handleOom(out.appendSlice(val)); - } - }, - .VarArgv => { - // Special variables like $1, $@, etc. depend on runtime args - ctx.current_dynamic = true; - }, - .cmd_subst => { - // Command substitutions can't be statically analyzed - // Mark as dynamic and skip the actual substitution - ctx.current_dynamic = true; - }, - .asterisk => { - // Glob pattern - output as literal for tracing - bun.handleOom(out.appendSlice("*")); - }, - .double_asterisk => { - // Glob pattern - output as literal for tracing - bun.handleOom(out.appendSlice("**")); - }, - .brace_begin => { - bun.handleOom(out.appendSlice("{")); - }, - .brace_end => { - bun.handleOom(out.appendSlice("}")); - }, - .comma => { - bun.handleOom(out.appendSlice(",")); - }, - .tilde => { - // Expand tilde to home directory - if (ctx.getVar("HOME")) |home| { - bun.handleOom(out.appendSlice(home)); - } else { - bun.handleOom(out.appendSlice("~")); - } - }, - } -} - -// ============================================================================= -// Public API -// ============================================================================= - -/// Trace a shell script and return the trace result -pub fn trace( - allocator: Allocator, - shargs: *ShellArgs, - jsobjs: []JSValue, - export_env: ?*EnvMap, - cwd: ?[]const u8, - globalThis: *JSGlobalObject, -) TraceResult { - // Get current working directory - var cwd_buf: bun.PathBuffer = undefined; - const current_cwd = cwd orelse brk: { - const result = bun.sys.getcwdZ(&cwd_buf); - switch (result) { - .result => |c| break :brk c[0..c.len], - .err => break :brk "/", - } - }; - - var result = TraceResult.init(allocator, current_cwd); - var ctx = TraceContext.init(allocator, &result, current_cwd, export_env, jsobjs, globalThis); - defer ctx.deinit(); - - traceScript(&ctx, &shargs.script_ast); - - return result; -} - -/// JavaScript-callable function to trace a shell script -pub fn traceShellScript(globalThis: *JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue { - const allocator = bun.default_allocator; - const parsed_shell_script_js = callframe.argumentsAsArray(1)[0]; - if (parsed_shell_script_js.isUndefined()) { - return globalThis.throw("trace: expected a ParsedShellScript", .{}); - } - - const parsed_shell_script = jsc.Codegen.JSParsedShellScript.fromJS(parsed_shell_script_js) orelse { - return globalThis.throw("trace: expected a ParsedShellScript", .{}); - }; - - if (parsed_shell_script.args == null) { - return globalThis.throw("trace: shell args is null", .{}); - } - - const shargs = parsed_shell_script.args.?; - const jsobjs = parsed_shell_script.jsobjs.items; - - // Get cwd from parsed script if set - var cwd_utf8: ?bun.ZigString.Slice = null; - defer if (cwd_utf8) |*utf8| utf8.deinit(); - - const cwd_slice: ?[]const u8 = if (parsed_shell_script.cwd) |c| blk: { - cwd_utf8 = c.toUTF8(bun.default_allocator); - break :blk cwd_utf8.?.slice(); - } else null; - - var result = trace( - allocator, - shargs, - jsobjs, - if (parsed_shell_script.export_env != null) &parsed_shell_script.export_env.? else null, - cwd_slice, - globalThis, - ); - defer result.deinit(); - - return result.toJS(globalThis); -} - -const std = @import("std"); -const Allocator = std.mem.Allocator; - -const bun = @import("bun"); -const ResolvePath = bun.path; -const which = bun.which; -const GlobWalker = bun.glob.BunGlobWalker; - -const jsc = bun.jsc; -const JSGlobalObject = jsc.JSGlobalObject; -const JSValue = jsc.JSValue; - -const shell = bun.shell; -const EnvMap = shell.EnvMap; -const EnvStr = shell.EnvStr; -const Interpreter = shell.Interpreter; -const ast = shell.AST; - -const Braces = shell.interpret.Braces; -const ShellArgs = shell.interpret.ShellArgs; diff --git a/src/shell/braces.zig b/src/shell/braces.zig index 832adab71a..3f9ef5cf6c 100644 --- a/src/shell/braces.zig +++ b/src/shell/braces.zig @@ -723,68 +723,6 @@ test Lexer { } } -/// High-level helper that expands brace patterns in a string. -/// Returns a list of expanded strings. Caller owns the returned memory. -/// On error or if no expansion is needed, returns the input as a single-element list. -pub fn expandBracesAlloc(input: []const u8, allocator: Allocator) std.ArrayListUnmanaged([]const u8) { - var out: std.ArrayListUnmanaged([]const u8) = .{}; - - // Use arena for temporary tokenization - var arena = std.heap.ArenaAllocator.init(allocator); - defer arena.deinit(); - const arena_alloc = arena.allocator(); - - // Tokenize - use appropriate lexer based on content - const lexer_output = if (bun.strings.isAllASCII(input)) - Lexer.tokenize(arena_alloc, input) catch { - out.append(allocator, allocator.dupe(u8, input) catch return out) catch {}; - return out; - } - else - NewLexer(.wtf8).tokenize(arena_alloc, input) catch { - out.append(allocator, allocator.dupe(u8, input) catch return out) catch {}; - return out; - }; - - const expansion_count = calculateExpandedAmount(lexer_output.tokens.items[0..]); - if (expansion_count == 0) { - out.append(allocator, allocator.dupe(u8, input) catch return out) catch {}; - return out; - } - - // Allocate expanded strings - const expanded_strings = arena_alloc.alloc(std.array_list.Managed(u8), expansion_count) catch { - out.append(allocator, allocator.dupe(u8, input) catch return out) catch {}; - return out; - }; - - for (0..expansion_count) |i| { - expanded_strings[i] = std.array_list.Managed(u8).init(allocator); - } - - // Perform brace expansion - expand( - arena_alloc, - lexer_output.tokens.items[0..], - expanded_strings, - lexer_output.contains_nested, - ) catch { - for (expanded_strings) |*s| s.deinit(); - out.append(allocator, allocator.dupe(u8, input) catch return out) catch {}; - return out; - }; - - // Collect results - for (expanded_strings) |*s| { - const slice = s.toOwnedSlice() catch ""; - if (slice.len > 0) { - out.append(allocator, slice) catch {}; - } - } - - return out; -} - const SmolStr = @import("../string.zig").SmolStr; const Encoding = @import("./shell.zig").StringEncoding; diff --git a/src/shell/interpreter.zig b/src/shell/interpreter.zig index d4a1d6aa50..f556889893 100644 --- a/src/shell/interpreter.zig +++ b/src/shell/interpreter.zig @@ -444,10 +444,10 @@ pub const Interpreter = struct { if (comptime free_buffered_io) { if (this._buffered_stdout == .owned) { - this._buffered_stdout.owned.deinit(bun.default_allocator); + this._buffered_stdout.owned.clearAndFree(bun.default_allocator); } if (this._buffered_stderr == .owned) { - this._buffered_stderr.owned.deinit(bun.default_allocator); + this._buffered_stderr.owned.clearAndFree(bun.default_allocator); } } @@ -994,7 +994,7 @@ pub const Interpreter = struct { interp.exit_code = exit_code; switch (try interp.run()) { .err => |e| { - interp.deinitEverything(); + interp.#deinitFromExec(); bun.Output.err(e, "Failed to run script {s}", .{std.fs.path.basename(path)}); bun.Global.exit(1); return 1; @@ -1003,7 +1003,7 @@ pub const Interpreter = struct { } mini.tick(&is_done, @as(fn (*anyopaque) bool, IsDone.isDone)); const code = interp.exit_code.?; - interp.deinitEverything(); + interp.#deinitFromExec(); return code; } @@ -1061,7 +1061,7 @@ pub const Interpreter = struct { interp.exit_code = exit_code; switch (try interp.run()) { .err => |e| { - interp.deinitEverything(); + interp.#deinitFromExec(); bun.Output.err(e, "Failed to run script {s}", .{path_for_errors}); bun.Global.exit(1); return 1; @@ -1070,7 +1070,7 @@ pub const Interpreter = struct { } mini.tick(&is_done, @as(fn (*anyopaque) bool, IsDone.isDone)); const code = interp.exit_code.?; - interp.deinitEverything(); + interp.#deinitFromExec(); return code; } @@ -1142,7 +1142,7 @@ pub const Interpreter = struct { _ = callframe; // autofix if (this.setupIOBeforeRun().asErr()) |e| { - defer this.deinitEverything(); + defer this.#deinitFromExec(); const shellerr = bun.shell.ShellErr.newSys(e); return try throwShellErr(&shellerr, .{ .js = globalThis.bunVM().event_loop }); } @@ -1191,20 +1191,21 @@ pub const Interpreter = struct { defer decrPendingActivityFlag(&this.has_pending_activity); if (this.event_loop == .js) { - defer this.deinitAfterJSRun(); this.exit_code = exit_code; const this_jsvalue = this.this_jsvalue; if (this_jsvalue != .zero) { if (jsc.Codegen.JSShellInterpreter.resolveGetCached(this_jsvalue)) |resolve| { const loop = this.event_loop.js; const globalThis = this.globalThis; - this.this_jsvalue = .zero; + const buffered_stdout = this.getBufferedStdout(globalThis); + const buffered_stderr = this.getBufferedStderr(globalThis); this.keep_alive.disable(); + this.#derefRootShellAndIOIfNeeded(true); loop.enter(); _ = resolve.call(globalThis, .js_undefined, &.{ JSValue.jsNumberFromU16(exit_code), - this.getBufferedStdout(globalThis), - this.getBufferedStderr(globalThis), + buffered_stdout, + buffered_stderr, }) catch |err| globalThis.reportActiveExceptionAsUnhandled(err); jsc.Codegen.JSShellInterpreter.resolveSetCached(this_jsvalue, globalThis, .js_undefined); jsc.Codegen.JSShellInterpreter.rejectSetCached(this_jsvalue, globalThis, .js_undefined); @@ -1219,35 +1220,45 @@ pub const Interpreter = struct { return .done; } - fn deinitAfterJSRun(this: *ThisInterpreter) void { - log("Interpreter(0x{x}) deinitAfterJSRun", .{@intFromPtr(this)}); - this.root_io.deref(); - this.keep_alive.disable(); - this.root_shell.deinitImpl(false, false); + fn #derefRootShellAndIOIfNeeded(this: *ThisInterpreter, free_buffered_io: bool) void { + if (free_buffered_io) { + // Can safely be called multiple times. + if (this.root_shell._buffered_stderr == .owned) { + this.root_shell._buffered_stderr.owned.clearAndFree(bun.default_allocator); + } + if (this.root_shell._buffered_stdout == .owned) { + this.root_shell._buffered_stdout.owned.clearAndFree(bun.default_allocator); + } + } + + // Has this already been finalized? + if (this.this_jsvalue != .zero) { + // Cannot be safely called multiple times. + this.root_io.deref(); + this.root_shell.deinitImpl(false, false); + } + this.this_jsvalue = .zero; } fn deinitFromFinalizer(this: *ThisInterpreter) void { - if (this.root_shell._buffered_stderr == .owned) { - this.root_shell._buffered_stderr.owned.deinit(bun.default_allocator); - } - if (this.root_shell._buffered_stdout == .owned) { - this.root_shell._buffered_stdout.owned.deinit(bun.default_allocator); - } - this.this_jsvalue = .zero; + this.#derefRootShellAndIOIfNeeded(true); + this.keep_alive.disable(); this.args.deinit(); this.allocator.destroy(this); } - fn deinitEverything(this: *ThisInterpreter) void { + fn #deinitFromExec(this: *ThisInterpreter) void { log("deinit interpreter", .{}); + + this.this_jsvalue = .zero; this.root_io.deref(); this.root_shell.deinitImpl(false, true); + for (this.vm_args_utf8.items[0..]) |str| { str.deinit(); } this.vm_args_utf8.deinit(); - this.this_jsvalue = .zero; this.allocator.destroy(this); } diff --git a/src/shell/shell.zig b/src/shell/shell.zig index 83e7209485..92bf54c9cb 100644 --- a/src/shell/shell.zig +++ b/src/shell/shell.zig @@ -2,7 +2,6 @@ pub const interpret = @import("./interpreter.zig"); pub const subproc = @import("./subproc.zig"); pub const AllocScope = @import("./AllocScope.zig"); -pub const TraceInterpreter = @import("./TraceInterpreter.zig"); pub const EnvMap = interpret.EnvMap; pub const EnvStr = interpret.EnvStr; diff --git a/test/cli/install/symlink-path-traversal.test.ts b/test/cli/install/symlink-path-traversal.test.ts new file mode 100644 index 0000000000..2d06e73937 --- /dev/null +++ b/test/cli/install/symlink-path-traversal.test.ts @@ -0,0 +1,391 @@ +import { spawn } from "bun"; +import { describe, expect, it, setDefaultTimeout } from "bun:test"; +import { access, lstat, readlink, rm, writeFile } from "fs/promises"; +import { bunExe, bunEnv as env, tempDir } from "harness"; +import { tmpdir } from "os"; +import { join } from "path"; + +// This test validates the fix for a symlink path traversal vulnerability in tarball extraction. +// CVE: Path traversal via symlink when installing packages +// +// The attack works as follows: +// 1. Create a tarball with a symlink entry pointing outside (e.g., symlink -> ../../../tmp) +// 2. Include a file entry through that symlink path (e.g., symlink/pwned.txt) +// 3. On extraction, the symlink is created first +// 4. Then when the file is written through the symlink path, it escapes the extraction directory +// +// The fix validates symlink targets before creating them, blocking those that would escape. +// +// Note: These tests only run on POSIX systems as the symlink extraction code is POSIX-only. + +// Platform-agnostic temp directory for testing path traversal +const systemTmpDir = tmpdir(); +const pwnedFilePath = join(systemTmpDir, "pwned.txt"); + +// Helper to create tar files programmatically +function createTarHeader( + name: string, + size: number, + type: "0" | "2" | "5", // 0=file, 2=symlink, 5=directory + linkname: string = "", +): Uint8Array { + const header = new Uint8Array(512); + const encoder = new TextEncoder(); + + // Name (100 bytes) + const nameBytes = encoder.encode(name); + header.set(nameBytes.slice(0, 100), 0); + + // Mode (8 bytes) - octal + const modeStr = type === "5" ? "0000755" : "0000644"; + header.set(encoder.encode(modeStr.padStart(7, "0") + " "), 100); + + // UID (8 bytes) + header.set(encoder.encode("0000000 "), 108); + + // GID (8 bytes) + header.set(encoder.encode("0000000 "), 116); + + // Size (12 bytes) - octal + const sizeStr = size.toString(8).padStart(11, "0") + " "; + header.set(encoder.encode(sizeStr), 124); + + // Mtime (12 bytes) + const mtime = Math.floor(Date.now() / 1000) + .toString(8) + .padStart(11, "0"); + header.set(encoder.encode(mtime + " "), 136); + + // Checksum placeholder (8 spaces) + header.set(encoder.encode(" "), 148); + + // Type flag (1 byte) + header[156] = type.charCodeAt(0); + + // Link name (100 bytes) - for symlinks + if (linkname) { + const linkBytes = encoder.encode(linkname); + header.set(linkBytes.slice(0, 100), 157); + } + + // USTAR magic + header.set(encoder.encode("ustar"), 257); + header[262] = 0; // null terminator + header.set(encoder.encode("00"), 263); + + // Calculate and set checksum + let checksum = 0; + for (let i = 0; i < 512; i++) { + checksum += header[i]; + } + const checksumStr = checksum.toString(8).padStart(6, "0") + "\0 "; + header.set(encoder.encode(checksumStr), 148); + + return header; +} + +function padToBlock(data: Uint8Array): Uint8Array[] { + const result = [data]; + const remainder = data.length % 512; + if (remainder > 0) { + result.push(new Uint8Array(512 - remainder)); + } + return result; +} + +function createTarball( + entries: Array<{ name: string; type: "file" | "symlink" | "dir"; content?: string; linkname?: string }>, +): Uint8Array { + const blocks: Uint8Array[] = []; + const encoder = new TextEncoder(); + + for (const entry of entries) { + if (entry.type === "dir") { + blocks.push(createTarHeader(entry.name, 0, "5")); + } else if (entry.type === "symlink") { + blocks.push(createTarHeader(entry.name, 0, "2", entry.linkname || "")); + } else { + const content = encoder.encode(entry.content || ""); + blocks.push(createTarHeader(entry.name, content.length, "0")); + blocks.push(...padToBlock(content)); + } + } + + // End of archive (two empty blocks) + blocks.push(new Uint8Array(512)); + blocks.push(new Uint8Array(512)); + + // Combine all blocks + const totalLength = blocks.reduce((sum, b) => sum + b.length, 0); + const tarball = new Uint8Array(totalLength); + let offset = 0; + for (const block of blocks) { + tarball.set(block, offset); + offset += block.length; + } + + return Bun.gzipSync(tarball); +} + +// Skip on Windows - symlink extraction is POSIX-only +const isWindows = process.platform === "win32"; + +describe.concurrent.skipIf(isWindows)("symlink path traversal protection", () => { + setDefaultTimeout(60000); + + it("should skip symlinks with relative path traversal targets", async () => { + // This reproduces the exact attack from the security report: + // 1. Symlink test-package/symlink-to-tmp -> ../../../../../../../ + // 2. File test-package/symlink-to-tmp/pwned.txt + + // Calculate relative path to system temp directory (enough ../ to escape) + const symlinkTarget = "../../../../../../../" + systemTmpDir.replace(/^\//, ""); + + const tarball = createTarball([ + { name: "test-package/", type: "dir" }, + { + name: "test-package/package.json", + type: "file", + content: JSON.stringify({ name: "test-package", version: "1.0.0" }), + }, + // Malicious symlink pointing way outside + { name: "test-package/symlink-to-tmp", type: "symlink", linkname: symlinkTarget }, + // File that would be written through the symlink + { name: "test-package/symlink-to-tmp/pwned.txt", type: "file", content: "Arbitrary file write" }, + ]); + + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + if (url.pathname.includes("/tarball/") || url.pathname.endsWith(".tar.gz")) { + return new Response(tarball, { headers: { "Content-Type": "application/gzip" } }); + } + if (url.pathname.includes("/repos/")) { + return Response.json({ default_branch: "main" }); + } + return new Response("Not Found", { status: 404 }); + }, + }); + + try { + using dir = tempDir("symlink-traversal-test", {}); + const installDir = String(dir); + + await writeFile( + join(installDir, "package.json"), + JSON.stringify({ + name: "test-app", + version: "1.0.0", + dependencies: { "test-package": "github:user/repo#main" }, + }), + ); + + await writeFile(join(installDir, "bunfig.toml"), `[install]\ncache = false\n`); + + const proc = spawn({ + cmd: [bunExe(), "install"], + cwd: installDir, + stdout: "pipe", + stderr: "pipe", + env: { ...env, GITHUB_API_URL: `http://localhost:${server.port}` }, + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // The install should complete successfully (exit code 0) + // If it fails, show diagnostics + if (exitCode !== 0) { + console.error("Install failed with exit code:", exitCode); + console.error("stdout:", stdout); + console.error("stderr:", stderr); + } + expect(exitCode).toBe(0); + + // Verify stderr doesn't leak absolute paths like the system temp directory + expect(stderr).not.toContain(systemTmpDir); + + // CRITICAL CHECK: Verify no file was written to system temp directory + let fileInTmp = false; + try { + await access(pwnedFilePath); + fileInTmp = true; + } catch { + fileInTmp = false; + } + expect(fileInTmp).toBe(false); + + // Verify the malicious symlink was NOT created as a symlink + // (It may exist as a directory since the tarball has a file entry through it) + const pkgDir = join(installDir, "node_modules", "test-package"); + const symlinkPath = join(pkgDir, "symlink-to-tmp"); + try { + const stats = await lstat(symlinkPath); + // If it exists, it must NOT be a symlink (directory is OK - that's what happens + // when the symlink is blocked but a file tries to write through it) + expect(stats.isSymbolicLink()).toBe(false); + } catch { + // Path doesn't exist at all - also acceptable + } + } finally { + server.stop(); + // Clean up pwned file in case the test failed + try { + await rm(pwnedFilePath, { force: true }); + } catch {} + } + }); + + it("should skip symlinks with absolute path targets", async () => { + const tarball = createTarball([ + { name: "test-package/", type: "dir" }, + { + name: "test-package/package.json", + type: "file", + content: JSON.stringify({ name: "test-package", version: "1.0.0" }), + }, + // Absolute symlink - directly points to system temp directory + { name: "test-package/abs-symlink", type: "symlink", linkname: systemTmpDir }, + ]); + + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + if (url.pathname.includes("/tarball/") || url.pathname.endsWith(".tar.gz")) { + return new Response(tarball, { headers: { "Content-Type": "application/gzip" } }); + } + if (url.pathname.includes("/repos/")) { + return Response.json({ default_branch: "main" }); + } + return new Response("Not Found", { status: 404 }); + }, + }); + + try { + using dir = tempDir("absolute-symlink-test", {}); + const installDir = String(dir); + + await writeFile( + join(installDir, "package.json"), + JSON.stringify({ + name: "test-app", + version: "1.0.0", + dependencies: { "test-package": "github:user/repo#main" }, + }), + ); + + await writeFile(join(installDir, "bunfig.toml"), `[install]\ncache = false\n`); + + const proc = spawn({ + cmd: [bunExe(), "install"], + cwd: installDir, + stdout: "pipe", + stderr: "pipe", + env: { ...env, GITHUB_API_URL: `http://localhost:${server.port}` }, + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // The install should complete successfully + if (exitCode !== 0) { + console.error("Install failed with exit code:", exitCode); + console.error("stdout:", stdout); + console.error("stderr:", stderr); + } + expect(exitCode).toBe(0); + + // Check that no absolute symlink was created + const pkgDir = join(installDir, "node_modules", "test-package"); + try { + const symlinkPath = join(pkgDir, "abs-symlink"); + const stats = await lstat(symlinkPath); + if (stats.isSymbolicLink()) { + const target = await readlink(symlinkPath); + // Absolute symlinks should be blocked + expect(target.startsWith("/")).toBe(false); + } + } catch { + // Symlink doesn't exist - expected behavior + } + } finally { + server.stop(); + } + }); + + it("should allow safe relative symlinks within the package (install succeeds)", async () => { + // This test verifies that safe symlinks don't cause extraction to fail. + // Note: Safe symlinks ARE created in the cache during extraction, but bun's + // install process doesn't preserve them in the final node_modules. + // We verify the install succeeds, which proves safe symlinks are allowed. + const tarball = createTarball([ + { name: "test-package/", type: "dir" }, + { + name: "test-package/package.json", + type: "file", + content: JSON.stringify({ name: "test-package", version: "1.0.0" }), + }, + { name: "test-package/src/", type: "dir" }, + { name: "test-package/src/index.js", type: "file", content: "module.exports = 'hello';" }, + // Safe symlink - points to sibling directory (stays within package) + { name: "test-package/link-to-src", type: "symlink", linkname: "src" }, + // Safe symlink - relative path within same directory + { name: "test-package/src/link-to-index", type: "symlink", linkname: "./index.js" }, + ]); + + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + if (url.pathname.includes("/tarball/") || url.pathname.endsWith(".tar.gz")) { + return new Response(tarball, { headers: { "Content-Type": "application/gzip" } }); + } + if (url.pathname.includes("/repos/")) { + return Response.json({ default_branch: "main" }); + } + return new Response("Not Found", { status: 404 }); + }, + }); + + try { + using dir = tempDir("safe-symlink-test", {}); + const installDir = String(dir); + + await writeFile( + join(installDir, "package.json"), + JSON.stringify({ + name: "test-app", + version: "1.0.0", + dependencies: { "test-package": "github:user/repo#main" }, + }), + ); + + await writeFile(join(installDir, "bunfig.toml"), `[install]\ncache = false\n`); + + const proc = spawn({ + cmd: [bunExe(), "install"], + cwd: installDir, + stdout: "pipe", + stderr: "pipe", + env: { ...env, GITHUB_API_URL: `http://localhost:${server.port}` }, + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Install should succeed - safe symlinks should not cause errors + if (exitCode !== 0) { + console.error("Install failed with exit code:", exitCode); + console.error("stdout:", stdout); + console.error("stderr:", stderr); + } + expect(exitCode).toBe(0); + + // Verify package was installed (package.json should exist) + const pkgDir = join(installDir, "node_modules", "test-package"); + const pkgJsonPath = join(pkgDir, "package.json"); + await access(pkgJsonPath); // Throws if doesn't exist + } finally { + server.stop(); + } + }); +}); diff --git a/test/js/bun/shell/trace.test.ts b/test/js/bun/shell/trace.test.ts deleted file mode 100644 index 41808088be..0000000000 --- a/test/js/bun/shell/trace.test.ts +++ /dev/null @@ -1,417 +0,0 @@ -import { $ } from "bun"; -import { describe, expect, test } from "bun:test"; -import { tempDir } from "harness"; - -// Normalize path separators for cross-platform tests -const normalizePath = (p: string) => p.replaceAll("\\", "/"); - -// Permission flags (octal) - mirrors the Zig constants -const Permission = { - O_RDONLY: 0o0, - O_WRONLY: 0o1, - O_RDWR: 0o2, - O_CREAT: 0o100, - O_EXCL: 0o200, - O_TRUNC: 0o1000, - O_APPEND: 0o2000, - X_OK: 0o100000, - DELETE: 0o200000, - MKDIR: 0o400000, - CHDIR: 0o1000000, - ENV: 0o2000000, -} as const; - -// Convenience combinations -const READ = Permission.O_RDONLY; -const WRITE = Permission.O_WRONLY; -const CREATE = Permission.O_CREAT | Permission.O_WRONLY; -const CREATE_TRUNC = Permission.O_CREAT | Permission.O_TRUNC | Permission.O_WRONLY; -const APPEND = Permission.O_APPEND | Permission.O_WRONLY; -const EXECUTE = Permission.X_OK; - -describe("Bun.$.trace", () => { - test("returns trace result object", () => { - const result = $.trace`echo hello`; - expect(result).toHaveProperty("operations"); - expect(result).toHaveProperty("cwd"); - expect(result).toHaveProperty("success"); - expect(result).toHaveProperty("error"); - expect(result.success).toBe(true); - expect(result.error).toBeNull(); - expect(Array.isArray(result.operations)).toBe(true); - }); - - test("traces echo command (builtin, no file access)", () => { - const result = $.trace`echo hello world`; - expect(result.success).toBe(true); - - // echo is a builtin that runs in-process - no file access, no operations - // It just writes to stdout (terminal) which doesn't require any permissions - expect(result.operations.length).toBe(0); - }); - - test("traces cat command with file read", () => { - const result = $.trace`cat /tmp/test.txt`; - expect(result.success).toBe(true); - - // cat is a builtin - it reads files but runs in-process (no EXECUTE) - const readOps = result.operations.filter(op => op.flags === READ && op.path?.endsWith("test.txt")); - expect(readOps.length).toBe(1); - expect(normalizePath(readOps[0].path!)).toBe("/tmp/test.txt"); - }); - - test("traces rm command with delete permission", () => { - const result = $.trace`rm /tmp/to-delete.txt`; - expect(result.success).toBe(true); - - // Should have delete for the file - const deleteOps = result.operations.filter(op => op.flags === Permission.DELETE); - expect(deleteOps.length).toBe(1); - expect(normalizePath(deleteOps[0].path!)).toBe("/tmp/to-delete.txt"); - }); - - test("traces mkdir command", () => { - const result = $.trace`mkdir /tmp/newdir`; - expect(result.success).toBe(true); - - // Should have mkdir permission - const mkdirOps = result.operations.filter(op => op.flags === Permission.MKDIR); - expect(mkdirOps.length).toBe(1); - expect(normalizePath(mkdirOps[0].path!)).toBe("/tmp/newdir"); - }); - - test("traces touch command with create permission", () => { - const result = $.trace`touch /tmp/newfile.txt`; - expect(result.success).toBe(true); - - // Should have create permission - const createOps = result.operations.filter(op => op.flags === CREATE); - expect(createOps.length).toBe(1); - expect(normalizePath(createOps[0].path!)).toBe("/tmp/newfile.txt"); - }); - - test("traces cp command with read and write", () => { - const result = $.trace`cp /tmp/src.txt /tmp/dst.txt`; - expect(result.success).toBe(true); - - // Should have read for source - const readOps = result.operations.filter(op => op.flags === READ && op.path?.endsWith("src.txt")); - expect(readOps.length).toBe(1); - - // Should have create for destination - const writeOps = result.operations.filter(op => op.flags === CREATE && op.path?.endsWith("dst.txt")); - expect(writeOps.length).toBe(1); - }); - - test("traces mv command with read, delete, and write", () => { - const result = $.trace`mv /tmp/old.txt /tmp/new.txt`; - expect(result.success).toBe(true); - - // Should have read+delete for source (combined in one operation) - const srcOps = result.operations.filter( - op => op.flags === (READ | Permission.DELETE) && op.path?.endsWith("old.txt"), - ); - expect(srcOps.length).toBe(1); - - // Should have create for destination - const dstOps = result.operations.filter(op => op.flags === CREATE && op.path?.endsWith("new.txt")); - expect(dstOps.length).toBe(1); - }); - - test("traces cd command with chdir permission", () => { - const result = $.trace`cd /tmp`; - expect(result.success).toBe(true); - - const chdirOps = result.operations.filter(op => op.flags === Permission.CHDIR); - expect(chdirOps.length).toBe(1); - expect(normalizePath(chdirOps[0].path!)).toBe("/tmp"); - }); - - test("traces environment variable assignments with accumulated env", () => { - const result = $.trace`FOO=1 BAR=2 echo test`; - expect(result.success).toBe(true); - - const envOps = result.operations.filter(op => op.flags === Permission.ENV); - expect(envOps.length).toBe(2); - // First op has FOO - expect(envOps[0].env).toEqual({ FOO: "1" }); - // Second op has both FOO and BAR - expect(envOps[1].env?.FOO).toBe("1"); - expect(envOps[1].env?.BAR).toBe("2"); - }); - - test("traces export with env values", () => { - const result = $.trace`export FOO=hello BAR=world`; - expect(result.success).toBe(true); - - const envOps = result.operations.filter(op => op.flags === Permission.ENV); - expect(envOps.length).toBe(1); - expect(envOps[0].env?.FOO).toBe("hello"); - expect(envOps[0].env?.BAR).toBe("world"); - }); - - test("traces output redirection combined with command", () => { - const result = $.trace`echo hello > /tmp/output.txt`; - expect(result.success).toBe(true); - - // echo is a builtin - redirect creates the output file (CREATE_TRUNC, no EXECUTE) - const redirectOps = result.operations.filter(op => op.flags === CREATE_TRUNC && op.path?.endsWith("output.txt")); - expect(redirectOps.length).toBe(1); - }); - - test("traces append redirection combined with command", () => { - const result = $.trace`echo hello >> /tmp/append.txt`; - expect(result.success).toBe(true); - - // echo is a builtin - append redirect opens file for appending (no EXECUTE) - const appendOps = result.operations.filter(op => op.flags === APPEND && op.path?.endsWith("append.txt")); - expect(appendOps.length).toBe(1); - }); - - test("traces input redirection with read and stdin stream", () => { - const result = $.trace`cat < /tmp/input.txt`; - expect(result.success).toBe(true); - - // Should have read for input file with stdin stream marker - const stdinOps = result.operations.filter( - op => op.flags === READ && op.path?.endsWith("input.txt") && op.stream === "stdin", - ); - expect(stdinOps.length).toBe(1); - }); - - test("traces stderr redirection with stream marker", () => { - const result = $.trace`cat /nonexistent 2> /tmp/err.txt`; - expect(result.success).toBe(true); - - // Should have stderr stream for error redirect - const stderrOps = result.operations.filter(op => op.stream === "stderr" && op.path?.endsWith("err.txt")); - expect(stderrOps.length).toBe(1); - expect(stderrOps[0].flags).toBe(CREATE_TRUNC); - }); - - test("stdout redirect has stream marker", () => { - const result = $.trace`echo hello > /tmp/out.txt`; - expect(result.success).toBe(true); - - const stdoutOps = result.operations.filter(op => op.stream === "stdout"); - expect(stdoutOps.length).toBe(1); - expect(normalizePath(stdoutOps[0].path!)).toBe("/tmp/out.txt"); - }); - - test("traces export command with env permission", () => { - const result = $.trace`export FOO=bar`; - expect(result.success).toBe(true); - - const envOps = result.operations.filter(op => op.flags === Permission.ENV); - expect(envOps.length).toBeGreaterThan(0); - }); - - test("traces variable assignment with env permission", () => { - const result = $.trace`FOO=bar echo $FOO`; - expect(result.success).toBe(true); - - const envOps = result.operations.filter(op => op.flags === Permission.ENV); - expect(envOps.length).toBeGreaterThan(0); - }); - - test("traces pipeline", () => { - const result = $.trace`cat /tmp/file.txt | grep pattern`; - expect(result.success).toBe(true); - - // cat is a builtin - reads file (no EXECUTE, no command field) - const readOps = result.operations.filter(op => op.flags === READ && op.path?.endsWith("file.txt")); - expect(readOps.length).toBe(1); - - // grep is external, should have execute permission and command field - const grepOps = result.operations.filter(op => op.command === "grep" && (op.flags & EXECUTE) !== 0); - expect(grepOps.length).toBe(1); - }); - - test("traces ls with directory read", () => { - const result = $.trace`ls /tmp`; - expect(result.success).toBe(true); - - const readOps = result.operations.filter(op => op.flags === READ && normalizePath(op.path || "") === "/tmp"); - expect(readOps.length).toBe(1); - }); - - test("traces ls without args (current dir)", () => { - const result = $.trace`ls`; - expect(result.success).toBe(true); - - // Should read current directory (.) - const readOps = result.operations.filter(op => op.flags === READ); - expect(readOps.length).toBe(1); - }); - - test("includes cwd in result", () => { - const result = $.trace`echo test`; - expect(result.cwd).toBeTruthy(); - expect(typeof result.cwd).toBe("string"); - }); - - test("includes cwd in each operation", () => { - const result = $.trace`cat /tmp/test.txt`; - for (const op of result.operations) { - expect(op.cwd).toBeTruthy(); - expect(typeof op.cwd).toBe("string"); - } - }); - - test("handles template literal interpolation", () => { - const filename = "test.txt"; - const result = $.trace`cat /tmp/${filename}`; - expect(result.success).toBe(true); - - const readOps = result.operations.filter(op => op.flags === READ && op.path?.endsWith("test.txt")); - expect(readOps.length).toBe(1); - }); - - test("does not actually execute commands", () => { - // This would fail if it actually ran, since the file doesn't exist - const result = $.trace`cat /nonexistent/path/that/does/not/exist.txt`; - expect(result.success).toBe(true); - expect(result.operations.length).toBeGreaterThan(0); - }); - - test("external command resolves path when available", () => { - // Use a cross-platform external command - const cmd = process.platform === "win32" ? "cmd" : "/bin/ls"; - const result = $.trace`${cmd} --version`; - expect(result.success).toBe(true); - - const execOps = result.operations.filter(op => op.flags === EXECUTE); - expect(execOps.length).toBeGreaterThan(0); - // Command name should be captured - expect(execOps[0].command).toBe(cmd); - }); - - test("external commands include args array", () => { - const result = $.trace`grep -r 'pattern' src/`; - expect(result.success).toBe(true); - - const execOps = result.operations.filter(op => op.flags === EXECUTE); - expect(execOps.length).toBe(1); - expect(execOps[0].command).toBe("grep"); - expect(execOps[0].args).toEqual(["-r", "pattern", "src/"]); - }); - - test("pipeline commands each have their own args", () => { - const result = $.trace`git diff HEAD^ -- src/ | head -100`; - expect(result.success).toBe(true); - - const execOps = result.operations.filter(op => op.flags === EXECUTE); - expect(execOps.length).toBe(2); - - expect(execOps[0].command).toBe("git"); - expect(execOps[0].args).toEqual(["diff", "HEAD^", "--", "src/"]); - - expect(execOps[1].command).toBe("head"); - expect(execOps[1].args).toEqual(["-100"]); - }); - - test("builtins do not have args (tracked as file operations)", () => { - const result = $.trace`cat file1.txt file2.txt`; - expect(result.success).toBe(true); - - // Builtins track files, not args - const readOps = result.operations.filter(op => op.flags === READ); - expect(readOps.length).toBe(2); - expect(readOps[0].args).toBeUndefined(); - expect(readOps[1].args).toBeUndefined(); - }); - - test("traces && (and) operator", () => { - const result = $.trace`cat /tmp/a.txt && cat /tmp/b.txt`; - expect(result.success).toBe(true); - - // Both commands should be traced - const readOps = result.operations.filter(op => op.flags === READ); - expect(readOps.length).toBe(2); - expect(normalizePath(readOps[0].path!)).toBe("/tmp/a.txt"); - expect(normalizePath(readOps[1].path!)).toBe("/tmp/b.txt"); - }); - - test("traces || (or) operator", () => { - const result = $.trace`cat /tmp/a.txt || cat /tmp/b.txt`; - expect(result.success).toBe(true); - - // Both commands should be traced - const readOps = result.operations.filter(op => op.flags === READ); - expect(readOps.length).toBe(2); - }); - - test("traces subshell with cwd isolation", () => { - const result = $.trace`(cd /tmp && ls) && ls`; - expect(result.success).toBe(true); - - // Should have: CHDIR /tmp, READ /tmp (inside subshell), READ . (outside subshell) - const chdirOps = result.operations.filter(op => op.flags === Permission.CHDIR); - expect(chdirOps.length).toBe(1); - expect(normalizePath(chdirOps[0].path!)).toBe("/tmp"); - - const readOps = result.operations.filter(op => op.flags === READ); - expect(readOps.length).toBe(2); - // First ls inside subshell should see /tmp - expect(normalizePath(readOps[0].cwd!)).toBe("/tmp"); - // Second ls outside subshell should see original cwd (subshell cwd is restored) - expect(normalizePath(readOps[1].cwd!)).not.toBe("/tmp"); - }); - - test("cd updates cwd for subsequent commands", () => { - const result = $.trace`cd /tmp && ls`; - expect(result.success).toBe(true); - - const readOps = result.operations.filter(op => op.flags === READ); - expect(readOps.length).toBe(1); - expect(normalizePath(readOps[0].cwd!)).toBe("/tmp"); - expect(normalizePath(readOps[0].path!)).toBe("/tmp"); // ls reads cwd - }); - - test("expands brace patterns", () => { - const result = $.trace`cat /tmp/{a,b,c}.txt`; - expect(result.success).toBe(true); - - const readOps = result.operations.filter(op => op.flags === READ); - expect(readOps.length).toBe(3); - expect(normalizePath(readOps[0].path!)).toBe("/tmp/a.txt"); - expect(normalizePath(readOps[1].path!)).toBe("/tmp/b.txt"); - expect(normalizePath(readOps[2].path!)).toBe("/tmp/c.txt"); - }); - - test("expands tilde to home directory", () => { - const result = $.trace`cat ~/.config/test.txt`; - expect(result.success).toBe(true); - - const readOps = result.operations.filter(op => op.flags === READ); - expect(readOps.length).toBe(1); - expect(readOps[0].path).not.toContain("~"); - // Home directory path varies by platform - if (process.platform === "win32") { - // Windows uses USERPROFILE which expands to something like C:\Users\username - expect(readOps[0].path).toMatch(/\.config[/\\]test\.txt$/); - } else { - expect(readOps[0].path).toContain(".config/test.txt"); - } - }); - - test("expands glob patterns to matching files", () => { - // Create test files for glob expansion using tempDir helper - const { join } = require("path"); - using dir = tempDir("trace-glob-test", { - "a.txt": "", - "b.txt": "", - "c.txt": "", - }); - const testDir = String(dir); - - const result = $.trace`cat ${testDir}/*.txt`; - expect(result.success).toBe(true); - - const readOps = result.operations.filter(op => op.flags === READ); - expect(readOps.length).toBe(3); - const paths = readOps.map(op => normalizePath(op.path!)).sort(); - const expected = [join(testDir, "a.txt"), join(testDir, "b.txt"), join(testDir, "c.txt")].map(normalizePath); - expect(paths).toEqual(expected); - }); -}); diff --git a/test/js/web/websocket/websocket-proxy.test.ts b/test/js/web/websocket/websocket-proxy.test.ts index 7a15f11294..09d5353263 100644 --- a/test/js/web/websocket/websocket-proxy.test.ts +++ b/test/js/web/websocket/websocket-proxy.test.ts @@ -655,257 +655,4 @@ describe("ws module with HttpsProxyAgent", () => { expect(messages).toContain("hello from ws module via agent"); gc(); }); - - test("ws module passes agent with TLS options", async () => { - const { promise, resolve, reject } = Promise.withResolvers(); - - const agent = new HttpsProxyAgent(`http://127.0.0.1:${proxyPort}`, { - rejectUnauthorized: false, - }); - const ws = new WS(`wss://127.0.0.1:${wssPort}`, { agent }); - - const receivedMessages: string[] = []; - - ws.on("open", () => { - ws.send("hello from ws module via agent to wss"); - }); - - ws.on("message", (data: Buffer) => { - receivedMessages.push(data.toString()); - if (receivedMessages.length === 2) { - ws.close(); - } - }); - - ws.on("close", () => { - resolve(receivedMessages); - }); - - ws.on("error", (err: Error) => { - reject(err); - }); - - const messages = await promise; - expect(messages).toContain("connected"); - expect(messages).toContain("hello from ws module via agent to wss"); - gc(); - }); - - test("ws module explicit proxy takes precedence over agent", async () => { - const { promise, resolve, reject } = Promise.withResolvers(); - - // Create agent pointing to wrong port - const agent = new HttpsProxyAgent(`http://127.0.0.1:1`); - // But use explicit proxy option with correct port - const ws = new WS(`ws://127.0.0.1:${wsPort}`, { - agent, - proxy: `http://127.0.0.1:${proxyPort}`, // This should take precedence - }); - - const receivedMessages: string[] = []; - - ws.on("open", () => { - ws.send("ws module explicit proxy wins"); - }); - - ws.on("message", (data: Buffer) => { - receivedMessages.push(data.toString()); - if (receivedMessages.length === 2) { - ws.close(); - } - }); - - ws.on("close", () => { - resolve(receivedMessages); - }); - - ws.on("error", (err: Error) => { - reject(err); - }); - - const messages = await promise; - expect(messages).toContain("connected"); - expect(messages).toContain("ws module explicit proxy wins"); - gc(); - }); -}); - -describe("WebSocket with HttpsProxyAgent", () => { - test("ws:// through HttpsProxyAgent", async () => { - const { promise, resolve, reject } = Promise.withResolvers(); - - const agent = new HttpsProxyAgent(`http://127.0.0.1:${proxyPort}`); - const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { agent }); - - const receivedMessages: string[] = []; - - ws.onopen = () => { - ws.send("hello from WebSocket via HttpsProxyAgent"); - }; - - ws.onmessage = event => { - receivedMessages.push(String(event.data)); - if (receivedMessages.length === 2) { - ws.close(); - } - }; - - ws.onclose = () => { - resolve(receivedMessages); - }; - - ws.onerror = event => { - reject(event); - }; - - const messages = await promise; - expect(messages).toContain("connected"); - expect(messages).toContain("hello from WebSocket via HttpsProxyAgent"); - gc(); - }); - - test("wss:// through HttpsProxyAgent with rejectUnauthorized", async () => { - const { promise, resolve, reject } = Promise.withResolvers(); - - const agent = new HttpsProxyAgent(`http://127.0.0.1:${proxyPort}`, { - rejectUnauthorized: false, - }); - const ws = new WebSocket(`wss://127.0.0.1:${wssPort}`, { agent }); - - const receivedMessages: string[] = []; - - ws.onopen = () => { - ws.send("hello from wss via HttpsProxyAgent"); - }; - - ws.onmessage = event => { - receivedMessages.push(String(event.data)); - if (receivedMessages.length === 2) { - ws.close(); - } - }; - - ws.onclose = () => { - resolve(receivedMessages); - }; - - ws.onerror = event => { - reject(event); - }; - - const messages = await promise; - expect(messages).toContain("connected"); - expect(messages).toContain("hello from wss via HttpsProxyAgent"); - gc(); - }); - - test("HttpsProxyAgent with authentication", async () => { - const { promise, resolve, reject } = Promise.withResolvers(); - - const agent = new HttpsProxyAgent(`http://proxy_user:proxy_pass@127.0.0.1:${authProxyPort}`); - const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { agent }); - - const receivedMessages: string[] = []; - - ws.onopen = () => { - ws.send("hello from WebSocket with auth via HttpsProxyAgent"); - }; - - ws.onmessage = event => { - receivedMessages.push(String(event.data)); - if (receivedMessages.length === 2) { - ws.close(); - } - }; - - ws.onclose = () => { - resolve(receivedMessages); - }; - - ws.onerror = event => { - reject(event); - }; - - const messages = await promise; - expect(messages).toContain("connected"); - expect(messages).toContain("hello from WebSocket with auth via HttpsProxyAgent"); - gc(); - }); - - test("HttpsProxyAgent with agent.proxy as URL object", async () => { - const { promise, resolve, reject } = Promise.withResolvers(); - - // HttpsProxyAgent stores the proxy URL as a URL object in agent.proxy - const agent = new HttpsProxyAgent(`http://127.0.0.1:${proxyPort}`); - // Verify the agent has the proxy property as a URL object - expect(agent.proxy).toBeDefined(); - expect(typeof agent.proxy).toBe("object"); - expect(agent.proxy.href).toContain(`127.0.0.1:${proxyPort}`); - - const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { agent }); - - const receivedMessages: string[] = []; - - ws.onopen = () => { - ws.send("hello via agent with URL object"); - }; - - ws.onmessage = event => { - receivedMessages.push(String(event.data)); - if (receivedMessages.length === 2) { - ws.close(); - } - }; - - ws.onclose = () => { - resolve(receivedMessages); - }; - - ws.onerror = event => { - reject(event); - }; - - const messages = await promise; - expect(messages).toContain("connected"); - expect(messages).toContain("hello via agent with URL object"); - gc(); - }); - - test("explicit proxy option takes precedence over agent", async () => { - const { promise, resolve, reject } = Promise.withResolvers(); - - // Create agent pointing to wrong port (that doesn't exist) - const agent = new HttpsProxyAgent(`http://127.0.0.1:1`); - // But use explicit proxy option with correct port - const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { - agent, - proxy: `http://127.0.0.1:${proxyPort}`, // This should take precedence - }); - - const receivedMessages: string[] = []; - - ws.onopen = () => { - ws.send("explicit proxy wins"); - }; - - ws.onmessage = event => { - receivedMessages.push(String(event.data)); - if (receivedMessages.length === 2) { - ws.close(); - } - }; - - ws.onclose = () => { - resolve(receivedMessages); - }; - - ws.onerror = event => { - reject(event); - }; - - const messages = await promise; - expect(messages).toContain("connected"); - expect(messages).toContain("explicit proxy wins"); - gc(); - }); });