diff --git a/native/v8-runtime/build.rs b/native/v8-runtime/build.rs index bad03ff22..5cbcc5cca 100644 --- a/native/v8-runtime/build.rs +++ b/native/v8-runtime/build.rs @@ -2,6 +2,20 @@ use std::env; use std::fs; use std::path::{Path, PathBuf}; +/// The ICU major version that the bundled `icudtl.dat` was built for. +/// Update this constant (and the `icudtl.dat` file) when upgrading the V8 +/// crate to a version that ships a different ICU. +/// +/// To update: +/// 1. Check the new ICU version in the V8 crate's +/// `third_party/icu/source/common/unicode/uvernum.h` (U_ICU_VERSION_MAJOR_NUM). +/// 2. Download the matching full ICU data from: +/// https://github.com/unicode-org/icu/releases/download/release-{MAJOR}-{MINOR}/icu4c-{MAJOR}_{MINOR}-data-bin-l.zip +/// 3. Extract `icudt{MAJOR}l.dat`, rename to `icudtl.dat`, and place it +/// in this directory (`native/v8-runtime/icudtl.dat`). +/// 4. Update `BUNDLED_ICU_MAJOR_VERSION` below. +const BUNDLED_ICU_MAJOR_VERSION: u32 = 74; + fn cargo_home() -> PathBuf { if let Some(home) = env::var_os("CARGO_HOME") { return PathBuf::from(home); @@ -34,11 +48,64 @@ fn read_v8_version(lock_path: &Path) -> String { panic!("failed to locate v8 version in {}", lock_path.display()); } -fn find_v8_icu_data(v8_version: &str) -> PathBuf { +/// Read U_ICU_VERSION_MAJOR_NUM from the V8 crate's uvernum.h header. +fn read_v8_icu_major_version(v8_version: &str) -> Option { + let registry_src = cargo_home().join("registry").join("src"); + let entries = fs::read_dir(®istry_src).ok()?; + + for entry in entries.flatten() { + let header = entry + .path() + .join(format!("v8-{}", v8_version)) + .join("third_party/icu/source/common/unicode/uvernum.h"); + if let Ok(content) = fs::read_to_string(&header) { + for line in content.lines() { + if line.contains("U_ICU_VERSION_MAJOR_NUM") && !line.contains("ifndef") { + if let Some(num) = line.split_whitespace().last() { + return num.parse().ok(); + } + } + } + } + } + + None +} + +fn find_v8_icu_data(v8_version: &str, manifest_dir: &Path) -> PathBuf { + // Prefer the full ICU data bundled in the repo. The V8 crate only ships a + // stripped-down flutter_desktop/icudtl.dat (~1.6 MB) that excludes locale + // data for NumberFormat, DateTimeFormat, and most non-English locales, + // causing "Internal error. Icu error." at runtime. + let bundled = manifest_dir.join("icudtl.dat"); + if bundled.exists() { + // Verify the bundled data matches the V8 crate's ICU version. + if let Some(v8_icu_major) = read_v8_icu_major_version(v8_version) { + if v8_icu_major != BUNDLED_ICU_MAJOR_VERSION { + panic!( + "\n\n\ + *** ICU version mismatch ***\n\ + The V8 crate (v8-{v8}) uses ICU {v8_icu}, but the bundled icudtl.dat \ + is for ICU {bundled}.\n\n\ + To fix:\n \ + 1. Download: https://github.com/unicode-org/icu/releases/download/\ + release-{v8_icu}-1/icu4c-{v8_icu}_1-data-bin-l.zip\n \ + 2. Extract the .dat file and save as native/v8-runtime/icudtl.dat\n \ + 3. Update BUNDLED_ICU_MAJOR_VERSION to {v8_icu} in build.rs\n\n", + v8 = v8_version, + v8_icu = v8_icu_major, + bundled = BUNDLED_ICU_MAJOR_VERSION, + ); + } + } + return bundled; + } + + // Fallback: search the V8 crate in the cargo registry. let registry_src = cargo_home().join("registry").join("src"); let candidates = [ - Path::new("third_party/icu/common/icudtl.dat"), Path::new("third_party/icu/flutter_desktop/icudtl.dat"), + Path::new("third_party/icu/common/icudtl.dat"), Path::new("third_party/icu/chromecast_video/icudtl.dat"), ]; @@ -57,6 +124,11 @@ fn find_v8_icu_data(v8_version: &str) -> PathBuf { for relative in candidates { let candidate = crate_root.join(relative); if candidate.exists() { + println!( + "cargo:warning=Using stripped ICU data from V8 crate. \ + Intl.NumberFormat/DateTimeFormat may fail for non-English locales. \ + See native/v8-runtime/build.rs for instructions on bundling full ICU data." + ); return candidate; } } @@ -77,9 +149,10 @@ fn main() { println!("cargo:rerun-if-changed={}", lock_path.display()); println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=icudtl.dat"); let v8_version = read_v8_version(&lock_path); - let icu_data = find_v8_icu_data(&v8_version); + let icu_data = find_v8_icu_data(&v8_version, &manifest_dir); let dest_path = out_dir.join("icudtl.dat"); fs::copy(&icu_data, &dest_path).unwrap_or_else(|error| { diff --git a/native/v8-runtime/icudtl.dat b/native/v8-runtime/icudtl.dat new file mode 100644 index 000000000..c9b2f3ca7 Binary files /dev/null and b/native/v8-runtime/icudtl.dat differ diff --git a/native/v8-runtime/npm/darwin-arm64/package.json b/native/v8-runtime/npm/darwin-arm64/package.json index 09eff2f6a..8343fa168 100644 --- a/native/v8-runtime/npm/darwin-arm64/package.json +++ b/native/v8-runtime/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@firestartorg/secure-exec-v8-darwin-arm64", - "version": "0.2.2", + "version": "0.2.3-rc.7", "license": "Apache-2.0", "publishConfig": { "registry": "https://npm.pkg.github.com" diff --git a/native/v8-runtime/npm/darwin-x64/package.json b/native/v8-runtime/npm/darwin-x64/package.json index 005e0b93f..30756ff71 100644 --- a/native/v8-runtime/npm/darwin-x64/package.json +++ b/native/v8-runtime/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@firestartorg/secure-exec-v8-darwin-x64", - "version": "0.2.2", + "version": "0.2.3-rc.7", "license": "Apache-2.0", "publishConfig": { "registry": "https://npm.pkg.github.com" diff --git a/native/v8-runtime/npm/linux-arm64-gnu/package.json b/native/v8-runtime/npm/linux-arm64-gnu/package.json index b13733462..21a508e9d 100644 --- a/native/v8-runtime/npm/linux-arm64-gnu/package.json +++ b/native/v8-runtime/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@firestartorg/secure-exec-v8-linux-arm64-gnu", - "version": "0.2.2", + "version": "0.2.3-rc.7", "license": "Apache-2.0", "publishConfig": { "registry": "https://npm.pkg.github.com" diff --git a/native/v8-runtime/npm/linux-x64-gnu/package.json b/native/v8-runtime/npm/linux-x64-gnu/package.json index 9d777a2b6..8cf1bbbfd 100644 --- a/native/v8-runtime/npm/linux-x64-gnu/package.json +++ b/native/v8-runtime/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@firestartorg/secure-exec-v8-linux-x64-gnu", - "version": "0.2.2", + "version": "0.2.3-rc.7", "license": "Apache-2.0", "publishConfig": { "registry": "https://npm.pkg.github.com" diff --git a/native/v8-runtime/npm/win32-x64/package.json b/native/v8-runtime/npm/win32-x64/package.json index 21c03749d..ad6419524 100644 --- a/native/v8-runtime/npm/win32-x64/package.json +++ b/native/v8-runtime/npm/win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "@firestartorg/secure-exec-v8-win32-x64", - "version": "0.2.2", + "version": "0.2.3-rc.7", "license": "Apache-2.0", "publishConfig": { "registry": "https://npm.pkg.github.com" diff --git a/package.json b/package.json index 0f21625b8..67c31d17a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firestart-secure-exec-monorepo", - "version": "0.2.2", + "version": "0.2.3-rc.7", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/core/package.json b/packages/core/package.json index 883403e68..81ce8e9ca 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@firestartorg/secure-exec-core", - "version": "0.2.2", + "version": "0.2.3-rc.7", "type": "module", "license": "Apache-2.0", "main": "./dist/index.js", diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index 341985c9c..a5738c205 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "@firestartorg/secure-exec-nodejs", - "version": "0.2.2", + "version": "0.2.3-rc.7", "type": "module", "license": "Apache-2.0", "main": "./dist/index.js", diff --git a/packages/nodejs/src/bridge-handlers.ts b/packages/nodejs/src/bridge-handlers.ts index 0a63b5c46..47d7baa50 100644 --- a/packages/nodejs/src/bridge-handlers.ts +++ b/packages/nodejs/src/bridge-handlers.ts @@ -4145,16 +4145,12 @@ async function maybeDecompressHttpBody( } function shouldEncodeHttpBodyAsBinary( - urlString: string, - headers: http.IncomingHttpHeaders, + _urlString: string, + _headers: http.IncomingHttpHeaders, ): boolean { - const contentType = headers["content-type"] || ""; - const headerValue = Array.isArray(contentType) ? contentType.join(", ") : contentType; - return ( - headerValue.includes("octet-stream") || - headerValue.includes("gzip") || - urlString.endsWith(".tgz") - ); + // Always encode response bodies as base64 across the bridge to prevent + // binary corruption. The isolate decodes via Buffer.from(body, "base64"). + return true; } /** diff --git a/packages/nodejs/src/bridge/network.ts b/packages/nodejs/src/bridge/network.ts index 764a8bc31..ff250b185 100644 --- a/packages/nodejs/src/bridge/network.ts +++ b/packages/nodejs/src/bridge/network.ts @@ -328,8 +328,17 @@ function encodeFetchBody( body: string, bodyEncoding: string | null, ): Uint8Array { - if (bodyEncoding === "base64" && typeof Buffer !== "undefined") { - return new Uint8Array(Buffer.from(body, "base64")); + if (bodyEncoding === "base64") { + if (typeof Buffer !== "undefined") { + return new Uint8Array(Buffer.from(body, "base64")); + } + // Fallback base64 decode via atob + const binary = atob(body); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; } if (typeof TextEncoder !== "undefined") { return new TextEncoder().encode(body); @@ -504,16 +513,22 @@ export async function fetch(input: string | URL | Request, options: FetchOptions await sendBytes(bytes); } } else { - // Node.js Readable stream + // Node.js Readable stream — pause/resume for backpressure await new Promise((resolve, reject) => { const stream = rawBody as any; + let pending: Promise = Promise.resolve(); stream.on("data", (data: any) => { const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data instanceof Uint8Array ? data : new Uint8Array(data); - sendBytes(bytes); + stream.pause(); + pending = pending.then(() => sendBytes(bytes)).then(() => { + stream.resume(); + }); + }); + stream.on("end", () => { + pending.then(() => resolve(), reject); }); - stream.on("end", () => resolve()); stream.on("error", (err: any) => reject(err)); }); } @@ -635,16 +650,27 @@ export async function fetch(input: string | URL | Request, options: FetchOptions }, async text(): Promise { - if (bodyEncoding === "base64" && typeof Buffer !== "undefined") { - return Buffer.from(responseBody, "base64").toString("utf8"); + if (bodyEncoding === "base64") { + if (typeof Buffer !== "undefined") { + return Buffer.from(responseBody, "base64").toString("utf8"); + } + const binary = atob(responseBody); + return new TextDecoder().decode(Uint8Array.from(binary, c => c.charCodeAt(0))); } return responseBody; }, async json(): Promise { - const textBody = - bodyEncoding === "base64" && typeof Buffer !== "undefined" - ? Buffer.from(responseBody, "base64").toString("utf8") - : responseBody; + let textBody: string; + if (bodyEncoding === "base64") { + if (typeof Buffer !== "undefined") { + textBody = Buffer.from(responseBody, "base64").toString("utf8"); + } else { + const binary = atob(responseBody); + textBody = new TextDecoder().decode(Uint8Array.from(binary, c => c.charCodeAt(0))); + } + } else { + textBody = responseBody; + } return JSON.parse(textBody || "{}"); }, async arrayBuffer(): Promise { @@ -767,7 +793,7 @@ export class Request { // Response class export class Response { - private _body: string | null; + private _body: string | Uint8Array | null; status: number; statusText: string; headers: Headers; @@ -776,8 +802,19 @@ export class Response { url: string; redirected: boolean; - constructor(body?: string | null, init: { status?: number; statusText?: string; headers?: Record } = {}) { - this._body = body || null; + constructor(body?: string | ReadableStream | ArrayBuffer | Uint8Array | null, init: { status?: number; statusText?: string; headers?: Record } = {}) { + if (body === null || body === undefined) { + this._body = null; + } else if (typeof body === "string") { + this._body = body; + } else if (body instanceof Uint8Array) { + this._body = body; + } else if (body instanceof ArrayBuffer) { + this._body = new Uint8Array(body); + } else { + // ReadableStream — store reference, will be consumed lazily + this._body = body as any; + } this.status = init.status || 200; this.statusText = init.statusText || "OK"; this.headers = new Headers(init.headers); @@ -787,26 +824,59 @@ export class Response { this.redirected = false; } + private async _bytes(): Promise { + const body = this._body; + if (body === null) return new Uint8Array(0); + if (body instanceof Uint8Array) return body; + if (typeof body === "string") return new TextEncoder().encode(body); + // ReadableStream + const reader = (body as any).getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value instanceof Uint8Array ? value : new Uint8Array(value)); + } + const total = chunks.reduce((s: number, c: Uint8Array) => s + c.length, 0); + const result = new Uint8Array(total); + let off = 0; + for (const c of chunks) { result.set(c, off); off += c.length; } + // Cache for subsequent calls + (this as any)._body = result; + return result; + } + async text(): Promise { - return String(this._body || ""); + const bytes = await this._bytes(); + return new TextDecoder().decode(bytes); } async json(): Promise { - return JSON.parse(this._body || "{}"); + return JSON.parse(await this.text()); + } + + async arrayBuffer(): Promise { + const bytes = await this._bytes(); + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + } + + async blob(): Promise { + const bytes = await this._bytes(); + return new Blob([bytes as any], { type: this.headers.get("content-type") || "" }); } get body(): { getReader(): { read(): Promise<{ done: boolean; value?: Uint8Array }> } } | null { - const bodyStr = this._body; - if (bodyStr === null) return null; + const self = this; + if (this._body === null) return null; return { getReader() { let consumed = false; return { async read() { - if (consumed) return { done: true }; + if (consumed) return { done: true as const }; consumed = true; - const encoder = new TextEncoder(); - return { done: false, value: encoder.encode(bodyStr) }; + const bytes = await self._bytes(); + return { done: false as const, value: bytes }; }, }; }, @@ -814,7 +884,7 @@ export class Response { } clone(): Response { - return new Response(this._body, { status: this.status, statusText: this.statusText }); + return new Response(this._body as any, { status: this.status, statusText: this.statusText }); } static error(): Response { diff --git a/packages/secure-exec/package.json b/packages/secure-exec/package.json index 6668f8869..4240a9a57 100644 --- a/packages/secure-exec/package.json +++ b/packages/secure-exec/package.json @@ -1,6 +1,6 @@ { "name": "@firestartorg/secure-exec", - "version": "0.2.2", + "version": "0.2.3-rc.7", "type": "module", "license": "Apache-2.0", "main": "./dist/index.js", diff --git a/packages/secure-exec/tests/projects/astro-pass/package.json b/packages/secure-exec/tests/projects/astro-pass/package.json index c5405f6df..177ded2cb 100644 --- a/packages/secure-exec/tests/projects/astro-pass/package.json +++ b/packages/secure-exec/tests/projects/astro-pass/package.json @@ -4,7 +4,7 @@ "type": "commonjs", "dependencies": { "@astrojs/react": "3.6.2", - "astro": "4.15.9", + "astro": "6.1.10", "react": "18.3.1", "react-dom": "18.3.1" } diff --git a/packages/secure-exec/tests/runtime-driver/node/bridge-hardening.test.ts b/packages/secure-exec/tests/runtime-driver/node/bridge-hardening.test.ts index cc72f97d8..79e816ef1 100644 --- a/packages/secure-exec/tests/runtime-driver/node/bridge-hardening.test.ts +++ b/packages/secure-exec/tests/runtime-driver/node/bridge-hardening.test.ts @@ -299,7 +299,7 @@ describe("bridge-side resource hardening", () => { const capture = createConsoleCapture(); proc = createTestNodeRuntime({ onStdio: capture.onStdio, - cpuTimeMs: 200, + cpuTimeLimitMs: 200, }); const result = await proc.exec(` @@ -692,6 +692,87 @@ describe("bridge-side resource hardening", () => { }); }); + describe("Intl locale support (full ICU)", () => { + it("Intl.NumberFormat formats with en-US locale", async () => { + const capture = createConsoleCapture(); + proc = createTestNodeRuntime({ onStdio: capture.onStdio }); + + const result = await proc.exec(` + const formatted = new Intl.NumberFormat("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(1234.56); + console.log(formatted); + `); + + expect(result.code).toBe(0); + expect(capture.stdout().trim()).toBe("1,234.56"); + }); + + it("Intl.NumberFormat formats with non-English locale (de-DE)", async () => { + const capture = createConsoleCapture(); + proc = createTestNodeRuntime({ onStdio: capture.onStdio }); + + const result = await proc.exec(` + const formatted = new Intl.NumberFormat("de-DE", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(1234.56); + console.log(formatted); + `); + + expect(result.code).toBe(0); + expect(capture.stdout().trim()).toBe("1.234,56"); + }); + + it("Number.toLocaleString works with non-English locale (de-DE)", async () => { + const capture = createConsoleCapture(); + proc = createTestNodeRuntime({ onStdio: capture.onStdio }); + + const result = await proc.exec(` + const formatted = (5000).toLocaleString("de-DE", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + console.log(formatted); + `); + + expect(result.code).toBe(0); + expect(capture.stdout().trim()).toBe("5.000,00"); + }); + + it("Intl.DateTimeFormat works with non-English locale (de-DE)", async () => { + const capture = createConsoleCapture(); + proc = createTestNodeRuntime({ onStdio: capture.onStdio }); + + const result = await proc.exec(` + const formatted = new Intl.DateTimeFormat("de-DE", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date("2025-03-01T00:00:00Z")); + console.log(formatted); + `); + + expect(result.code).toBe(0); + expect(capture.stdout().trim()).toBe("01.03.2025"); + }); + + it("Intl.Collator sorts with non-English locale (de-DE)", async () => { + const capture = createConsoleCapture(); + proc = createTestNodeRuntime({ onStdio: capture.onStdio }); + + const result = await proc.exec(` + const collator = new Intl.Collator("de-DE"); + const sorted = ["Österreich", "Andorra", "Ägypten"].sort(collator.compare); + console.log(JSON.stringify(sorted)); + `); + + expect(result.code).toBe(0); + expect(JSON.parse(capture.stdout().trim())).toEqual(["Ägypten", "Andorra", "Österreich"]); + }); + }); + describe("process.kill signal handling", () => { it("process.kill(process.pid, 'SIGINT') exits with 130", async () => { const capture = createConsoleCapture(); diff --git a/packages/typescript/package.json b/packages/typescript/package.json index 71b332392..8c50ff54b 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@secure-exec/typescript", - "version": "0.2.2", + "version": "0.2.3-rc.7", "private": true, "type": "module", "license": "Apache-2.0", diff --git a/packages/v8/package.json b/packages/v8/package.json index 9685c9911..16621c292 100644 --- a/packages/v8/package.json +++ b/packages/v8/package.json @@ -1,6 +1,6 @@ { "name": "@firestartorg/secure-exec-v8", - "version": "0.2.2", + "version": "0.2.3-rc.7", "type": "module", "license": "Apache-2.0", "main": "./dist/index.js", @@ -33,10 +33,10 @@ "typescript": "^5.7.2" }, "optionalDependencies": { - "@firestartorg/secure-exec-v8-darwin-arm64": "0.2.1", - "@firestartorg/secure-exec-v8-darwin-x64": "0.2.1", - "@firestartorg/secure-exec-v8-linux-arm64-gnu": "0.2.1", - "@firestartorg/secure-exec-v8-linux-x64-gnu": "0.2.1" + "@firestartorg/secure-exec-v8-darwin-arm64": "0.2.3-rc.7", + "@firestartorg/secure-exec-v8-darwin-x64": "0.2.3-rc.7", + "@firestartorg/secure-exec-v8-linux-arm64-gnu": "0.2.3-rc.7", + "@firestartorg/secure-exec-v8-linux-x64-gnu": "0.2.3-rc.7" }, "dependencies": { "cbor-x": "^1.6.4"