diff --git a/src/commands/curl/curl.ts b/src/commands/curl/curl.ts index 1c3fb5f7..62f138e0 100644 --- a/src/commands/curl/curl.ts +++ b/src/commands/curl/curl.ts @@ -5,6 +5,7 @@ * Network access must be explicitly configured via BashEnvOptions.network. */ +import { fromBuffer } from "../../fs/encoding.js"; import { getErrorMessage } from "../../interpreter/helpers/errors.js"; import { _Headers } from "../../security/trusted-globals.js"; import type { Command, CommandContext, ExecResult } from "../../types.js"; @@ -109,16 +110,18 @@ async function saveCookies( await ctx.fs.writeFile(filePath, setCookie); } -/** - * Build output string from response - */ +/** One JS character per byte for stdout (matches raw byte stream for ASCII / binary). */ +function fetchBodyToStdoutString(body: Uint8Array): string { + return fromBuffer(body, "binary"); +} + function buildOutput( options: CurlOptions, result: { status: number; statusText: string; headers: Record; - body: string; + body: Uint8Array; url: string; }, requestUrl: string, @@ -148,7 +151,7 @@ function buildOutput( // Add body (unless head-only mode) if (!options.headOnly) { - output += result.body; + output += fetchBodyToStdoutString(result.body); } else if (options.includeHeaders || options.verbose) { // For HEAD, we already showed headers } else { @@ -164,7 +167,7 @@ function buildOutput( status: result.status, headers: result.headers, url: result.url, - bodyLength: result.body.length, + bodyLength: result.body.byteLength, }); } @@ -255,7 +258,7 @@ export const curlCommand: Command = { status: result.status, headers: result.headers, url: result.url, - bodyLength: result.body.length, + bodyLength: result.body.byteLength, }); } } diff --git a/src/commands/curl/tests/availability.test.ts b/src/commands/curl/tests/availability.test.ts index 30280e29..036f3979 100644 --- a/src/commands/curl/tests/availability.test.ts +++ b/src/commands/curl/tests/availability.test.ts @@ -73,7 +73,7 @@ describe("curl availability", () => { const customFetch: SecureFetch = vi.fn().mockResolvedValue({ status: 200, headers: {}, - body: "custom response", + body: new TextEncoder().encode("custom response"), url: "https://example.com", }); const env = new Bash({ fetch: customFetch }); @@ -86,7 +86,7 @@ describe("curl availability", () => { const customFetch: SecureFetch = vi.fn().mockResolvedValue({ status: 200, headers: { "content-type": "application/json" }, - body: '{"ok":true}', + body: new TextEncoder().encode('{"ok":true}'), url: "https://example.com/api", }); const env = new Bash({ fetch: customFetch }); @@ -103,7 +103,7 @@ describe("curl availability", () => { const customFetch: SecureFetch = vi.fn().mockResolvedValue({ status: 200, headers: {}, - body: "from custom fetch", + body: new TextEncoder().encode("from custom fetch"), url: "https://example.com", }); const env = new Bash({ diff --git a/src/commands/curl/tests/binary.test.ts b/src/commands/curl/tests/binary.test.ts index f485a635..787eece4 100644 --- a/src/commands/curl/tests/binary.test.ts +++ b/src/commands/curl/tests/binary.test.ts @@ -67,10 +67,9 @@ describe("curl binary data", () => { }); it("handles binary response with high bytes", async () => { - // Simulate high byte values (as they would appear in text) - const binaryData = String.fromCharCode(0xff, 0xfe, 0x00, 0x01); + const bytes = new Uint8Array([0xff, 0xfe, 0x00, 0x01]); global.fetch = vi.fn(async () => { - return new Response(binaryData, { + return new Response(bytes, { status: 200, headers: { "content-type": "application/octet-stream" }, }); @@ -81,7 +80,24 @@ describe("curl binary data", () => { }); const result = await env.exec("curl https://api.example.com/binary"); - expect(result.stdout).toBe(binaryData); + expect(result.stdout).toBe(String.fromCharCode(0xff, 0xfe, 0x00, 0x01)); + }); + + it("writes raw JPEG magic to file with -o", async () => { + const jpegSoi = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); + global.fetch = vi.fn(async () => { + return new Response(jpegSoi, { + status: 200, + headers: { "content-type": "image/jpeg" }, + }); + }) as typeof fetch; + + const env = new Bash({ + network: { allowedUrlPrefixes: ["https://api.example.com"] }, + }); + await env.exec("curl -o /out.jpg https://api.example.com/img"); + const written = await env.fs.readFileBuffer("/out.jpg"); + expect(written).toEqual(jpegSoi); }); }); diff --git a/src/commands/python3/worker.ts b/src/commands/python3/worker.ts index 1bbb8595..b2d82eff 100644 --- a/src/commands/python3/worker.ts +++ b/src/commands/python3/worker.ts @@ -1093,6 +1093,8 @@ function generateHttpBridgeCode(): string { # Write request JSON to /_jb_http/request (custom FS triggers HTTP via SharedArrayBuffer) # Then read response JSON from same path. +import base64 as _base64 + class _JbHttpResponse: """HTTP response object similar to requests.Response""" def __init__(self, data): @@ -1100,9 +1102,15 @@ class _JbHttpResponse: self.reason = data.get('statusText', '') # @banned-pattern-ignore: Python code, not JavaScript self.headers = data.get('headers', {}) - self.text = data.get('body', '') self.url = data.get('url', '') self._error = data.get('error') + b64 = data.get('bodyBase64') + if b64 is not None: + self.content = _base64.b64decode(b64) + self.text = self.content.decode('utf-8', errors='replace') + else: + self.content = b'' + self.text = data.get('body', '') @property def ok(self): diff --git a/src/commands/worker-bridge/bridge-handler.ts b/src/commands/worker-bridge/bridge-handler.ts index 9a2020a3..cb307a09 100644 --- a/src/commands/worker-bridge/bridge-handler.ts +++ b/src/commands/worker-bridge/bridge-handler.ts @@ -5,6 +5,7 @@ * requests from a worker thread via SharedArrayBuffer + Atomics. */ +import { fromBuffer } from "../../fs/encoding.js"; import type { IFileSystem } from "../../fs/interface.js"; import { sanitizeErrorMessage } from "../../fs/real-fs-utils.js"; import { shellJoinArgs } from "../../helpers/shell-quote.js"; @@ -516,7 +517,7 @@ export class BridgeHandler { status: result.status, statusText: result.statusText, headers: result.headers, - body: result.body, + bodyBase64: fromBuffer(result.body, "base64"), url: result.url, }); this.protocol.setResultFromString(response); diff --git a/src/commands/worker-bridge/sync-backend.ts b/src/commands/worker-bridge/sync-backend.ts index 5e8d4fe0..5579a8a1 100644 --- a/src/commands/worker-bridge/sync-backend.ts +++ b/src/commands/worker-bridge/sync-backend.ts @@ -241,7 +241,21 @@ export class SyncBackend { throw new Error(result.error || "HTTP request failed"); } const responseJson = new TextDecoder().decode(result.result); - return JSON.parse(responseJson); + const parsed = JSON.parse(responseJson) as { + status: number; + statusText: string; + headers: Record; + url: string; + bodyBase64: string; + }; + const body = atob(parsed.bodyBase64 ?? ""); + return { + status: parsed.status, + statusText: parsed.statusText, + headers: parsed.headers, + url: parsed.url, + body, + }; } /** diff --git a/src/network/fetch.ts b/src/network/fetch.ts index b5a5e50f..a9c1aa49 100644 --- a/src/network/fetch.ts +++ b/src/network/fetch.ts @@ -359,28 +359,36 @@ async function responseToResult( } } - // Read body with size tracking - let body: string; + // Read body as raw bytes (never UTF-8 decode — preserves JPEG, etc.) + let body: Uint8Array; if (maxResponseSize > 0 && response.body) { const reader = response.body.getReader(); - const decoder = new TextDecoder(); - const chunks: string[] = []; + const chunks: Uint8Array[] = []; let totalSize = 0; while (true) { const { done, value } = await reader.read(); if (done) break; + if (!value) continue; totalSize += value.byteLength; if (totalSize > maxResponseSize) { reader.cancel(); throw new ResponseTooLargeError(maxResponseSize); } - chunks.push(decoder.decode(value, { stream: true })); + chunks.push(value); + } + body = new Uint8Array(totalSize); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; } - chunks.push(decoder.decode()); - body = chunks.join(""); } else { - body = await response.text(); + const ab = await response.arrayBuffer(); + if (maxResponseSize > 0 && ab.byteLength > maxResponseSize) { + throw new ResponseTooLargeError(maxResponseSize); + } + body = new Uint8Array(ab); } return { diff --git a/src/network/types.ts b/src/network/types.ts index 1da64e3a..5664fa1e 100644 --- a/src/network/types.ts +++ b/src/network/types.ts @@ -140,7 +140,8 @@ export interface FetchResult { status: number; statusText: string; headers: Record; - body: string; + /** Raw response bytes (never decoded as UTF-8 text). */ + body: Uint8Array; url: string; }