Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions src/commands/curl/curl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, string>;
body: string;
body: Uint8Array;
url: string;
},
requestUrl: string,
Expand Down Expand Up @@ -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 {
Expand All @@ -164,7 +167,7 @@ function buildOutput(
status: result.status,
headers: result.headers,
url: result.url,
bodyLength: result.body.length,
bodyLength: result.body.byteLength,
});
}

Expand Down Expand Up @@ -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,
});
}
}
Expand Down
6 changes: 3 additions & 3 deletions src/commands/curl/tests/availability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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 });
Expand All @@ -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({
Expand Down
24 changes: 20 additions & 4 deletions src/commands/curl/tests/binary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
});
Expand All @@ -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);
});
});

Expand Down
10 changes: 9 additions & 1 deletion src/commands/python3/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1093,16 +1093,24 @@ 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):
self.status_code = data.get('status', 0)
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):
Expand Down
3 changes: 2 additions & 1 deletion src/commands/worker-bridge/bridge-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 15 additions & 1 deletion src/commands/worker-bridge/sync-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
url: string;
bodyBase64: string;
};
const body = atob(parsed.bodyBase64 ?? "");
return {
status: parsed.status,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Python HTTP responses have empty content (bytes) because bodyBase64 is stripped by sync-backend before reaching the Python worker.

Fix on Vercel

statusText: parsed.statusText,
headers: parsed.headers,
url: parsed.url,
body,
};
}

/**
Expand Down
24 changes: 16 additions & 8 deletions src/network/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion src/network/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@ export interface FetchResult {
status: number;
statusText: string;
headers: Record<string, string>;
body: string;
/** Raw response bytes (never decoded as UTF-8 text). */
body: Uint8Array;
url: string;
}

Expand Down
Loading