diff --git a/README.md b/README.md index 97ad7e3..9400e4a 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Install the package in your web project, then follow the [complete mobile setup](docs/integration.md#workflow): ```sh -npm install --save-exact cf-workers-native@0.1.4 +npm install --save-exact cf-workers-native@0.1.5 ``` Installation alone does not generate a mobile project. The guide covers adding @@ -84,7 +84,7 @@ Open your application repository and give your assistant this prompt: ```text Read https://github.com/rdlabo-dev/cf-workers-native/blob/main/docs/integration.md. -Add an iOS and Android version of this Workers web app using cf-workers-native@0.1.4 +Add an iOS and Android version of this Workers web app using cf-workers-native@0.1.5 and Capacitor. Preserve the existing pages, backend and Cloudflare deployment configuration. Use an existing test Worker for cloud-backed requests; ask before any cloud deployment. Start by verifying one page, navigation and a form or data action on a simulator/emulator. diff --git a/demo/package.json b/demo/package.json index 1788075..c994bc4 100644 --- a/demo/package.json +++ b/demo/package.json @@ -75,6 +75,7 @@ "typescript": "^7.0.2", "vite": "^7.3.7", "vitest": "^5.0.5", - "wrangler": "^4.151.0" + "wrangler": "^4.151.0", + "ws": "8.21.0" } } diff --git a/demo/tests/experiments/node-compat/README.md b/demo/tests/experiments/node-compat/README.md new file mode 100644 index 0000000..be024e1 --- /dev/null +++ b/demo/tests/experiments/node-compat/README.md @@ -0,0 +1,45 @@ +# Pinned Node compatibility experiments + +`exports.json` pins the workerd package and compatibility date. The public +expansion probe runs shared operation contracts through both build plugins, +Chromium/WebKit, page and Dedicated Worker. Surface inventory results are separate +from behavioral acceptance. + +From the repository root: + +```sh +node --test demo/tests/experiments/node-compat/expansion-contracts.probe.mjs +node demo/tests/experiments/node-compat/prepare-events-consumer.mjs +CONSUMER_PACKAGE=/absolute/path/to/prepared.tgz npm run test:package +node demo/tests/experiments/node-compat/expansion-native.mjs ios +node demo/tests/experiments/node-compat/expansion-native.mjs android +``` + +Run the native commands sequentially: they share the independent installed +consumer. Set the Android SDK and `adb` PATH as described in the native test +procedure. Preparation writes the exact tarball path and SHA-256 to +`demo/artifacts/node-inventory/events-consumer.json`; the native runner checks that +hash before compiling the consumer. Do not rebuild repository `dist` while a +public browser probe reads it. + +Native networking needs OpenSSL and Python3. Local HTTP/HTTPS, TLS, STARTTLS and +WebSocket peers use ephemeral loopback ports. The Python TLS peer explicitly calls +`unwrap()` to send close_notify; separate peers end or reset the TCP connection +without that TLS notification. The generated test CA is confined to the debug +consumer/simulator trust configuration. Workerd uses the same CA and fixture +ports. FS lifetime probes use two real workerd requests and distinct native +request scopes. + +Page cases run in independent iframe realms and remove each frame on completion. +Dedicated Workers terminate after each case. This permits cold Wasm measurements +without retaining a module instance for every case in one page. Progress and +failure records identify the active case. The overall native deadline is separate +from individual case and socket deadlines. + +`NODE_COMPAT_CASES=name,otherName` filters shared cases for diagnosis. It does not +filter native network rows, and a filtered run is not full acceptance. Native +`stage:complete` means all observations were collected; exact assertions run +subsequently and may still fail. Current TLS and WebSocket implementation gaps +remain failures, not skipped or accepted differences. The reports, generated +harness, compiled hash and workerd baseline live under +`demo/artifacts/node-inventory/native-expansion//`. diff --git a/demo/tests/experiments/node-compat/completion-report.mjs b/demo/tests/experiments/node-compat/completion-report.mjs index 56131e0..024c173 100644 --- a/demo/tests/experiments/node-compat/completion-report.mjs +++ b/demo/tests/experiments/node-compat/completion-report.mjs @@ -23,7 +23,7 @@ const assessments = [ modules: ["crypto", "zlib"], priority: 2, category: "implementation-gap", - next: "Inventory algorithms, key/codec classes, synchronous and streaming behavior. Evaluate JS/Wasm implementations against all pinned operations; random/hash/HKDF/scrypt, owned secret/asymmetric keys, RSA/EC/Ed25519 signatures, prime generation/checking X509Certificate parsing/fields/name checks/signature checks and Brotli/Zstd profiles now supplement dependency polyfills. Pinned Workers X509 key-check restrictions are documented. Additional key-generation options, remaining algorithms and complete codec contracts remain open. No technical-impossibility claim has been established.", + next: "Inventory algorithms, key/codec classes, synchronous and streaming behavior. Evaluate JS/Wasm implementations against all pinned operations; random/Hash/HMAC/PBKDF2/HKDF/scrypt, owned secret/asymmetric keys, RSA/EC/Ed25519 signatures, prime generation/checking X509Certificate parsing/fields/name checks/signature checks and cipher metadata lookup now supplement dependency polyfills. Gzip/deflate now use zlib Wasm; all codec streams use the pinned Workers lifecycle, with option, reset, close, callback and output-limit contracts. Pinned Workers X509 key-check restrictions are documented. The pinned 17 digest names, copying, finalization and KDF length limits have shared contracts; Float16Array input-brand coverage depends on engine availability recorded in native measurements. Additional key-generation options, remaining algorithms and complete codec contracts remain open. No technical-impossibility claim has been established.", evidence: [ "surface-gaps.json", "expansion-contracts.probe.mjs", @@ -35,7 +35,7 @@ const assessments = [ modules: ["fs", "fs/promises"], priority: 2, category: "implementation-gap", - next: "Compare the Workers virtual filesystem, descriptors, FileHandle, writes, permissions and watch behavior. Request-local memory-backed /tmp now supports writes, descriptors and FileHandle; permission no-ops, statfs and embedded-file copying are tested; compare remaining cross-volume operations, descriptors and stream semantics.", + next: "Compare the Workers virtual filesystem, descriptors, FileHandle, writes, permissions and watch behavior. Request-local memory-backed /tmp now supports writes, descriptors and FileHandle; permission no-ops, statfs and embedded-file copying are tested; embedded descriptors/FileHandles, links, immutable copies, cp options, millisecond timestamps and readonly metadata are also compared. Devices, directory iteration, glob, stream fd/custom-fs options, byte streams, URL normalization, FD reuse and request-local cwd now have upstream and comparative coverage. Complete the remaining per-operation/option inventory and all-platform acceptance; these cases do not certify the whole module.", evidence: [ "../../../../src/compat/fs/index.ts", "../../../../src/compat/fs/promises/index.ts", diff --git a/demo/tests/experiments/node-compat/expansion-contracts.probe.mjs b/demo/tests/experiments/node-compat/expansion-contracts.probe.mjs index 66b0dbd..9d36acd 100644 --- a/demo/tests/experiments/node-compat/expansion-contracts.probe.mjs +++ b/demo/tests/experiments/node-compat/expansion-contracts.probe.mjs @@ -9,9 +9,37 @@ import { build } from "esbuild"; import { chromium, webkit } from "@playwright/test"; import { Miniflare, convertV4MiniflareOptions } from "miniflare"; import { workersCapacitorPlugin } from "../../../../dist/tooling/esbuild.js"; -import { eventCases } from "../../../../src/compat/expansion/index.spec.mjs"; +import { eventCases as allEventCases } from "../../../../src/compat/expansion/index.spec.mjs"; + +import { + localBoundaryCases as allBoundaryCases, + localBoundaryExpected, +} from "../../../../src/compat/expansion/boundaries.spec.mjs"; +const requestedCases = process.env.NODE_COMPAT_CASES?.split(","); +if (requestedCases) + for (const name of requestedCases) + assert.ok( + Object.hasOwn(allEventCases, name) || + Object.hasOwn(allBoundaryCases, name), + `Unknown compatibility case: ${name}`, + ); +const eventCases = requestedCases + ? Object.fromEntries( + requestedCases + .filter((name) => Object.hasOwn(allEventCases, name)) + .map((name) => [name, allEventCases[name]]), + ) + : allEventCases; const root = fileURLToPath(new URL("../../../..", import.meta.url)); +const boundaryCases = requestedCases + ? Object.fromEntries( + requestedCases + .filter((name) => Object.hasOwn(allBoundaryCases, name)) + .map((name) => [name, allBoundaryCases[name]]), + ) + : allBoundaryCases; +const localCases = { ...eventCases, ...boundaryCases }; const profile = JSON.parse( await readFile(new URL("exports.json", import.meta.url), "utf8"), ).profile; @@ -24,23 +52,32 @@ assert.equal( ); const runtime = new Miniflare( convertV4MiniflareOptions({ - modules: true, compatibilityDate: profile.compatibilityDate, - script: `import * as tls from 'node:tls'; import * as streams from 'node:stream/web'; import * as module from 'node:module'; import * as httpCommon from 'node:_http_common'; import * as httpOutgoing from 'node:_http_outgoing'; import * as httpServer from 'node:_http_server'; import * as http from 'node:http'; import * as fs from 'node:fs'; import * as zlib from 'node:zlib'; import * as sys from 'node:sys'; import * as util from 'node:util'; import * as types from 'node:util/types'; import * as console from 'node:console'; import * as crypto from 'node:crypto'; + modulesRoot: "/fixture", + modules: [ + { + type: "ESModule", + path: "/fixture/main.mjs", + contents: `import * as tls from 'node:tls'; import * as classicStreams from 'node:stream'; import * as streams from 'node:stream/web'; import * as module from 'node:module'; import * as httpCommon from 'node:_http_common'; import * as httpOutgoing from 'node:_http_outgoing'; import * as httpServer from 'node:_http_server'; import * as http from 'node:http'; import * as cloudflareNode from 'cloudflare:node'; import * as fs from 'node:fs'; import * as zlib from 'node:zlib'; import * as sys from 'node:sys'; import * as util from 'node:util'; import * as types from 'node:util/types'; import * as console from 'node:console'; import * as crypto from 'node:crypto'; import { AsyncLocalStorage } from 'node:async_hooks'; import process from 'node:process'; const cases = {${Object.entries(eventCases) - .map(([name, fn]) => `${JSON.stringify(name)}: (${fn.toString()})`) - .join(",")}}; + .map(([name, fn]) => `${JSON.stringify(name)}: (${fn.toString()})`) + .join(",")}}; export default {async fetch(request) { const name = new URL(request.url).pathname.slice(1); - return Response.json(await cases[name]({process, tls, streams, sys, util, types, console, crypto, fs, zlib, module, httpCommon, httpOutgoing, httpServer, http})); + return Response.json(await cases[name]({process, tls, classicStreams, streams, sys, util, types, console, crypto, fs, zlib, module, httpCommon, httpOutgoing, httpServer, http, cloudflareNode})); }};`, + }, + { type: "Text", path: "/fixture/readonly.txt", contents: "abcdef" }, + { type: "Text", path: "/fixture/nested/child.txt", contents: "child" }, + ], }), ); const baseline = {}; try { for (const name of Object.keys(eventCases)) { + console.log(`Workers baseline: ${name}`); const response = await runtime.dispatchFetch( `https://events.invalid/${name}`, ); @@ -54,7 +91,12 @@ try { } finally { await runtime.dispose(); } -const output = new URL("../../../artifacts/node-inventory/", import.meta.url); +const output = new URL( + requestedCases + ? "../../../artifacts/node-inventory/targeted/" + : "../../../artifacts/node-inventory/", + import.meta.url, +); await mkdir(output, { recursive: true }); await writeFile( new URL("expansion-contracts-workerd.json", output), @@ -64,11 +106,13 @@ const compiled = await build({ absWorkingDir: root, stdin: { resolveDir: root, - contents: `import * as tls from 'node:tls'; import * as streams from 'node:stream/web'; import * as module from 'node:module'; import * as httpCommon from 'node:_http_common'; import * as httpOutgoing from 'node:_http_outgoing'; import * as httpServer from 'node:_http_server'; import * as http from 'node:http'; import * as fs from 'node:fs'; import * as zlib from 'node:zlib'; import * as sys from 'node:sys'; import * as util from 'node:util'; import * as types from 'node:util/types'; import * as console from 'node:console'; import * as crypto from 'node:crypto'; -import { eventCases } from './src/compat/expansion/index.spec.mjs'; + contents: `import * as tls from 'node:tls'; import * as classicStreams from 'node:stream'; import * as streams from 'node:stream/web'; import * as module from 'node:module'; import * as httpCommon from 'node:_http_common'; import * as httpOutgoing from 'node:_http_outgoing'; import * as httpServer from 'node:_http_server'; import * as http from 'node:http'; import * as cloudflareNode from 'cloudflare:node'; import * as fs from 'node:fs'; import * as zlib from 'node:zlib'; import * as sys from 'node:sys'; import * as util from 'node:util'; import * as types from 'node:util/types'; import * as console from 'node:console'; import * as crypto from 'node:crypto'; +import { eventCases } from './src/compat/expansion/index.spec.mjs'; import {localBoundaryCases} from './src/compat/expansion/boundaries.spec.mjs'; +import { runWithRequestScope as withRequestScope } from './dist/shared/request-scope.js'; import { AsyncLocalStorage } from 'node:async_hooks'; import process from 'node:process'; -export const run = name => eventCases[name]({process, tls, streams, sys, util, types, console, crypto, fs, zlib, module, httpCommon, httpOutgoing, httpServer, http});`, +fs.registerVirtualFiles({'/bundle/readonly.txt':'abcdef','/bundle/nested/child.txt':'child'}); +export const run = name => (eventCases[name] ?? localBoundaryCases[name])({withRequestScope,process, tls, classicStreams, streams, sys, util, types, console, crypto, fs, zlib, module, httpCommon, httpOutgoing, httpServer, http, cloudflareNode});`, }, bundle: true, platform: "browser", @@ -85,11 +129,14 @@ export const run = name => eventCases[name]({process, tls, streams, sys, util, t const entry = fileURLToPath(new URL("expansion-vite-entry.mjs", output)); await writeFile( entry, - `import * as tls from 'node:tls'; import * as streams from 'node:stream/web'; import * as module from 'node:module'; import * as httpCommon from 'node:_http_common'; import * as httpOutgoing from 'node:_http_outgoing'; import * as httpServer from 'node:_http_server'; import * as http from 'node:http'; import * as fs from 'node:fs'; import * as zlib from 'node:zlib'; import * as sys from 'node:sys'; import * as util from 'node:util'; import * as types from 'node:util/types'; import * as console from 'node:console'; import * as crypto from 'node:crypto'; + `import * as tls from 'node:tls'; import * as classicStreams from 'node:stream'; import * as streams from 'node:stream/web'; import * as module from 'node:module'; import * as httpCommon from 'node:_http_common'; import * as httpOutgoing from 'node:_http_outgoing'; import * as httpServer from 'node:_http_server'; import * as http from 'node:http'; import * as cloudflareNode from 'cloudflare:node'; import * as fs from 'node:fs'; import * as zlib from 'node:zlib'; import * as sys from 'node:sys'; import * as util from 'node:util'; import * as types from 'node:util/types'; import * as console from 'node:console'; import * as crypto from 'node:crypto'; import { eventCases } from ${JSON.stringify(fileURLToPath(new URL("../../../../src/compat/expansion/index.spec.mjs", import.meta.url)))}; +import {localBoundaryCases} from ${JSON.stringify(fileURLToPath(new URL("../../../../src/compat/expansion/boundaries.spec.mjs", import.meta.url)))}; +import { runWithRequestScope as withRequestScope } from ${JSON.stringify(fileURLToPath(new URL("../../../../dist/shared/request-scope.js", import.meta.url)))}; import { AsyncLocalStorage } from 'node:async_hooks'; import process from 'node:process'; -export const run = name => eventCases[name]({process, tls, streams, sys, util, types, console, crypto, fs, zlib, module, httpCommon, httpOutgoing, httpServer, http});`, +fs.registerVirtualFiles({'/bundle/readonly.txt':'abcdef','/bundle/nested/child.txt':'child'}); +export const run = name => (eventCases[name] ?? localBoundaryCases[name])({withRequestScope,process, tls, classicStreams, streams, sys, util, types, console, crypto, fs, zlib, module, httpCommon, httpOutgoing, httpServer, http, cloudflareNode});`, ); const viteResult = await viteBuild({ configFile: false, @@ -126,7 +173,7 @@ for (const [builder, source] of Object.entries({ const browser = await launcher.launch(); const results = {}; try { - for (const name of Object.keys(eventCases)) { + for (const name of Object.keys(localCases)) { await t.test(name, { timeout: 15000 }, async () => { const page = await browser.newPage(); await page.route("http://localhost/", (route) => @@ -186,7 +233,12 @@ for (const [builder, source] of Object.entries({ { source, name, mode }, ); results[name] = result; - assert.deepEqual(result, baseline[name]); + assert.deepEqual( + result, + Object.hasOwn(boundaryCases, name) + ? localBoundaryExpected[name] + : baseline[name], + ); } finally { await page.close(); } @@ -206,6 +258,7 @@ for (const [builder, source] of Object.entries({ engine, mode, status: "webview-profile-operation-contracts", + localBoundaryCases: Object.keys(boundaryCases), knownGaps: { process: "existing partial process profile; native stack inspection remains engine-dependent", diff --git a/demo/tests/experiments/node-compat/expansion-native.mjs b/demo/tests/experiments/node-compat/expansion-native.mjs index 4992149..54951d3 100644 --- a/demo/tests/experiments/node-compat/expansion-native.mjs +++ b/demo/tests/experiments/node-compat/expansion-native.mjs @@ -1,3 +1,4 @@ +import { resolve } from "node:path"; import { createServer as tcpServer } from "node:net"; import { TLSSocket, createSecureContext } from "node:tls"; import { execFileSync } from "node:child_process"; @@ -7,9 +8,24 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { createHash } from "node:crypto"; import { build } from "esbuild"; -import { eventCases } from "../../../../src/compat/expansion/index.spec.mjs"; +import { eventCases as allEventCases } from "../../../../src/compat/expansion/index.spec.mjs"; +import { + localBoundaryCases, + localBoundaryExpected, +} from "../../../../src/compat/expansion/boundaries.spec.mjs"; import { nativeBackend } from "../../native/backend.mjs"; import { runNativeAcceptance } from "../../native/acceptance.mjs"; +import { createLifecycleFixture } from "./network-lifecycle-fixture.mjs"; +import { networkLifecycleBaseline } from "./network-lifecycle-baseline.mjs"; +const requestedCases = process.env.NODE_COMPAT_CASES?.split(","); +if (requestedCases) + for (const name of requestedCases) + assert.ok(Object.hasOwn(allEventCases, name), `Unknown case: ${name}`); +const eventCases = requestedCases + ? Object.fromEntries( + requestedCases.map((name) => [name, allEventCases[name]]), + ) + : allEventCases; const [platform, device] = process.argv.slice(2); assert.ok( ["ios", "android"].includes(platform) && device, @@ -28,6 +44,16 @@ const baseline = JSON.parse( const profile = JSON.parse( await readFile(new URL("exports.json", import.meta.url), "utf8"), ).profile; +if (requestedCases) + for (const name of requestedCases) + assert.ok( + Object.hasOwn(baseline.results, name), + `Missing baseline case: ${name}`, + ); +if (requestedCases) + baseline.results = Object.fromEntries( + requestedCases.map((name) => [name, baseline.results[name]]), + ); assert.deepEqual(baseline.profile, profile); assert.deepEqual(Object.keys(baseline.results), Object.keys(eventCases)); const artifacts = fileURLToPath( @@ -68,7 +94,7 @@ await writeFile( compatibility_date: profile.compatibilityDate, }), ); -const { consumer, tarball, tarballSha256 } = JSON.parse( +const consumerEvidence = JSON.parse( await readFile( new URL( "../../../artifacts/node-inventory/events-consumer.json", @@ -77,6 +103,18 @@ const { consumer, tarball, tarballSha256 } = JSON.parse( "utf8", ), ); +const { consumer, tarball, tarballSha256 } = consumerEvidence; +assert.equal( + createHash("sha256") + .update(await readFile(tarball)) + .digest("hex"), + tarballSha256, +); +const { workersCapacitorPlugin: installedVitePlugin } = await import( + pathToFileURL( + `${consumer}/node_modules/cf-workers-native/dist/tooling/vite.js`, + ).href +); const { workersCapacitorPlugin } = await import( pathToFileURL( `${consumer}/node_modules/cf-workers-native/dist/tooling/esbuild.js`, @@ -86,13 +124,26 @@ const compiled = await build({ absWorkingDir: consumer, stdin: { resolveDir: consumer, - contents: `import * as net from 'node:net'; import * as tls from 'node:tls'; import * as streams from 'node:stream/web'; import * as module from 'node:module'; import * as httpCommon from 'node:_http_common'; import * as httpOutgoing from 'node:_http_outgoing'; import * as httpServer from 'node:_http_server'; import * as http from 'node:http'; import * as fs from 'node:fs'; import * as zlib from 'node:zlib'; import * as sys from 'node:sys'; import * as util from 'node:util'; import * as types from 'node:util/types'; import * as console from 'node:console'; import * as crypto from 'node:crypto'; + contents: `import * as https from 'node:https'; import * as net from 'node:net'; import * as tls from 'node:tls'; import * as classicStreams from 'node:stream'; import * as streams from 'node:stream/web'; import * as module from 'node:module'; import * as httpCommon from 'node:_http_common'; import * as httpOutgoing from 'node:_http_outgoing'; import * as httpServer from 'node:_http_server'; import * as http from 'node:http'; import * as cloudflareNode from 'cloudflare:node'; import * as fs from 'node:fs'; import * as zlib from 'node:zlib'; import * as sys from 'node:sys'; import * as util from 'node:util'; import * as types from 'node:util/types'; import * as console from 'node:console'; import * as crypto from 'node:crypto'; import { eventCases } from ${JSON.stringify(root + "/src/compat/expansion/index.spec.mjs")}; +import {runNetworkLifecycle} from ${JSON.stringify(root + "/src/compat/http/network.spec.mjs")}; +import {runTlsLifecycle} from ${JSON.stringify(root + "/src/compat/tls/network.spec.mjs")}; +import {runWebSocketLifecycle} from ${JSON.stringify(root + "/src/compat/http/websocket-network.spec.mjs")}; +import {createFsLifetimeContract} from ${JSON.stringify(root + "/src/compat/fs/request-lifetime.spec.mjs")}; +const runFsLifetime = async () => {const step=createFsLifetimeContract(fs),out={};for(const kind of ["direct","handle"]){await withRequestScope(()=>step("first",kind));out[kind]=await withRequestScope(()=>step("second",kind));}return out}; +import {runWebSocketSurface} from ${JSON.stringify(root + "/src/compat/http/websocket-surface.spec.mjs")}; +import {workersCapacitor} from ${JSON.stringify(consumer + "/node_modules/cf-workers-native/dist/index.js")}; +const runNativeNetwork = async ports => {const runtime=workersCapacitor({baseURL:"https://network-lifecycle.invalid",worker:{fetch(){return new Response("unused")}}});try{await runtime.ready;return {http:await runNetworkLifecycle({http,https},ports),tls:await runTlsLifecycle({net,tls},ports),websocket:await runWebSocketLifecycle(http.WebSocket,ports),fsLifetime:await runFsLifetime(),websocketSurface:runWebSocketSurface()}}finally{await runtime.dispose()}}; +import {localBoundaryCases} from ${JSON.stringify(root + "/src/compat/expansion/boundaries.spec.mjs")}; import { AsyncLocalStorage } from 'node:async_hooks'; import process from 'node:process'; -export const run = name => eventCases[name]({process, tls, streams, sys, util, types, console, crypto, fs, zlib, module, httpCommon, httpOutgoing, httpServer, http}); +fs.registerVirtualFiles({'/bundle/readonly.txt':'abcdef','/bundle/nested/child.txt':'child'}); +import {measureOperation,measurements} from ${JSON.stringify(root + "/demo/tests/experiments/node-compat/native-measurements.mjs")}; +export {measurements}; +import { runWithRequestScope as withRequestScope } from ${JSON.stringify(consumer + "/node_modules/cf-workers-native/dist/shared/request-scope.js")}; +export const run = name => measureOperation(name,()=>(eventCases[name] ?? localBoundaryCases[name])({withRequestScope,process, tls, classicStreams, streams, sys, util, types, console, crypto, fs, zlib, module, httpCommon, httpOutgoing, httpServer, http, cloudflareNode}),name==='classicCodecs'?()=>zlib.gzipSync('x'.repeat(1024)):undefined); export {createNetworkRelay, initializeWorkerNetwork} from ${JSON.stringify(consumer + "/node_modules/cf-workers-native/dist/browser/fetch/relay.js")}; -export const runTls = async ({port,upgradePort}) => { +export const runTls = async ({port,upgradePort,networkPorts}) => { const attempt = (servername,halfClose=false) => new Promise((resolve,reject)=> { let accepted=false, body=''; const socket=tls.connect({host:'127.0.0.1',port,servername}); @@ -124,7 +175,7 @@ export const runTls = async ({port,upgradePort}) => { plain.on('data',onGreeting); }); const upgradeEnd=[];for(let i=0;i<3;i++)upgradeEnd.push(await upgrade('localhost',true)); - return {valid,directEnd,invalid,upgrade:await upgrade('localhost'),upgradeEnd,upgradeTls12:await upgrade('localhost',true,true),upgradeUntrusted:await upgrade('localhost',false,'untrusted'),upgradeInvalid:await upgrade('wrong.invalid')}; + return {valid,directEnd,invalid,upgrade:await upgrade('localhost'),upgradeEnd,upgradeTls12:await upgrade('localhost',true,true),upgradeUntrusted:await upgrade('localhost',false,'untrusted'),upgradeInvalid:await upgrade('wrong.invalid'),network:await runNativeNetwork(networkPorts)}; };`, }, bundle: true, @@ -140,9 +191,16 @@ export const runTls = async ({port,upgradePort}) => { }), ], }); -const source = compiled.outputFiles[0].text; +const entryOutput = compiled.outputFiles.find( + (file) => file.path === resolve(artifacts, "candidate.js"), +); +assert.ok(entryOutput, "Missing native contract entry output"); +const source = entryOutput.text; const hash = createHash("sha256").update(source).digest("hex"); await writeFile(`${artifacts}/candidate.js`, source); +const harnessSource = await readFile(fileURLToPath(import.meta.url)); +const harnessSha256 = createHash("sha256").update(harnessSource).digest("hex"); +await writeFile(`${artifacts}/harness.mjs`, harnessSource); await writeFile( `${artifacts}/inputs.json`, JSON.stringify( @@ -153,7 +211,10 @@ await writeFile( device, tarball, tarballSha256, + harnessSha256, + consumerEvidence, cases: Object.keys(eventCases), + localBoundaryCases: Object.keys(localBoundaryCases), }, null, 2, @@ -329,7 +390,80 @@ const upgradeServer = tcpServer((socket) => { socket.on("data", begin); }); await new Promise((resolve) => upgradeServer.listen(0, "127.0.0.1", resolve)); -const tlsPorts = { port: 0, upgradePort: upgradeServer.address().port }; +// A valid chain with the wrong hostname isolates endpoint-name rejection. +await writeFile( + `${tlsDirectory}/wrong.cnf`, + (await readFile(`${tlsDirectory}/ca.cnf`, "utf8")) + + "\n[wrong]\nsubjectAltName=DNS:wrong.invalid\nbasicConstraints=CA:FALSE\n", +); +execFileSync( + "openssl", + [ + "req", + "-new", + "-key", + `${tlsDirectory}/key.pem`, + "-out", + `${tlsDirectory}/wrong-request.pem`, + "-subj", + "/CN=wrong.invalid", + ], + { stdio: "pipe" }, +); +execFileSync( + "openssl", + [ + "ca", + "-batch", + "-config", + `${tlsDirectory}/wrong.cnf`, + "-cert", + `${tlsDirectory}/ca.pem`, + "-extensions", + "wrong", + "-in", + `${tlsDirectory}/wrong-request.pem`, + "-out", + `${tlsDirectory}/wrong.pem`, + "-startdate", + certificateTime(Date.now() - 86400000), + "-enddate", + certificateTime(Date.now() + 172800000), + ], + { stdio: "pipe" }, +); +const networkFixture = await createLifecycleFixture({ + mismatchCert: await readFile(`${tlsDirectory}/wrong.pem`), + key: await readFile(`${tlsDirectory}/key.pem`), + cert: await readFile(`${tlsDirectory}/ca.pem`), +}); +let networkExpected; +try { + networkExpected = await networkLifecycleBaseline( + networkFixture.ports, + `${tlsDirectory}/ca.pem`, + `${artifacts}/network-baseline`, + ); +} catch (error) { + await networkFixture.close(); + for (const socket of upgradeSockets) socket.destroy(); + await new Promise((resolve) => upgradeServer.close(resolve)); + throw error; +} +const tlsPorts = { + port: 0, + upgradePort: upgradeServer.address().port, + networkPorts: networkFixture.ports, +}; +if (platform === "android") + for (const port of Object.values(networkFixture.ports)) + execFileSync("adb", [ + "-s", + device, + "reverse", + `tcp:${port}`, + `tcp:${port}`, + ]); if (platform === "android") execFileSync("adb", [ "-s", @@ -351,43 +485,86 @@ const backend = await nativeBackend({ "--remove", `tcp:${tlsPorts.upgradePort}`, ]); + if (platform === "android") + for (const port of Object.values(networkFixture.ports)) + execFileSync("adb", [ + "-s", + device, + "reverse", + "--remove", + `tcp:${port}`, + ]); + await networkFixture.close(); for (const socket of upgradeSockets) socket.destroy(); await new Promise((resolve) => upgradeServer.close(resolve)); }, instrument: () => false, }); -tlsPorts.port = Number(backend.url.port); +tlsPorts.port = networkFixture.ports.gracefulHttp; const bootstrapCode = ` async function main() { const source = ${JSON.stringify(source)}; -const names = ${JSON.stringify(Object.keys(eventCases))}; +const names = ${JSON.stringify([...Object.keys(eventCases), ...Object.keys(localBoundaryCases)])}; +const boundaryNames = ${JSON.stringify(Object.keys(localBoundaryCases))}; const report = async value => {await fetch(${JSON.stringify(backend.url.origin + "/__acceptance/report")}, {method:'POST', mode:'no-cors', headers:{'content-type':'text/plain'}, body:JSON.stringify(value)});}; -const results = {}; +const results = {}, measurements = {}, localBoundaries = {}; +// One immutable Blob avoids accumulating copies of the large fixture payload. +// URL fragments retain separate module identities for cold per-case measurements. +let moduleURL, activeCase; try { + moduleURL = URL.createObjectURL(new Blob([source], {type:'text/javascript'})); for (const mode of ['page', 'worker']) { - results[mode] = {}; + results[mode] = {}; measurements[mode] = {}; for (const name of names) { - const url = URL.createObjectURL(new Blob([source], {type:'text/javascript'})); - try { - if (mode === 'page') { const api = await import(/* @vite-ignore */ url); results[mode][name] = JSON.parse(JSON.stringify(await api.run(name))); } + activeCase = {mode,name}; + document.body.textContent=mode+"/"+name; + globalThis.__compatActiveCase=activeCase; + if(Object.keys(results[mode]).length % 10 === 0)await report({stage:"progress",activeCase}); + const url = moduleURL+'#'+mode+'-'+name; + if (mode === 'page') { + // Discard the realm after each cold case; module URL fragments alone keep + // every Wasm instance alive in the parent page's module map. + const frame=document.createElement('iframe');frame.hidden=true; + const token='case-'+name; + try { + const outcome=await new Promise((resolve,reject)=>{ + const timer=setTimeout(()=>{window.removeEventListener('message',receive);reject(new Error('Page case timeout: '+name));},30000); + const receive=event=>{ + if(event.source!==frame.contentWindow || event.data?.token!==token)return; + clearTimeout(timer);window.removeEventListener('message',receive); + event.data.error?reject(new Error(event.data.error)):resolve(event.data); + }; + window.addEventListener('message',receive); + const code='globalThis.Capacitor=parent.Capacitor;try {const api=await import('+JSON.stringify(url)+');parent.postMessage({token:'+JSON.stringify(token)+',value:JSON.parse(JSON.stringify(await api.run('+JSON.stringify(name)+'))),measurements:api.measurements['+JSON.stringify(name)+']},"*");}catch(error){parent.postMessage({token:'+JSON.stringify(token)+',error:String(error)},"*");}'; + frame.srcdoc='', @@ -89,13 +96,25 @@ export async function runNativeAcceptance({ root: bootstrap, publicDir, plugins: [ - workersCapacitorPlugin({ + { + name: "acceptance-package-isolation", + generateBundle() { + for (const id of this.getModuleIds()) { + for (const root of forbiddenModuleRoots) + assert.ok( + !id.startsWith(root + "/"), + `Checkout module entered installed-package build: ${id}`, + ); + } + }, + }, + buildPlugin({ execution, worker, workerConfig, kvExecution, remoteBindings, - assetsExecution: "remote", + assetsExecution: assetsExecution ?? undefined, }), ], build: { outDir: join(shell, "www"), emptyOutDir: true }, @@ -262,6 +281,7 @@ export async function runNativeAcceptance({ JSON.stringify( { platform, + ...measurements(launched), elapsedFlowMs: Date.now() - launched, bundleBytes: ( await Promise.all( diff --git a/demo/tests/native/backend.mjs b/demo/tests/native/backend.mjs index 927e116..305c010 100644 --- a/demo/tests/native/backend.mjs +++ b/demo/tests/native/backend.mjs @@ -12,6 +12,7 @@ export async function nativeBackend({ dispose, instrument = () => true, tlsDirectory, + onReport = () => {}, }) { const directory = tlsDirectory ?? (await mkdtemp(join(tmpdir(), "native-acceptance-"))); @@ -51,7 +52,9 @@ export async function nativeBackend({ const chunks = []; for await (const chunk of req) chunks.push(chunk); if (req.url === "/__acceptance/report") { - reports.push(JSON.parse(Buffer.concat(chunks).toString())); + const report = JSON.parse(Buffer.concat(chunks).toString()); + reports.push(report); + onReport(report); res.end("ok"); return; } diff --git a/docs/README.md b/docs/README.md index c78d9e7..026b0ab 100644 --- a/docs/README.md +++ b/docs/README.md @@ -57,7 +57,7 @@ Start with network execution: requests go to your existing deployed Worker. Bund Follow the [native embedding verification](native-embedding.md#verification) or [Capacitor acceptance flow](integration.md#6-verify-the-existing-application-in-capacitor). Test navigation, authentication, mutations, lifecycle and network failures in your own app. Simulator/emulator results do not replace checking the devices you intend to support. -Review [security responsibilities](integration.md#5-security-the-device-is-not-a-trusted-server) and [known limitations](compatibility.md). This release is **0.1.4**; native embedding does not supply general Capacitor plugin integration or automatic store publication. +Review [security responsibilities](integration.md#5-security-the-device-is-not-a-trusted-server) and [known limitations](compatibility.md). This release is **0.1.5**; native embedding does not supply general Capacitor plugin integration or automatic store publication. ## Examples and development diff --git a/docs/build-setup.md b/docs/build-setup.md index 62ae3e5..50a59cc 100644 --- a/docs/build-setup.md +++ b/docs/build-setup.md @@ -31,7 +31,7 @@ application's runtime import changes; Worker source and execution options are sh | Android | `workersAndroid` from `cf-workers-native/android` | [Existing Android app](native-embedding.md#android) | | Capacitor | `workersCapacitor` from `cf-workers-native/capacitor` | [Capacitor app](integration.md) | -Use `cf-workers-native@0.1.4` with matching +Use `cf-workers-native@0.1.5` with matching native dependencies; see [dependency setup](native-embedding.md#dependency-setup). Build the corresponding entry for each target, then package the entire output directory. Existing Capacitor examples below use `workersCapacitor`; the same diff --git a/docs/compatibility.md b/docs/compatibility.md index 1c45e20..56cd925 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -11,7 +11,7 @@ The earlier `workers-capacitor` package releases 0.1.0 Preview and the explicitl | Host | Scope | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| iOS embedding | 0.1.4 `WorkersIOS` SwiftPM product for UIKit/SwiftUI. Close before removing a UIKit controller; SwiftUI dismantling closes automatically. | +| iOS embedding | 0.1.5 `WorkersIOS` SwiftPM product for UIKit/SwiftUI. Close before removing a UIKit controller; SwiftUI dismantling closes automatically. | | Android embedding | Maven Central SDK `io.github.rdlabo:cf-workers-native-android:0.1.2`. Requires WebView `MULTI_PROFILE`; one SDK screen at a time, with persistent SDK cookies isolated from host cookies. | | Capacitor | Existing Capacitor 8 integration, verified with 8.5.3; use this path for general native plugins. | @@ -27,22 +27,23 @@ and [verification scope](native-embedding.md#verification) for host-specific evi Whole-Node compatibility is incomplete. Importing a module successfully does not establish equivalent methods, validation, state, scheduling or engine behavior. -| Area | Behavior and limitations | -| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| path, querystring, string_decoder, buffer, timers | Implement the pinned Workers profile. Workers stubs remain stubs; for example, matchesGlob reproduces the pinned rejection. | -| HTTP and net | In-memory server dispatch; no OS listening server. Outbound TCP/TLS requires the native transport. | -| fs / fs/promises | Read-only embedded files plus request-local in-memory `/tmp`, with writes, descriptors and FileHandle operations. Watch is unsupported, as in Workers. Full filesystem semantics remain under audit. | -| module | Limited builtin registry; createRequire loads VFS JSON only. Executable modules must be bundled; arbitrary dynamic resolution is unsupported. | -| process, console, util / util/types / sys | Incomplete exports or behavior. Engine introspection, call sites and host-object detection are not equivalent to Workers. | -| crypto and zlib | Selected profiles cover random/hash/HKDF/scrypt, secret/asymmetric keys, signatures, primes and Brotli/Zstd streaming. X509Certificate parsing, fields and checks are implemented with the pinned Workers limits; additional algorithms and full crypto/codec parity remain gaps. | -| TLS and HTTP internals | TLS and HTTP internal imports resolve. Direct Node TLS exchange and hostname rejection are tested on native transports. Existing native sockets upgrade with authenticated TLS before application writes. TLS 1.2/1.3 upgrades are tested on both OSes; custom Duplex wrapping and complete class/state contracts remain gaps. | -| Streams | An owned implementation supplies byte streams and the tested Workers tee cancellation and pipeTo abort behavior on every host. Whole-module acceptance remains open. | -| async_hooks | Build-transformed async functions retain scoped stores. Untransformed native async callbacks reject; arbitrary dynamic code and request-origin isolation are not fully supported. | -| diagnostics_channel | Synchronous channel/store contracts are implemented; async propagation depends on the async-hooks limitations. | -| worker_threads | Workers stubs and local MessagePort behavior, not Node threads. Trusted-event branding, host-object serialization and global identity differ. | -| DNS | DNS-over-HTTPS implementation; synthetic Service Binding/Hyperdrive address overrides are not implemented. | -| events | [WebView events profile](../src/compat/events/README.md): EventEmitter, abort/iteration helpers, transformed AsyncResource propagation and process warning delivery. Native async and V8 inspection remain bounded. | -| Other modules | Implementations and shared contracts exist, but whole-module acceptance remains outstanding. Namespace equality alone is insufficient. | +| Area | Behavior and limitations | +| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| path, querystring, string_decoder, buffer, timers | Implement the pinned Workers profile. Workers stubs remain stubs; for example, matchesGlob reproduces the pinned rejection. | +| HTTP and net | In-memory server dispatch; no OS listening server. Outbound TCP/TLS requires the native transport. | +| fs / fs/promises | Read-only embedded files plus request-local in-memory `/tmp`, with writes, descriptors and FileHandle operations. Watch is unsupported, as in Workers. Full filesystem semantics remain under audit. | +| module | Limited builtin registry; createRequire loads VFS JSON only. Executable modules must be bundled; arbitrary dynamic resolution is unsupported. | +| process, console, util / util/types / sys | Incomplete exports or behavior. Engine introspection, call sites and host-object detection are not equivalent to Workers. | +| crypto and zlib | Selected profiles cover random/Hash/HMAC/PBKDF2/HKDF/scrypt, secret/asymmetric keys, signatures, primes and Brotli/Zstd streaming. X509Certificate parsing, fields and checks are implemented with the pinned Workers limits; additional algorithms and full crypto/codec parity remain gaps. | +| TLS and HTTP internals | Direct TLS and STARTTLS use authenticated native transports and distinguish close_notify from truncated connections. Workers' early STARTTLS success notification and some handshake cancellation/closure events differ; complete class/state compatibility remains open. | +| WebSocket | Host WebSocket supports tested WSS exchanges. Plain WS is restricted by Android WebView policy. Workers WebSocketPair, accept, attachment methods and exact constructor/state behavior remain implementation gaps. | +| Streams | An owned implementation supplies byte streams and the tested Workers tee cancellation and pipeTo abort behavior on every host. Whole-module acceptance remains open. | +| async_hooks | Build-transformed async functions retain scoped stores. Untransformed native async callbacks reject; arbitrary dynamic code and request-origin isolation are not fully supported. | +| diagnostics_channel | Synchronous channel/store contracts are implemented; async propagation depends on the async-hooks limitations. | +| worker_threads | Workers stubs and local MessagePort behavior, not Node threads. Trusted-event branding, host-object serialization and global identity differ. | +| DNS | DNS-over-HTTPS implementation; synthetic Service Binding/Hyperdrive address overrides are not implemented. | +| events | [WebView events profile](../src/compat/events/README.md): EventEmitter, abort/iteration helpers, transformed AsyncResource propagation and process warning delivery. Native async and V8 inspection remain bounded. | +| Other modules | Implementations and shared contracts exist, but whole-module acceptance remains outstanding. Namespace equality alone is insufficient. | The exact missing/extra export baseline is maintained in [surface-gaps.json](../demo/tests/experiments/node-compat/surface-gaps.json), against @@ -81,6 +82,15 @@ Residual work has different causes: - **Verification gaps:** even an aligned export surface needs whole-module operation/prototype coverage and native acceptance before it is called complete. +A codec option getter can mutate an input or dictionary before native processing; +those ordinary mutations are preserved. Resizable ArrayBuffers and growable SharedArrayBuffer views are snapshotted +at conversion, as in Workers. A fixed ArrayBuffer transferred by an option getter +is a narrower engine boundary: Workers can retain its native backing store after +detachment, while JavaScript cannot recover mutations made immediately before the +transfer. Local fast codecs reject that case with `TypeError` instead of silently +compressing empty data. Avoid transferring input or dictionary buffers from option +getters. This limitation does not exempt other codec options or lifecycle behavior. + Run `npm run test:node-boundaries --prefix demo` for the counterexamples and `npm run test:events --prefix demo` for the Workers events baseline and implementation. `node:events` is routed through the [WebView events profile](../src/compat/events/README.md); process warning delivery diff --git a/docs/development.md b/docs/development.md index 5a439f8..14cfb6e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -135,7 +135,7 @@ requires a verified namespace, release signing key and explicit publication. Run the independent consumer suite against an exact registry version: ```sh -CONSUMER_PACKAGE=cf-workers-native@0.1.4 npm run test:package +CONSUMER_PACKAGE=cf-workers-native@0.1.5 npm run test:package ``` This downloads the published tarball instead of packing the checkout. The same @@ -144,3 +144,26 @@ shared runtime contracts. `artifacts/package-results.json` records the temporary consumer directory. Its `node_modules/cf-workers-native` directory can also be passed as `WORKERS_NPM_PACKAGE_DIR` to the native embedding runners. Without `CONSUMER_PACKAGE`, the suite builds and packs the local checkout as before. + +For native acceptance from a fresh registry installation, prepare once, then run +the ordinary Worker onboarding flow on each booted simulator/emulator: + +```sh +CONSUMER_VERSION=0.1.5 npm run test:events:native:prepare --prefix demo +node demo/tests/experiments/published-package/native.mjs ios +node demo/tests/experiments/published-package/native.mjs android +``` + +Run these commands serially: the consumer shares its generated web directory. +Preparation verifies npm's SHA-512 integrity and records the archive SHA-256. +The native build imports the installed Vite plugin and runtime, and rejects SDK +modules from the checkout. The fixture checks initial rendering, link navigation, +form submission exactly once and the POST/303/GET flow against a local HTTPS +Workers backend. It does not contact a production service. + +Evidence is written to `artifacts/validation/published-/onboarding/`. +`elapsedFlowMs` measures native launch through completion of the whole flow; +it is not a startup benchmark. `documentReadyMs` is the document's performance +clock, and `bundleBytes` covers generated web assets, not the native app binary. +The [published-package fixture](../demo/tests/experiments/published-package/README.md) +documents the procedure and verification limits. diff --git a/docs/integration.md b/docs/integration.md index 1a1636f..f77b66c 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -87,7 +87,7 @@ For the Vite example below, install `vite@7` as a dev dependency if it is not al Set the application's own ID/name and `webDir: "www"`. Use Swift Package Manager for iOS; this library does not include CocoaPods integration. Install the npm Preview using its exact version. The `latest` tag includes 0.x Preview releases: ```sh -npm install --save-exact cf-workers-native@0.1.4 +npm install --save-exact cf-workers-native@0.1.5 ``` For a source build instead, create a package tarball from a checkout of this repository: @@ -98,7 +98,7 @@ npm install npm pack # In the application package: -npm install /path/to/cf-workers-native/cf-workers-native-0.1.4.tgz +npm install /path/to/cf-workers-native/cf-workers-native-0.1.5.tgz ``` `npm pack` runs the SDK build through `prepack`. Install the generated tarball rather than the checkout directory: directory installs can create a symlink back to the SDK tree, including a recursive path when the app lives inside that checkout. The tarball filename above uses the current source version. @@ -107,11 +107,11 @@ The installed package is named `cf-workers-native`; the checkout directory can r ### Migrating from the old npm package -If your application already depends on `workers-capacitor`, remove it before installing the renamed package. Keeping both dependencies can make Capacitor discover and register the same native plugin twice. In the application package, using 0.1.4: +If your application already depends on `workers-capacitor`, remove it before installing the renamed package. Keeping both dependencies can make Capacitor discover and register the same native plugin twice. In the application package, using 0.1.5: ```sh npm uninstall workers-capacitor -npm install --save-exact cf-workers-native@0.1.4 +npm install --save-exact cf-workers-native@0.1.5 ``` Change runtime imports from `workers-capacitor` to `cf-workers-native/capacitor`, and change other package subpaths to the `cf-workers-native` prefix. Rebuild the application's mobile web assets with its existing build command, then run `npx cap sync` to regenerate native plugin dependencies. New applications use the installation steps above directly. diff --git a/docs/native-embedding.md b/docs/native-embedding.md index f496fc6..31b3b3c 100644 --- a/docs/native-embedding.md +++ b/docs/native-embedding.md @@ -12,9 +12,10 @@ You will build the web assets in your web project, add the native SDK to your app, then open and close a Workers screen through its native API. The SDK resolves Capacitor internally; your native app does not need Capacitor configuration or CLI commands. -**0.1.4:** install `cf-workers-native@0.1.4` with matching +**0.1.5:** install `cf-workers-native@0.1.5` with matching [native dependencies](#dependency-setup). Android requires WebView `MULTI_PROFILE` -support. The native Android SDK is available from Maven Central at version `0.1.2`. +support. The previously published Maven `0.1.2` SDK does not include the +0.1.5 TLS upgrade bridge; see the Android version boundary below. ## Shared web build @@ -90,18 +91,19 @@ entries are subpath imports, not separate npm packages. There is no bare package Install the npm Preview in the web project. The `latest` tag includes 0.x Preview releases: ```sh -npm install --save-exact cf-workers-native@0.1.4 +npm install --save-exact cf-workers-native@0.1.5 ``` -Use the dependency set below. npm 0.1.4 and SwiftPM `v0.1.4` share the release -commit. The Android native implementation is unchanged from Maven 0.1.2, so -existing Android applications can continue using that published artifact: +Use the dependency set below. npm 0.1.5 and SwiftPM `v0.1.5` share the release +commit. For Capacitor, npm includes the matching Android native implementation. +The separately published Maven 0.1.2 artifact predates the 0.1.5 TLS upgrade +bridge; do not treat it as the matching native SDK for all 0.1.5 features: -| Component | Dependency | -| ---------- | --------------------------------------------------------------------- | -| JavaScript | `cf-workers-native@0.1.4` | -| iOS | SwiftPM tag `v0.1.4`, product `WorkersIOS` | -| Android | `io.github.rdlabo:cf-workers-native-android:0.1.2` from Maven Central | +| Component | Dependency | +| ---------- | ------------------------------------------------------------------------------------------------------------- | +| JavaScript | `cf-workers-native@0.1.5` | +| iOS | SwiftPM tag `v0.1.5`, product `WorkersIOS` | +| Android | Matching `v0.1.5` native sources via local publication; Maven Central `0.1.2` predates the TLS upgrade bridge | For local SDK development, create a JavaScript package tarball from the matching checkout: @@ -111,7 +113,7 @@ npm install npm pack # In the web project: -npm install /path/to/cf-workers-native/cf-workers-native-0.1.4.tgz +npm install /path/to/cf-workers-native/cf-workers-native-0.1.5.tgz ``` `npm pack` runs the build through `prepack`; the filename above uses the current @@ -128,7 +130,7 @@ The source checkout's npm and build tools belong in the web build or CI. ## iOS Add `https://github.com/rdlabo-dev/cf-workers-native` as a Swift package pinned to -tag `v0.1.4` for npm 0.1.4 (or the matching revision for a source build), and select the `WorkersIOS` product. The existing `v0.1.2` tag predates the package rename; use `v0.1.4` for this release. Use Xcode 26 / +tag `v0.1.5` for npm 0.1.5 (or the matching revision for a source build), and select the `WorkersIOS` product. The existing `v0.1.2` tag predates the package rename; use `v0.1.5` for this release. Use Xcode 26 / Swift 6.2 or newer, as required by the native dependency toolchain. Add your generated assets as a folder resource, for example `WorkerWeb`, then create the controller from that directory on the main actor: @@ -172,12 +174,15 @@ from JavaScript. Explicit closure cancels the pending startup notification. Add the SDK's Android Maven publication to your app's dependencies and include the generated web directory under `src/main/assets/`, for example `worker-web`. The publication resolves Capacitor and its other native dependencies. -The new Maven coordinate is `io.github.rdlabo:cf-workers-native-android:0.1.2`. -It is published and resolves from Maven Central. npm 0.1.4 does not change the -Android native implementation; this existing artifact remains compatible. +The previously published Maven coordinate is `io.github.rdlabo:cf-workers-native-android:0.1.2`. +This is the previously published SDK, not the matching Android native code for +npm 0.1.5. In particular, it does not include the new same-connection TLS upgrade +bridge. For the current implementation, use the Android native sources from the +matching release with the local publication workflow in [development](development.md#android-maven-publication), +or use Capacitor, which installs those sources from npm. Use Java 21 and Android SDK 36, with `ANDROID_HOME` pointing to the installed SDK. Include `google()` and `mavenCentral()` in your app's dependency repositories. -In your app's Groovy `build.gradle`: +For an application deliberately retaining that older SDK, the Groovy dependency is: ```groovy dependencies { diff --git a/native/android/src/main/java/jp/rdlabo/workerscapacitor/WorkerSockets.java b/native/android/src/main/java/jp/rdlabo/workerscapacitor/WorkerSockets.java index 82c73cd..af9efd3 100644 --- a/native/android/src/main/java/jp/rdlabo/workerscapacitor/WorkerSockets.java +++ b/native/android/src/main/java/jp/rdlabo/workerscapacitor/WorkerSockets.java @@ -11,6 +11,7 @@ import io.netty.channel.socket.ChannelInputShutdownEvent; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.handler.ssl.SslHandler; +import io.netty.handler.ssl.SslCloseCompletionEvent; import java.util.ArrayDeque; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -36,7 +37,7 @@ private final class Connection extends SimpleChannelInboundHandler { Result reading, watching, upgrading; String host; int port; int count, offset; - boolean ready, closed, eof, ended, writing, reset; + boolean ready, closed, eof, ended, writing, reset, encrypted, closeNotify; Connection(String id, Result opening) { this.id=id; this.opening=opening; } void fail(String message) { if (closed) return; @@ -76,15 +77,32 @@ void drain() { } @Override public void channelReadComplete(ChannelHandlerContext context) { if(!ready && !closed) context.read(); else pump(); } @Override public void userEventTriggered(ChannelHandlerContext context,Object event) { - if(event instanceof ChannelInputShutdownEvent) { eof=true; drain(); notifyEnd(); } + if(event instanceof SslCloseCompletionEvent) { + SslCloseCompletionEvent tlsClose=(SslCloseCompletionEvent)event; + if(tlsClose.isSuccess()) { closeNotify=true; eof=true; drain(); notifyEnd(); } + else if(ready && encrypted && !closed) { fail("TLS connection closed without close_notify"); return; } + context.fireUserEventTriggered(event); + } else if(event instanceof ChannelInputShutdownEvent) { + if(!ready || upgrading!=null) { fail("TLS connection closed before handshake completed"); return; } + if(encrypted && !closeNotify) { fail("TLS connection closed without close_notify"); return; } + eof=true; drain(); notifyEnd(); + } else context.fireUserEventTriggered(event); } @Override public void channelInactive(ChannelHandlerContext context) { - if(!closed) { eof=true; drain(); notifyEnd(); } + if(!closed) { + if(!ready || upgrading!=null) { fail("Socket closed before handshake completed"); return; } + if(encrypted && !closeNotify) { fail("TLS connection closed without close_notify"); return; } + eof=true; drain(); notifyEnd(); + } } @Override public void exceptionCaught(ChannelHandlerContext context,Throwable error) { - reset = error instanceof java.io.IOException && String.valueOf(error.getMessage()).toLowerCase(java.util.Locale.ROOT).contains("reset"); - fail("Socket failed: "+error.getMessage()); + boolean peerReset = error instanceof java.io.IOException && String.valueOf(error.getMessage()).toLowerCase(java.util.Locale.ROOT).contains("reset"); + // Workers' plain TCP reset-as-EOF behavior must not turn a truncated + // authenticated TLS stream into a successful end. + if(encrypted && peerReset && closeNotify) { eof=true; drain(); notifyEnd(); return; } + reset = !encrypted && peerReset; + fail(encrypted && peerReset ? "TLS connection closed without close_notify" : "Socket failed: "+error.getMessage()); } } synchronized void open(String id,String host,int port,boolean tls,Result result) { open(id,host,port,tls,null,result); } @@ -103,7 +121,7 @@ synchronized void open(String id,String host,int port,boolean tls,String servern SSLParameters parameters=engine.getSSLParameters(); parameters.setEndpointIdentificationAlgorithm("HTTPS"); engine.setSSLParameters(parameters); } else engine=null; if(group==null) group=new NioEventLoopGroup(1); - Connection connection=new Connection(id,result); connection.host=host; connection.port=port; connections.put(id,connection); + Connection connection=new Connection(id,result); connection.host=host; connection.port=port; connection.encrypted=tls; connections.put(id,connection); ChannelFuture future=new Bootstrap().group(group).channel(NioSocketChannel.class) .option(ChannelOption.AUTO_READ,false).option(ChannelOption.ALLOW_HALF_CLOSURE,true) .option(ChannelOption.CONNECT_TIMEOUT_MILLIS,30000) @@ -144,7 +162,7 @@ void startTls(String id,String servername,Result result) { } engine.setUseClientMode(true); SSLParameters parameters=engine.getSSLParameters(); parameters.setEndpointIdentificationAlgorithm("HTTPS"); engine.setSSLParameters(parameters); - connection.upgrading=result; + connection.upgrading=result; connection.encrypted=true; JSObject moved=new JSObject(); moved.put("upgraded",true); if(connection.reading!=null) { Result pending=connection.reading; connection.reading=null; pending.resolve(moved); } if(connection.watching!=null) { Result pending=connection.watching; connection.watching=null; pending.resolve(moved); } diff --git a/scripts/build-codecs.mjs b/scripts/build-codecs.mjs index fa1a645..1f44885 100644 --- a/scripts/build-codecs.mjs +++ b/scripts/build-codecs.mjs @@ -1,9 +1,17 @@ import { build } from "esbuild"; -import { readFile } from "node:fs/promises"; +import { readFile, copyFile } from "node:fs/promises"; import { createHash } from "node:crypto"; // Fail before packaging if a generated implementation and its provenance drift. for (const [asset, manifest] of [ + [ + "src/compat/zlib/native-zlib.wasm", + "src/compat/zlib/native-zlib.build.json", + ], + [ + "src/compat/crypto/native-cipher.wasm", + "src/compat/crypto/native-cipher.build.json", + ], [ "src/compat/crypto/native-crypto.wasm", "src/compat/crypto/native-crypto.build.json", @@ -70,3 +78,26 @@ await build({ target: "es2022", loader: { ".wasm": "binary" }, }); + +await build({ + entryPoints: ["src/compat/zlib/native-zlib.ts"], + outfile: "dist/compat/zlib/native-zlib.js", + bundle: true, + format: "esm", + target: "es2022", + loader: { ".wasm": "binary" }, +}); + +await copyFile( + "src/compat/zlib/classic-base.d.cts", + "dist/compat/zlib/classic-base.d.cts", +); + +await build({ + entryPoints: ["src/compat/crypto/native-cipher.ts"], + outfile: "dist/compat/crypto/native-cipher.js", + bundle: true, + format: "esm", + target: "es2022", + loader: { ".wasm": "binary" }, +}); diff --git a/scripts/build-native-cipher.mjs b/scripts/build-native-cipher.mjs new file mode 100644 index 0000000..d07347a --- /dev/null +++ b/scripts/build-native-cipher.mjs @@ -0,0 +1,119 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { createHash } from "node:crypto"; +import assert from "node:assert/strict"; +import { format, resolveConfig } from "prettier"; +const source = resolve("artifacts/upstream/boringssl"), + sdk = resolve(process.env.CODEC_EMSDK ?? "artifacts/toolchains/emsdk"), + build = resolve("artifacts/upstream/boringssl-build"); +const revision = (directory) => + execFileSync("git", ["rev-parse", "HEAD"], { + cwd: directory, + encoding: "utf8", + }).trim(); +const revisions = { emsdk: revision(sdk), boringssl: revision(source) }; +assert.equal(revisions.emsdk, "e4fe26ef59168ff44f4c23c466e497bf60b3411e"); +assert.equal(revisions.boringssl, "427ec40cc8edc545253289232e885708c409e5ea"); +assert.equal( + execFileSync("git", ["diff", "HEAD", "--name-only"], { + cwd: source, + encoding: "utf8", + }).trim(), + "", +); +const compiler = join(sdk, "upstream/emscripten"); +assert.equal( + execFileSync("git", ["diff", "HEAD", "--name-only"], { + cwd: sdk, + encoding: "utf8", + }).trim(), + "", +); +const emccVersion = execFileSync(join(compiler, "emcc"), ["--version"], { + encoding: "utf8", +}).split("\n")[0]; +assert.equal( + emccVersion, + "emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 4.0.20 (6913738ec5371a88c4af5a80db0ab42bad3de681)", +); + +const cmake = resolve("artifacts/toolchains/boringssl-build/bin/cmake"), + ninja = resolve("artifacts/toolchains/boringssl-build/bin/ninja"); +const definitions = + "-DOPENSSL_NO_THREADS_CORRUPT_MEMORY_AND_LEAK_SECRETS_IF_THREADED -DOPENSSL_NO_FILESYSTEM -DOPENSSL_NO_POSIX_IO -DOPENSSL_NO_SOCK"; +const configure = [ + "-S", + source, + "-B", + build, + "-G", + "Ninja", + "-DCMAKE_MAKE_PROGRAM=" + ninja, + "-DCMAKE_TOOLCHAIN_FILE=" + + join(compiler, "cmake/Modules/Platform/Emscripten.cmake"), + "-DCMAKE_BUILD_TYPE=Release", + "-DBUILD_TESTING=OFF", + "-DOPENSSL_NO_ASM=1", + "-DCMAKE_C_FLAGS=" + definitions, + "-DCMAKE_CXX_FLAGS=" + definitions, +]; +if (!process.argv.includes("--link-only")) { + assert.match( + execFileSync(cmake, ["--version"], { encoding: "utf8" }), + /^cmake version 4\.2\.0\b/, + ); + assert.equal( + execFileSync(ninja, ["--version"], { encoding: "utf8" }).trim(), + "1.13.0", + ); + execFileSync(cmake, configure, { stdio: "inherit" }); + execFileSync( + cmake, + ["--build", build, "--target", "crypto", "decrepit", "-j4"], + { stdio: "inherit" }, + ); +} +const binding = "src/compat/crypto/native-cipher.c", + output = "src/compat/crypto/native-cipher.wasm"; +const flags = [ + "-O3", + "-sSTANDALONE_WASM=1", + "-sALLOW_MEMORY_GROWTH=1", + "-sFILESYSTEM=0", + "-sASSERTIONS=0", + "-sMALLOC=emmalloc", + "-sABORTING_MALLOC=0", + "--no-entry", + '-sEXPORTED_FUNCTIONS=["_malloc","_free"]', +]; +const libraries = ["libdecrepit.a", "libcrypto.a"]; +execFileSync( + join(compiler, "emcc"), + [ + binding, + "-I" + join(source, "include"), + ...libraries.map((n) => join(build, n)), + ...flags, + "-o", + output, + ], + { stdio: "inherit" }, +); +const sha = (p) => createHash("sha256").update(readFileSync(p)).digest("hex"); +const manifest = "src/compat/crypto/native-cipher.build.json"; +writeFileSync( + manifest, + await format( + JSON.stringify({ + revisions, + toolVersions: { cmake: "4.2.0", ninja: "1.13.0", emcc: emccVersion }, + definitions, + flags, + libraries, + bindingSha256: sha(binding), + sha256: sha(output), + }), + { ...(await resolveConfig(manifest)), filepath: manifest }, + ), +); diff --git a/scripts/build-native-codecs.mjs b/scripts/build-native-codecs.mjs index a5909f5..86fd0c0 100644 --- a/scripts/build-native-codecs.mjs +++ b/scripts/build-native-codecs.mjs @@ -56,6 +56,7 @@ execFileSync( "-sFILESYSTEM=0", "-sASSERTIONS=0", "-sMALLOC=emmalloc", + "-sABORTING_MALLOC=0", "--no-entry", '-sEXPORTED_FUNCTIONS=["_malloc","_free"]', "-o", @@ -68,6 +69,9 @@ writeFileSync( JSON.stringify( { revisions, + bindingSha256: createHash("sha256") + .update(readFileSync("src/compat/zlib/native-codecs.c")) + .digest("hex"), sha256: createHash("sha256").update(readFileSync(output)).digest("hex"), }, null, diff --git a/scripts/build-native-zlib.mjs b/scripts/build-native-zlib.mjs new file mode 100644 index 0000000..e08194b --- /dev/null +++ b/scripts/build-native-zlib.mjs @@ -0,0 +1,49 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync, chmodSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { createHash } from "node:crypto"; +const sdk = resolve(process.env.CODEC_EMSDK ?? "artifacts/toolchains/emsdk"); +const revision = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: sdk, + encoding: "utf8", +}).trim(); +if (revision !== "e4fe26ef59168ff44f4c23c466e497bf60b3411e") + throw Error("Unexpected emsdk revision"); +const binding = "src/compat/zlib/native-zlib.c", + output = "src/compat/zlib/native-zlib.wasm"; +execFileSync( + join(sdk, "upstream/emscripten/emcc"), + [ + binding, + "-O3", + "-sUSE_ZLIB=1", + "-sSTANDALONE_WASM=1", + "-sALLOW_MEMORY_GROWTH=1", + "-sFILESYSTEM=0", + "-sASSERTIONS=0", + "-sMALLOC=emmalloc", + "-sABORTING_MALLOC=0", + "--no-entry", + '-sEXPORTED_FUNCTIONS=["_malloc","_free"]', + "-o", + output, + ], + { stdio: "inherit" }, +); +chmodSync(output, 0o644); +const hash = (file) => + createHash("sha256").update(readFileSync(file)).digest("hex"); +writeFileSync( + "src/compat/zlib/native-zlib.build.json", + JSON.stringify( + { + emsdk: revision, + zlib: "1.3.1", + portSha256: hash(join(sdk, "upstream/emscripten/tools/ports/zlib.py")), + bindingSha256: hash(binding), + sha256: hash(output), + }, + null, + 2, + ) + "\n", +); diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 9e49ade..6e7388e 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -13,7 +13,14 @@ function checkNodeCases(reports) { [], ); } -import { cp, mkdtemp, writeFile, mkdir, readdir } from "node:fs/promises"; +import { + cp, + mkdtemp, + writeFile, + mkdir, + readdir, + readFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve, dirname } from "node:path"; import { execFileSync, spawn } from "node:child_process"; @@ -43,6 +50,9 @@ console.log(`Consumer package: ${pack.name}@${pack.version} (${temp})`); for (const required of [ "dist/ios.js", "dist/android.js", + "dist/compat/zlib/classic-base.cjs", + "dist/compat/zlib/classic-base.d.cts", + "dist/compat/zlib/native-zlib.js", "native/ios-host/WorkersView.swift", "native/android/consumer-rules.pro", "native/android/src/main/java/jp/rdlabo/workerscapacitor/WorkersNative.java", @@ -110,8 +120,11 @@ for (const notice of [ (name) => `dist/compat/crypto/LICENSE.${name}`, ), "dist/compat/crypto/LICENSE-APACHE", + "dist/compat/crypto/LICENSE.boringssl", "dist/compat/crypto/LICENSE-3RD-PARTY.md", - ...["brotli", "zstd", "emscripten", "musl"].map( + "dist/compat/zlib/LICENSE-APACHE", + "dist/compat/zlib/LICENSE-3RD-PARTY.md", + ...["brotli", "zstd", "zlib", "emscripten", "musl"].map( (name) => `dist/compat/zlib/LICENSE.${name}`, ), ]) { @@ -152,6 +165,7 @@ execFileSync( "express@5.3.0", "@capacitor/core@8.5.3", "typescript@7.0.2", + "@types/node@26.6.5", ], { cwd: temp, stdio: "pipe" }, ); @@ -296,6 +310,39 @@ execFileSync( ], { cwd: temp, stdio: "pipe" }, ); +// Compile the installed adapter declarations, rather than resolving node:fs +// to @types/node. Library checking must remain enabled to catch broken .d.ts. +for (const fixture of ["fs-consumer.mts", "zlib-consumer.mts"]) { + await writeFile( + join(temp, fixture), + (await readFile(join(root, "tests/types", fixture), "utf8")).replaceAll( + "../../src/", + "./node_modules/cf-workers-native/dist/", + ), + ); + execFileSync( + process.execPath, + [ + "node_modules/typescript/bin/tsc", + "--noEmit", + "--strict", + "--skipLibCheck", + "false", + "--types", + "node", + "--lib", + "esnext,dom", + "--moduleResolution", + "nodenext", + "--module", + "nodenext", + "--target", + "es2022", + fixture, + ], + { cwd: temp, stdio: "pipe" }, + ); +} // Readiness must identify this consumer, not an unrelated preview on the // same port. Otherwise a stale server can supply passing historical results. const previewIdentity = randomUUID(); diff --git a/src/cloudflare/apis/cloudflare-node/index.spec.ts b/src/cloudflare/apis/cloudflare-node/index.spec.ts index 00b7eaf..ecfa8b5 100644 --- a/src/cloudflare/apis/cloudflare-node/index.spec.ts +++ b/src/cloudflare/apis/cloudflare-node/index.spec.ts @@ -212,7 +212,7 @@ it("propagates response errors after headers to the body reader", async () => { } }); -it("cancels and unlocks an unread upload after an early response", async () => { +it("leaves an unread upload uncancelled after an early response", async () => { let cancelled = false; const upload = new ReadableStream({ cancel() { @@ -229,7 +229,7 @@ it("cancels and unlocks an unread upload after an early response", async () => { const response = await httpServerHandler(server).fetch(request); expect(await response.text()).toBe("early"); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(cancelled).toBe(true); + expect(cancelled).toBe(false); expect(request.body!.locked).toBe(false); } finally { server.close(); @@ -657,7 +657,7 @@ it("dispatches an inbound socket through the shared logical-port registry", asyn } }); -it("cancels the request upload when the response reader is cancelled", async () => { +it("leaves the request upload uncancelled when the response reader is cancelled", async () => { let cancelled = false; const request = new Request("https://example.test/", { method: "POST", @@ -675,7 +675,7 @@ it("cancels the request upload when the response reader is cancelled", async () const response = await httpServerHandler(server).fetch(request); await response.body!.cancel(); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(cancelled).toBe(true); + expect(cancelled).toBe(false); expect(request.body!.locked).toBe(false); } finally { server.close(); diff --git a/src/cloudflare/apis/cloudflare-node/index.ts b/src/cloudflare/apis/cloudflare-node/index.ts index 82ba766..f78b3de 100644 --- a/src/cloudflare/apis/cloudflare-node/index.ts +++ b/src/cloudflare/apis/cloudflare-node/index.ts @@ -1,3 +1,5 @@ +import { Headers } from "../../../browser/headers/index.js"; +import { Response as WorkersResponse } from "../../../browser/response/index.js"; // Copyright (c) 2024-2026 Cloudflare, Inc. // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 @@ -121,19 +123,22 @@ export async function handleAsNodeRequest( reject(error); res.destroy(error); }); - res.once("finish", () => { - request.signal.removeEventListener("abort", onAbort); - if (!req.complete) req.destroy(); - }); res.once("headers", () => { try { const headers = new Headers(); for (const [key, value] of Object.entries(res.getHeaders())) { - for (const item of Array.isArray(value) ? value : [value]) + for (const item of Array.isArray(value) + ? key.toLowerCase() === "cookie" && value.length >= 2 + ? [value.join("; ")] + : value + : [value]) headers.append(key, String(item)); } + // This bridge exposes the standard Fetch contract. Its HTTP body chunks + // are ArrayBuffer-backed; owned Headers also implements the iterator + // protocol without depending on a particular TypeScript DOM lib version. resolve( - new Response( + new WorkersResponse( request.method === "HEAD" || [204, 205, 304].includes(res.statusCode) ? null @@ -143,32 +148,16 @@ export async function handleAsNodeRequest( statusText: res.statusMessage, headers, }, - ), + ) as unknown as Response, ); } catch (error) { reject(error); res.destroy(error instanceof Error ? error : new Error(String(error))); } }); - const onAbort = () => { - const reason = request.signal.reason; - const error = - reason instanceof Error - ? reason - : new Error("The operation was aborted", { cause: reason }); - req.destroy(error); - res.destroy(error); - }; res.once("close", () => { - request.signal.removeEventListener("abort", onAbort); - if (!req.complete) req.destroy(); if (!res.finished) reject(new Error("Premature close")); }); - request.signal.addEventListener("abort", onAbort, { once: true }); - if (request.signal.aborted) { - onAbort(); - return; - } try { server.emit("connection", server, req); server.emit("request", req, res); diff --git a/src/compat/crypto/LICENSE-3RD-PARTY.md b/src/compat/crypto/LICENSE-3RD-PARTY.md index 7530a8e..a238059 100644 --- a/src/compat/crypto/LICENSE-3RD-PARTY.md +++ b/src/compat/crypto/LICENSE-3RD-PARTY.md @@ -17,3 +17,23 @@ pinned workerd module; internal helpers are not added to the public facade. X509 certificate wrappers and safe name rendering are adapted from `src/node/internal/crypto_x509.ts` and `src/workerd/api/crypto/x509.c++` at workerd commit `5358e3d337336eaf696a48bc1563d808b10feba5` (Apache-2.0). + +Hash/HMAC and PBKDF2 frontends and digest/KDF test fixtures are adapted from +`src/node/internal/crypto_hash.ts`, `src/node/internal/crypto_pbkdf2.ts` and +`src/workerd/api/node/tests/crypto_{hash,hmac,pbkdf2,hkdf}-test.js` at the same +pinned workerd commit. The Apache-2.0 and Node.js MIT headers are retained. +Imports use the owned adapters. Latin-1 fixture expectations written as literal +caret notation upstream are decoded using independently verified digest bytes; +comments identify those adaptations. Float16 input-brand cases require the +engine's intrinsic; native evidence records its availability. + +The Cipheriv/Decipheriv frontend is adapted from `src/node/internal/crypto_cipher.ts` +and lifecycle behavior from `src/workerd/api/node/crypto.c++` at that same workerd +revision. The cipher fixture retains the upstream `crypto_cipher-test.js` notices. +BoringSSL commit `427ec40cc8edc545253289232e885708c409e5ea` supplies the bundled cipher +primitives; its license and bundled third-party notices are in `LICENSE.boringssl`. + +PEM/DER key parsing is adapted from `src/ncrypto.cpp` in Node.js ncrypto +`v1.1.4`, the version pinned by workerd. Copyright (c) 2025 Node.js; the MIT +license is retained in `LICENSE.ncrypto`. Parsing uses the bundled BoringSSL +backend before transferring owned key material to the OpenSSL operation backend. diff --git a/src/compat/crypto/LICENSE.boringssl b/src/compat/crypto/LICENSE.boringssl new file mode 100644 index 0000000..37a5b74 --- /dev/null +++ b/src/compat/crypto/LICENSE.boringssl @@ -0,0 +1,238 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +Licenses for support code +------------------------- + +Parts of the TLS test suite are under the Go license. This code is not included +in BoringSSL (i.e. libcrypto and libssl) when compiled, however, so +distributing code linked against BoringSSL does not trigger this license: + +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/compat/crypto/LICENSE.ncrypto b/src/compat/crypto/LICENSE.ncrypto new file mode 100644 index 0000000..6da37b6 --- /dev/null +++ b/src/compat/crypto/LICENSE.ncrypto @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Node.js + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/compat/crypto/asymmetric.ts b/src/compat/crypto/asymmetric.ts index bcb99fb..f74b328 100644 --- a/src/compat/crypto/asymmetric.ts +++ b/src/compat/crypto/asymmetric.ts @@ -1,3 +1,10 @@ +import { signatureDigest } from "./digests.js"; +import { curveName, generateDh, deriveDh, decodeKey } from "./native-cipher.js"; +import { validateExportOptions } from "./key-options.js"; +import { + ERR_INCOMPATIBLE_OPTION_PAIR, + ERR_MISSING_OPTION, +} from "./key-errors.js"; import { Buffer } from "node:buffer"; import { NativeKey, nativeTimingEqual } from "./native-crypto.js"; import { importJwk } from "./key-formats.js"; @@ -8,13 +15,14 @@ import { PublicKeyObject, } from "./keys.js"; import { keyStates } from "./key-state.js"; -import { bytes, validateInteger } from "./validation.js"; +import { bytes, validateInteger, isAnyBuffer } from "./validation.js"; import { validateObject, validateString, validateOneOf, validateFunction, validateUint32, + validateInt32, } from "../net/validators.js"; import { ERR_INVALID_ARG_TYPE, ERR_INVALID_ARG_VALUE } from "../net/errors.js"; function handle(key: KeyObject): NativeKey { @@ -36,12 +44,16 @@ function input(value: any, privateKey: boolean): NativeKey { value, ); const options = - typeof value === "string" || - value instanceof ArrayBuffer || - ArrayBuffer.isView(value) + typeof value === "string" || isAnyBuffer(value) || ArrayBuffer.isView(value) ? { key: value } : value; - const data = options.key; + const { + key: data, + encoding = "utf8", + format = "pem", + type, + passphrase, + } = options; if ( data == null || data instanceof KeyObject || @@ -54,44 +66,40 @@ function input(value: any, privateKey: boolean): NativeKey { ); if ( typeof data === "object" && - !(data instanceof ArrayBuffer) && + !isAnyBuffer(data) && !ArrayBuffer.isView(data) ) return importJwk(data, privateKey); - const format = options.format ?? "pem"; + if ( + typeof data !== "string" && + !isAnyBuffer(data) && + !ArrayBuffer.isView(data) + ) + throw new ERR_INVALID_ARG_TYPE( + "key", + ["ArrayBuffer", "Buffer", "TypedArray", "DataView", "string", "object"], + value, + ); validateOneOf(format, "format", ["pem", "der"]); - if (options.type !== undefined) + if (type !== undefined) validateOneOf( - options.type, + type, "type", privateKey ? ["pkcs1", "pkcs8", "sec1"] : ["pkcs1", "spki"], ); const raw = - typeof data === "string" - ? Buffer.from(data, options.encoding ?? "utf8") - : bytes(data, "key"); + typeof data === "string" ? Buffer.from(data, encoding) : bytes(data, "key"); const password = - options.passphrase === undefined + passphrase == null ? undefined - : typeof options.passphrase === "string" - ? Buffer.from(options.passphrase, options.encoding ?? "utf8") - : bytes(options.passphrase, "passphrase"); - if (privateKey) return NativeKey.parse(raw, format === "pem", true, password); + : typeof passphrase === "string" + ? Buffer.from(passphrase, encoding) + : bytes(passphrase, "passphrase"); + const decoded = decodeKey(raw, format === "pem", privateKey, type, password); try { - return NativeKey.parse(raw, format === "pem", false, password); - } catch (error) { - // createPublicKey also accepts a serialized private key, deriving public material. - let key: NativeKey; - try { - key = NativeKey.parse(raw, format === "pem", true, password); - } catch { - throw error; - } - try { - return NativeKey.parse(key.export(false, false, "spki"), false, false); - } finally { - key.dispose(); - } + return NativeKey.parse(decoded.data, false, decoded.privateKey); + } finally { + decoded.data.fill(0); } } export function createPrivateKey(value: unknown): PrivateKeyObject { @@ -104,11 +112,7 @@ export function createPublicKey(value: any): PublicKeyObject { if (value.type !== "private") throw new ERR_INVALID_ARG_TYPE("key", "PrivateKeyObject", value); return create("public", { - asymmetric: NativeKey.parse( - handle(value).export(false, false, "spki"), - false, - false, - ), + asymmetric: handle(value), }) as PublicKeyObject; } return create("public", { @@ -118,47 +122,135 @@ export function createPublicKey(value: any): PublicKeyObject { export function generateKeyPairSync(type: string, options: any = {}) { validateOneOf(type, "type", ["rsa", "ec", "ed25519", "x25519", "dh"]); validateObject(options, "options"); + const { + modulusLength, + publicExponent = 65537, + namedCurve, + prime, + primeLength, + generator, + group, + groupName, + paramEncoding = "named", + publicKeyEncoding, + privateKeyEncoding, + } = options; + if (publicKeyEncoding !== undefined) + validateExportOptions( + publicKeyEncoding, + "public", + "options.publicKeyEncoding", + ); + if (privateKeyEncoding !== undefined) + validateExportOptions( + privateKeyEncoding, + "private", + "options.privateKeyEncoding", + type, + ); let key: NativeKey; if (type === "rsa") { - validateUint32(options.modulusLength, "options.modulusLength"); - validateUint32(options.publicExponent ?? 65537, "options.publicExponent"); - key = NativeKey.generate( - "RSA", - options.modulusLength, - undefined, - options.publicExponent ?? 65537, - ); + validateUint32(modulusLength, "options.modulusLength"); + validateUint32(publicExponent, "options.publicExponent"); + if (modulusLength > 16384) + throw new RangeError("RSA modulusLength exceeds maximum (16384)"); + if (modulusLength < 256) throw new Error("Failed to set modulus length"); + if (modulusLength < 512 || publicExponent < 3 || publicExponent % 2 === 0) + throw new Error("Failed to generate key"); + try { + key = NativeKey.generate( + "RSA", + modulusLength & ~127, + undefined, + publicExponent, + ); + } catch { + throw new Error("Failed to generate key"); + } } else if (type === "ec") { - validateString(options.namedCurve, "options.namedCurve"); - validateOneOf(options.paramEncoding ?? "named", "options.paramEncoding", [ + validateString(namedCurve, "options.namedCurve"); + validateOneOf(paramEncoding, "options.paramEncoding", [ "named", "explicit", ]); - key = NativeKey.generate("EC", 0, options.namedCurve); + key = NativeKey.generate("EC", 0, curveName(namedCurve)); + key.ecEncoding = paramEncoding === "named" ? 1 : 0; } else if (type === "dh") { - const groups: Record = { - modp1: "modp_768", - modp2: "modp_1024", - modp5: "modp_1536", - modp14: "modp_2048", - modp15: "modp_3072", - modp16: "modp_4096", - modp17: "modp_6144", - modp18: "modp_8192", - }; - const group = options.group ?? options.groupName; - validateString(group, "options.group"); - key = NativeKey.generate("DH", 0, groups[group] ?? group); + if (generator != null) validateInt32(generator, "options.generator", 0); + if (group != null || groupName != null) { + for (const [name, value] of [ + ["prime", prime], + ["primeLength", primeLength], + ["generator", generator], + ]) + if (value != null) + throw new ERR_INCOMPATIBLE_OPTION_PAIR("group", name); + const selected = group || groupName; + validateString(selected, "options.group"); + const groups: Record = Object.assign( + Object.create(null), + { + modp5: "modp_1536", + modp14: "modp_2048", + modp15: "modp_3072", + modp16: "modp_4096", + modp17: "modp_6144", + modp18: "modp_8192", + }, + ); + const nativeGroup = groups[selected.toLowerCase()]; + if (!nativeGroup) throw new Error("Invalid or unsupported group"); + key = NativeKey.generate("DH", 0, nativeGroup); + } else { + if (prime != null) { + if (primeLength != null) + throw new ERR_INCOMPATIBLE_OPTION_PAIR("prime", "primeLength"); + if (!ArrayBuffer.isView(prime) && !isAnyBuffer(prime)) + throw new ERR_INVALID_ARG_TYPE( + "options.prime", + ["Buffer", "TypedArray", "ArrayBuffer"], + prime, + ); + } else if (primeLength != null) + validateInt32(primeLength, "options.primeLength", 0); + else + throw new ERR_MISSING_OPTION( + "At least one of the group, prime, or primeLength options", + ); + if (prime) { + const data = bytes(prime, "options.prime"); + if (data.byteLength > 1024) + throw new RangeError("DH prime exceeds maximum size"); + const components = generateDh(data, generator ?? 2); + try { + key = NativeKey.generateDh(data, generator ?? 2, components); + } finally { + components.fill(0); + } + } else + throw new Error( + "Generating DH keys from a prime length is not yet implemented", + ); + } } else key = NativeKey.generate(type.toUpperCase()); const privateKey = create("private", { asymmetric: key }) as PrivateKeyObject; - const publicKey = createPublicKey(privateKey); + // Workers constructs a separate public-only EC key. Other generated pairs + // retain the same key material under their public and private wrappers. + const publicKey = create("public", { + asymmetric: + type === "ec" + ? NativeKey.parse(key.export(false, false, "spki"), false, false) + : key, + }) as PublicKeyObject; return { - publicKey: options.publicKeyEncoding - ? publicKey.export(options.publicKeyEncoding) - : publicKey, - privateKey: options.privateKeyEncoding - ? privateKey.export(options.privateKeyEncoding) - : privateKey, + publicKey: + publicKeyEncoding !== undefined + ? publicKey.export(publicKeyEncoding) + : publicKey, + privateKey: + privateKeyEncoding !== undefined + ? privateKey.export(privateKeyEncoding) + : privateKey, }; } export function generateKeyPair( @@ -166,10 +258,6 @@ export function generateKeyPair( options: any, callback: any, ): void { - if (typeof options === "function") { - callback = options; - options = {}; - } validateFunction(callback, "callback"); let result: ReturnType | undefined, failure: unknown; @@ -184,6 +272,11 @@ export function generateKeyPair( : callback(null, result!.publicKey, result!.privateKey), ); } +Object.defineProperty( + generateKeyPair, + Symbol.for("nodejs.util.promisify.custom.args"), + { value: ["publicKey", "privateKey"], enumerable: false }, +); function optionsOf(value: any) { return value && typeof value === "object" && @@ -216,15 +309,19 @@ export function sign( const digest = digestName(algorithm), raw = bytes(data, "data"); const execute = () => { - const options = optionsOf(key); + const options = optionsOf(key), + native = handle(operationKey(key, true)); + if (native.type === "dsa") + throw new Error("Signing with DSA keys is not currently supported"); + const normalized = digest === null ? null : signatureDigest(digest); + if ( + native.type === "x25519" || + (native.type === "ed25519" && normalized !== null) || + (native.type !== "ed25519" && normalized === null) + ) + throw new Error("Failed to initialize signing context"); return Buffer.from( - handle(operationKey(key, true)).sign( - digest, - raw, - options.padding ?? 0, - options.saltLength ?? -1, - options.dsaEncoding === "ieee-p1363", - ), + native.sign(normalized, raw, 0, -1, options.dsaEncoding === "ieee-p1363"), ); }; if (callback === undefined) return execute(); @@ -248,13 +345,23 @@ export function verify( raw = bytes(data, "data"), sig = bytes(signature, "signature"); const execute = () => { - const options = optionsOf(key); - return handle(operationKey(key, false)).verify( - digest, + const options = optionsOf(key), + native = handle(operationKey(key, false)); + if (native.type === "dsa") + throw new Error("Verifying with DSA keys is not currently supported"); + const normalized = digest === null ? null : signatureDigest(digest); + if ( + native.type === "x25519" || + (native.type === "ed25519" && normalized !== null) || + (native.type !== "ed25519" && normalized === null) + ) + throw new Error("Failed to initialize verification context"); + return native.verify( + normalized, raw, sig, - options.padding ?? 0, - options.saltLength ?? -1, + 0, + -1, options.dsaEncoding === "ieee-p1363", ); }; @@ -303,6 +410,28 @@ export function diffieHellman(options: any): Buffer { // DHPointer::stateless argument. Preserve its observable failure rather than // silently substituting standalone Node behavior. try { + if (publicHandle.type === "dh") { + for (const name of ["p", "g"]) + if ( + !Buffer.from(publicHandle.parameter(name)).equals( + Buffer.from(privateHandle.parameter(name)), + ) + ) + throw new Error("Failed to derive shared diffie-hellman secret"); + const secret = publicHandle.parameter("priv"); + try { + return Buffer.from( + deriveDh( + publicHandle.parameter("p"), + publicHandle.parameter("g"), + secret, + privateHandle.parameter("pub"), + ), + ); + } finally { + secret.fill(0); + } + } return Buffer.from(publicHandle.derive(privateHandle)); } catch { throw new Error("Failed to derive shared diffie-hellman secret"); diff --git a/src/compat/crypto/cipher-handle.ts b/src/compat/crypto/cipher-handle.ts new file mode 100644 index 0000000..3c0e741 --- /dev/null +++ b/src/compat/crypto/cipher-handle.ts @@ -0,0 +1,349 @@ +// Workers CipherHandle/AeadHandle lifecycle, pinned to workerd +// 5358e3d337336eaf696a48bc1563d808b10feba5 (crypto.c++), Apache-2.0. +import { library, inputs, output, size } from "./native-cipher.js"; +import { secretMaterial } from "./keys.js"; +import { bufferBytes, viewBytes } from "../../shared/buffer-source.js"; +import { isAnyBuffer } from "./validation.js"; +const aeadNames = new Set([ + "aes-128-gcm", + "aes-192-gcm", + "aes-256-gcm", + "aes-128-eax", + "aes-256-eax", + "aes-128-ccm-matter", + "aes-128-ccm-bluetooth", + "aes-128-ccm-bluetooth-8", + "aes-128-gcm-randnonce", + "aes-256-gcm-randnonce", + "aes-128-gcm-siv", + "aes-256-gcm-siv", + "aes-128-ctr-hmac-sha256", + "aes-256-ctr-hmac-sha256", + "xchacha20-poly1305", + "chacha20-poly1305", +]); +const finalized = "Cipher/decipher context has already been finalized"; +function requireValue(value: unknown, message: string): asserts value { + if (!value) throw new Error(message); +} +function bytes(value: ArrayBuffer | ArrayBufferView): Uint8Array { + if (isAnyBuffer(value)) return bufferBytes(value); + if (ArrayBuffer.isView(value)) return viewBytes(value); + throw new TypeError("Expected an ArrayBuffer or ArrayBufferView"); +} +const released = new FinalizationRegistry<{ pointer: number; aead: boolean }>( + ({ pointer, aead }) => + library()[aead ? "cipher_aead_free" : "cipher_free"](pointer), +); +const validTag = (n: number) => n === 4 || n === 8 || (n >= 12 && n <= 16); +export class CipherHandle { + private pointer = 0; + private aead = false; + private auth = false; + private mode = 0; + private block = 1; + private iv: Uint8Array; + private aad?: Uint8Array; + private tag?: Uint8Array; + private tagLength?: number; + private tagPassed = false; + private updated = false; + private decrypt: boolean; + constructor( + direction: number, + algorithm: string, + key: unknown, + iv: ArrayBuffer | ArrayBufferView, + tagLength?: number, + ) { + this.decrypt = direction === 1; + this.iv = bytes(iv).slice(); + this.tagLength = tagLength; + const material = secretMaterial(key), + raw = bytes(material); + try { + inputs( + [algorithm, algorithm.toLowerCase().replaceAll("-", "_"), raw, this.iv], + (api, [name, aeadName, k, v]) => { + const cipher = api.cipher_lookup(name); + if (cipher) { + this.mode = api.cipher_info(cipher, 3); + this.auth = this.mode === 6; + this.block = api.cipher_info(cipher, 2); + const expected = api.cipher_info(cipher, 1); + requireValue( + !(expected && !this.iv.length) && + !(!this.auth && this.iv.length && this.iv.length !== expected), + "Invalid initialization vector", + ); + this.pointer = api.cipher_new(); + requireValue( + this.pointer, + "Failed to create cipher/decipher context", + ); + requireValue( + api.cipher_init(this.pointer, cipher, 0, 0, +!this.decrypt), + "Failed to initialize cipher/decipher context", + ); + if (this.auth) { + requireValue( + api.cipher_iv_size(this.pointer, this.iv.length), + "Invalid initialization vector", + ); + requireValue( + tagLength === undefined || validTag(tagLength), + "Invalid authentication tag length", + ); + } + requireValue( + api.cipher_key_size(this.pointer, raw.length), + "Invalid key length", + ); + requireValue( + api.cipher_init(this.pointer, 0, k, v, +!this.decrypt), + "Failed to initialize cipher/cipher context", + ); + } else { + const aead = aeadNames.has(algorithm.toLowerCase()) + ? api.cipher_aead(aeadName) + : 0; + requireValue(aead, "Unknown or unsupported cipher: " + algorithm); + this.aead = true; + this.auth = true; + this.mode = algorithm.toLowerCase().includes("gcm") + ? 6 + : algorithm.toLowerCase().includes("ccm") + ? 7 + : 0; + requireValue( + this.iv.length === api.cipher_aead_info(aead, 1), + "Invalid initialization vector", + ); + this.pointer = api.cipher_aead_new( + aead, + k, + raw.length, + tagLength ?? 0, + ); + requireValue( + this.pointer, + "Failed to initialize AEAD cipher/decipher context", + ); + if (this.mode === 6) + requireValue( + tagLength === undefined || validTag(tagLength), + "Invalid authentication tag length", + ); + else if (tagLength === undefined) { + requireValue( + algorithm.toLowerCase() === "chacha20-poly1305", + "The auth tag length is required for cipher " + algorithm, + ); + this.tagLength = 16; + } + } + }, + ); + released.register(this, { pointer: this.pointer, aead: this.aead }, this); + } catch (error) { + this.dispose(); + throw error; + } + } + private dispose() { + if (this.pointer) { + library()[this.aead ? "cipher_aead_free" : "cipher_free"](this.pointer); + this.pointer = 0; + released.unregister(this); + } + this.iv.fill(0); + this.aad?.fill(0); + } + private passTag() { + if (this.decrypt && this.auth && !this.tagPassed) { + this.tagPassed = true; + requireValue(this.tag, "No auth tag provided"); + inputs([this.tag], (api, [p]) => + requireValue( + api.cipher_tag(this.pointer, 0, this.tag!.length, p), + "Failed to set auth tag", + ), + ); + } + } + update(value: ArrayBuffer | ArrayBufferView) { + if (this.aead) + requireValue( + !this.updated, + "update() can only be invoked once on an AEAD", + ); + requireValue(this.pointer, finalized); + const data = bytes(value); + requireValue(data.length <= 0x7fffffff, "Data too large"); + if (this.mode === 7) + requireValue(data.length <= 65535, "Invalid message length"); + requireValue(data.length + this.block <= 0x7fffffff, "Data too large"); + if (this.aead) { + if (this.decrypt) requireValue(this.tag, "No auth tag provided"); + else this.tagLength ??= 16; + const tagLength = this.decrypt ? this.tag!.length : this.tagLength!; + const result = inputs( + [ + data, + this.iv, + this.aad ?? new Uint8Array(), + this.tag ?? tagLength, + data.length, + 4, + ], + (api, [d, v, a, t, o, n]) => { + const ok = this.decrypt + ? api.cipher_aead_open( + this.pointer, + o, + v, + this.iv.length, + d, + data.length, + t, + tagLength, + a, + this.aad?.length ?? 0, + ) + : api.cipher_aead_seal( + this.pointer, + o, + t, + n, + tagLength, + v, + this.iv.length, + d, + data.length, + a, + this.aad?.length ?? 0, + ); + // Workers exposes this backend failure as an internal Error with a per-run reference. + if (!ok && api.cipher_bad_decrypt()) + throw new DOMException( + "Decryption failed: Incorrect key/IV or corrupted ciphertext/authentication tag provided.", + "OperationError", + ); + requireValue(ok, "internal error"); + if (!this.decrypt) this.tag = output(api, t, size(api, n)); + return output(api, o, data.length); + }, + ); + this.updated = true; + return result; + } + this.passTag(); + return inputs([data, data.length + this.block, 4], (api, [d, o, n]) => { + api.cipher_update(this.pointer, o, n, d, data.length); + const length = size(api, n); + requireValue(length <= data.length + this.block, "Invalid buffer length"); + return output(api, o, length); + }); + } + final() { + requireValue(this.pointer, finalized); + if (this.aead) { + this.dispose(); + return new Uint8Array(); + } + this.passTag(); + const result = inputs([this.block, 4], (api, [o, n]) => { + requireValue( + api.cipher_final(this.pointer, o, n), + "Authentication failed", + ); + const length = size(api, n); + requireValue(length <= this.block, "Invalid buffer length"); + if (!this.decrypt && this.auth) { + this.tagLength ??= 16; + this.tag = inputs([this.tagLength], (api, [t]) => { + requireValue( + api.cipher_tag(this.pointer, 1, this.tagLength!, t), + "Authentication failed", + ); + return output(api, t, this.tagLength!); + }); + } + return output(api, o, length); + }); + this.dispose(); + return result; + } + setAAD(value: ArrayBuffer | ArrayBufferView, plaintextLength?: number) { + requireValue(this.pointer, finalized); + requireValue(this.auth, "Cipher does not support authenticated mode"); + const data = bytes(value); + if (this.aead) { + if (this.mode === 7) { + requireValue( + plaintextLength !== undefined, + "options.plaintextLength is required for CCM mode with AAD", + ); + requireValue(plaintextLength! <= 65535, "Data too large"); + if (this.decrypt) requireValue(this.tag, "No auth tag provided"); + } + this.aad?.fill(0); + this.aad = data.slice(); + } else + inputs([data, 4], (api, [p, n]) => + requireValue( + api.cipher_update(this.pointer, 0, n, p, data.length), + "Failed to set AAD", + ), + ); + } + setAutoPadding(value: boolean) { + requireValue(this.pointer, finalized); + requireValue(!this.aead, "Setting autopadding is not supported on AEADs"); + requireValue( + library().cipher_padding(this.pointer, +value), + "Failed to set autopadding", + ); + } + setAuthTag(value: ArrayBuffer | ArrayBufferView) { + if (!isAnyBuffer(value) && !ArrayBuffer.isView(value)) + throw new TypeError( + `Failed to execute 'setAuthTag' on '${this.aead ? "AeadHandle" : "CipherHandle"}': parameter 1 is not of type 'BufferSource'.`, + ); + requireValue(this.pointer, finalized); + requireValue(this.auth, "Cipher does not support authenticated mode"); + requireValue( + this.decrypt, + "Setting auth tag only support in decipher mode", + ); + requireValue(!this.tag, "Auth tag is already set"); + const data = bytes(value); + requireValue(data.length <= 0x7fffffff, "Auth tag is too big"); + requireValue( + this.mode === 6 + ? (this.tagLength === undefined || this.tagLength === data.length) && + validTag(data.length) + : this.tagLength === data.length, + "Invalid authentication tag length", + ); + this.tagLength = data.length; + this.tag = data.slice(); + } + getAuthTag() { + requireValue( + !this.pointer, + "Auth tag is only available once cipher context has been finalized", + ); + requireValue( + !this.decrypt, + "Getting the auth tag is only support for cipher", + ); + const tag = this.tag ?? new Uint8Array(); + this.tag = undefined; + return tag; + } +} +export default { + newHandle: (...args: ConstructorParameters) => + new CipherHandle(...args), +}; diff --git a/src/compat/crypto/cipher-info.ts b/src/compat/crypto/cipher-info.ts new file mode 100644 index 0000000..d203395 --- /dev/null +++ b/src/compat/crypto/cipher-info.ts @@ -0,0 +1,333 @@ +// Cipher metadata from workerd@5358e3d337336eaf696a48bc1563d808b10feba5. +// Metadata lookup does not imply that the local backend implements the cipher. +import { validateObject } from "../net/validators.js"; +import { ERR_INVALID_ARG_TYPE } from "../net/errors.js"; +export interface CipherInfo { + name: string; + nid: number; + blockSize: number; + ivLength: number; + keyLength: number; + mode: string; +} +const ciphers: Record = { + "aes-128-cbc": { + name: "AES-128-CBC", + nid: 419, + blockSize: 16, + ivLength: 16, + keyLength: 16, + mode: "cbc", + }, + "aes-192-cbc": { + name: "AES-192-CBC", + nid: 423, + blockSize: 16, + ivLength: 16, + keyLength: 24, + mode: "cbc", + }, + "aes-256-cbc": { + name: "AES-256-CBC", + nid: 427, + blockSize: 16, + ivLength: 16, + keyLength: 32, + mode: "cbc", + }, + "aes-128-ctr": { + name: "AES-128-CTR", + nid: 904, + blockSize: 1, + ivLength: 16, + keyLength: 16, + mode: "ctr", + }, + "aes-192-ctr": { + name: "AES-192-CTR", + nid: 905, + blockSize: 1, + ivLength: 16, + keyLength: 24, + mode: "ctr", + }, + "aes-256-ctr": { + name: "AES-256-CTR", + nid: 906, + blockSize: 1, + ivLength: 16, + keyLength: 32, + mode: "ctr", + }, + "aes-128-ecb": { + name: "AES-128-ECB", + nid: 418, + blockSize: 16, + ivLength: 0, + keyLength: 16, + mode: "ecb", + }, + "aes-192-ecb": { + name: "AES-192-ECB", + nid: 422, + blockSize: 16, + ivLength: 0, + keyLength: 24, + mode: "ecb", + }, + "aes-256-ecb": { + name: "AES-256-ECB", + nid: 426, + blockSize: 16, + ivLength: 0, + keyLength: 32, + mode: "ecb", + }, + "aes-128-ofb": { + name: "AES-128-OFB", + nid: 420, + blockSize: 1, + ivLength: 16, + keyLength: 16, + mode: "ofb", + }, + "aes-192-ofb": { + name: "AES-192-OFB", + nid: 424, + blockSize: 1, + ivLength: 16, + keyLength: 24, + mode: "ofb", + }, + "aes-256-ofb": { + name: "AES-256-OFB", + nid: 428, + blockSize: 1, + ivLength: 16, + keyLength: 32, + mode: "ofb", + }, + "aes-128-gcm": { + name: "id-aes128-GCM", + nid: 895, + blockSize: 1, + ivLength: 12, + keyLength: 16, + mode: "gcm", + }, + "aes-192-gcm": { + name: "id-aes192-GCM", + nid: 898, + blockSize: 1, + ivLength: 12, + keyLength: 24, + mode: "gcm", + }, + "aes-256-gcm": { + name: "id-aes256-GCM", + nid: 901, + blockSize: 1, + ivLength: 12, + keyLength: 32, + mode: "gcm", + }, + "des-cbc": { + name: "DES-CBC", + nid: 31, + blockSize: 8, + ivLength: 8, + keyLength: 8, + mode: "cbc", + }, + "des-ecb": { + name: "DES-ECB", + nid: 29, + blockSize: 8, + ivLength: 0, + keyLength: 8, + mode: "ecb", + }, + "des-ede": { + name: "DES-EDE", + nid: 32, + blockSize: 8, + ivLength: 0, + keyLength: 16, + mode: "ecb", + }, + "des-ede-cbc": { + name: "DES-EDE-CBC", + nid: 43, + blockSize: 8, + ivLength: 8, + keyLength: 16, + mode: "cbc", + }, + "des-ede3-cbc": { + name: "DES-EDE3-CBC", + nid: 44, + blockSize: 8, + ivLength: 8, + keyLength: 24, + mode: "cbc", + }, + "rc2-cbc": { + name: "RC2-CBC", + nid: 37, + blockSize: 8, + ivLength: 8, + keyLength: 16, + mode: "cbc", + }, + rc4: { + name: "RC4", + nid: 5, + blockSize: 1, + ivLength: 0, + keyLength: 16, + mode: "stream", + }, + "aes-256-eax": { + name: "aes-256-eax", + nid: 0, + blockSize: 1, + ivLength: 16, + keyLength: 32, + mode: "stream", + }, + "aes-128-ccm-matter": { + name: "aes-128-ccm-matter", + nid: 0, + blockSize: 1, + ivLength: 13, + keyLength: 16, + mode: "ccm", + }, + "aes-128-ccm-bluetooth-8": { + name: "aes-128-ccm-bluetooth-8", + nid: 0, + blockSize: 1, + ivLength: 13, + keyLength: 16, + mode: "ccm", + }, + "aes-128-gcm-randnonce": { + name: "aes-128-gcm-randnonce", + nid: 0, + blockSize: 1, + ivLength: 0, + keyLength: 16, + mode: "gcm", + }, + "aes-256-gcm-randnonce": { + name: "aes-256-gcm-randnonce", + nid: 0, + blockSize: 1, + ivLength: 0, + keyLength: 32, + mode: "gcm", + }, + "aes-128-gcm-siv": { + name: "aes-128-gcm-siv", + nid: 0, + blockSize: 1, + ivLength: 12, + keyLength: 16, + mode: "gcm", + }, + "aes-128-ccm-bluetooth": { + name: "aes-128-ccm-bluetooth", + nid: 0, + blockSize: 1, + ivLength: 13, + keyLength: 16, + mode: "ccm", + }, + "aes-256-ctr-hmac-sha256": { + name: "aes-256-ctr-hmac-sha256", + nid: 0, + blockSize: 1, + ivLength: 12, + keyLength: 64, + mode: "ctr", + }, + "aes-128-ctr-hmac-sha256": { + name: "aes-128-ctr-hmac-sha256", + nid: 0, + blockSize: 1, + ivLength: 12, + keyLength: 48, + mode: "ctr", + }, + "xchacha20-poly1305": { + name: "xchacha20-poly1305", + nid: 0, + blockSize: 1, + ivLength: 24, + keyLength: 32, + mode: "stream", + }, + "aes-256-gcm-siv": { + name: "aes-256-gcm-siv", + nid: 0, + blockSize: 1, + ivLength: 12, + keyLength: 32, + mode: "gcm", + }, + "aes-128-eax": { + name: "aes-128-eax", + nid: 0, + blockSize: 1, + ivLength: 16, + keyLength: 16, + mode: "stream", + }, + "chacha20-poly1305": { + name: "chacha20-poly1305", + nid: 950, + blockSize: 1, + ivLength: 12, + keyLength: 32, + mode: "stream", + }, +}; +export function getCipherInfo( + nameOrId: string | number, + options: { keyLength?: number; ivLength?: number } = {}, +): CipherInfo | undefined { + if (typeof nameOrId !== "string" && typeof nameOrId !== "number") + throw new ERR_INVALID_ARG_TYPE("nameOrId", ["string", "number"], nameOrId); + validateObject(options, "options"); + // The native binding converts both optional lengths before looking up a cipher. + const keyLength = + options.keyLength === undefined ? undefined : Number(options.keyLength) | 0; + const ivLength = + options.ivLength === undefined ? undefined : Number(options.ivLength) | 0; + const found = + typeof nameOrId === "string" + ? ciphers[nameOrId.toLowerCase()] + : Object.values(ciphers).find( + (c) => c.nid !== 0 && c.nid !== 950 && c.nid === (nameOrId | 0), + ); + if (!found) return; + const result = { ...found }; + // BoringSSL AEAD metadata uses a separate lookup, without length overrides. + if (found.nid === 0 || found.nid === 950) { + result.name = String(nameOrId); + return result; + } + if (keyLength !== undefined) { + if (found.nid === 5 || found.nid === 37) { + if (keyLength <= 0) return; + } else if (keyLength !== found.keyLength) return; + result.keyLength = keyLength; + } + if (ivLength !== undefined) { + if (found.mode === "gcm") { + if (ivLength <= 0) return; + } else if (ivLength !== found.ivLength) return; + result.ivLength = ivLength; + } + return result; +} diff --git a/src/compat/crypto/cipher.spec.ts b/src/compat/crypto/cipher.spec.ts new file mode 100644 index 0000000..fb19c46 --- /dev/null +++ b/src/compat/crypto/cipher.spec.ts @@ -0,0 +1,55 @@ +import { test } from "vitest"; +import { generateKeyPairSync } from "node:crypto"; +// @ts-expect-error Vendored upstream JavaScript fixture. +import * as cases from "./upstream-cipher.fixture.mjs"; +const keys = generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, +}); +const env = { + "rsa_public.pem": keys.publicKey, + "rsa_private.pem": keys.privateKey, +}; +for (const [name, fixture] of Object.entries(cases)) + test("pinned Workers cipher: " + name, () => + ( + fixture as { + test: (controller: unknown, bindings: typeof env) => unknown; + } + ).test(null, env), + ); + +import { expect } from "vitest"; +import { Buffer } from "node:buffer"; +import { + createCipheriv, + createDecipheriv, + Cipheriv, + Decipheriv, +} from "./symmetric.js"; +test("cipher declarations support authentication options and encoded output", () => { + const key = new Uint8Array(16), + iv = new Uint8Array(16); + const cipher = createCipheriv("aes-128-eax", key, iv, { authLengthTag: 16 }); + const encrypted: Buffer = cipher.update("hello", "utf8"); + const last: Buffer = cipher.final(); + const tag: Buffer = cipher.getAuthTag(); + const decipher = createDecipheriv("aes-128-eax", key, iv, { + authLengthTag: 16, + }); + decipher.setAuthTag(tag); + const invalidTag = () => { + // @ts-expect-error Workers accepts only BufferSource tags, even with encoding. + decipher.setAuthTag("00", "hex"); + }; + void invalidTag; + const plain: string = decipher.update(encrypted, undefined, "utf8"); + const end: string = decipher.final("utf8"); + expect(plain + end).toBe("hello"); + expect(last.length).toBe(0); + const ctor = new Cipheriv("aes-128-eax", key, iv, { authLengthTag: 16 }); + const dec = new Decipheriv("aes-128-eax", key, iv, { authLengthTag: 16 }); + ctor.final(); + dec.final(); +}); diff --git a/src/compat/crypto/constants.ts b/src/compat/crypto/constants.ts new file mode 100644 index 0000000..7f9b4d2 --- /dev/null +++ b/src/compat/crypto/constants.ts @@ -0,0 +1,75 @@ +// Pinned workerd 1.20261012.1 / compatibility date 2026-10-17. +// Descriptor snapshot from the shared Workers acceptance probe. +const values = { + DH_CHECK_P_NOT_SAFE_PRIME: 2, + DH_CHECK_P_NOT_PRIME: 1, + DH_UNABLE_TO_CHECK_GENERATOR: 4, + DH_NOT_SUITABLE_GENERATOR: 8, + RSA_PKCS1_PADDING: 1, + RSA_NO_PADDING: 3, + RSA_PKCS1_OAEP_PADDING: 4, + RSA_X931_PADDING: 5, + RSA_PKCS1_PSS_PADDING: 6, + RSA_PSS_SALTLEN_DIGEST: -1, + RSA_PSS_SALTLEN_MAX_SIGN: -2, + RSA_PSS_SALTLEN_AUTO: -2, + POINT_CONVERSION_COMPRESSED: 2, + POINT_CONVERSION_UNCOMPRESSED: 4, + POINT_CONVERSION_HYBRID: 6, + OPENSSL_VERSION_NUMBER: 0, + SSL_OP_ALL: 2147485776, + SSL_OP_ALLOW_NO_DHE_KEX: 1024, + SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION: 262144, + SSL_OP_CIPHER_SERVER_PREFERENCE: 4194304, + SSL_OP_CISCO_ANYCONNECT: 32768, + SSL_OP_COOKIE_EXCHANGE: 8192, + SSL_OP_CRYPTOPRO_TLSEXT_BUG: 2147483648, + SSL_OP_DONT_INSERT_EMPTY_FRAGMENTS: 2048, + SSL_OP_LEGACY_SERVER_CONNECT: 4, + SSL_OP_NO_COMPRESSION: 131072, + SSL_OP_NO_ENCRYPT_THEN_MAC: 524288, + SSL_OP_NO_QUERY_MTU: 4096, + SSL_OP_NO_RENEGOTIATION: 1073741824, + SSL_OP_NO_SESSION_RESUMPTION_ON_RENEGOTIATION: 65536, + SSL_OP_NO_SSLv2: 0, + SSL_OP_NO_SSLv3: 33554432, + SSL_OP_NO_TICKET: 16384, + SSL_OP_NO_TLSv1: 67108864, + SSL_OP_NO_TLSv1_1: 268435456, + SSL_OP_NO_TLSv1_2: 134217728, + SSL_OP_NO_TLSv1_3: 536870912, + SSL_OP_PRIORITIZE_CHACHA: 2097152, + SSL_OP_TLS_ROLLBACK_BUG: 8388608, + ENGINE_METHOD_RSA: 1, + ENGINE_METHOD_DSA: 2, + ENGINE_METHOD_DH: 4, + ENGINE_METHOD_RAND: 8, + ENGINE_METHOD_EC: 2048, + ENGINE_METHOD_CIPHERS: 64, + ENGINE_METHOD_DIGESTS: 128, + ENGINE_METHOD_PKEY_METHS: 512, + ENGINE_METHOD_PKEY_ASN1_METHS: 1024, + ENGINE_METHOD_ALL: 65535, + ENGINE_METHOD_NONE: 0, + defaultCoreCipherList: "", + TLS1_VERSION: 769, + TLS1_1_VERSION: 770, + TLS1_2_VERSION: 771, + TLS1_3_VERSION: 772, + defaultCipherList: "", +} as const; +export const constants = Object.create(null) as Omit< + { + readonly [K in keyof typeof values]: (typeof values)[K] extends number + ? number + : string; + }, + "defaultCipherList" +> & { defaultCipherList: string }; +for (const [name, value] of Object.entries(values)) + Object.defineProperty(constants, name, { + value, + writable: name === "defaultCipherList", + configurable: name === "defaultCipherList", + enumerable: false, + }); diff --git a/src/compat/crypto/dh-handle.ts b/src/compat/crypto/dh-handle.ts new file mode 100644 index 0000000..dfc7209 --- /dev/null +++ b/src/compat/crypto/dh-handle.ts @@ -0,0 +1,212 @@ +// Implements pinned workerd dh.c++ using its BoringSSL backend. +import { Buffer } from "node:buffer"; +import { inputs, library, output } from "./native-cipher.js"; +import { bytes } from "./validation.js"; +import { hasActiveRequest } from "../../shared/request-context.js"; +const released = new FinalizationRegistry((pointer) => + library().dh_free(pointer), +); +function integer(data: Uint8Array): bigint { + const hex = Buffer.from(data).toString("hex"); + return hex ? BigInt("0x" + hex) : 0n; +} +function numeric(value: number): Uint8Array { + let hex = value.toString(16); + if (hex.length % 2) hex = "0" + hex; + return Buffer.from(hex, "hex"); +} +function peerError(code: number, operation: string): void { + if (code === 0) return; + const compute = operation === "computeSecret"; + const prefix = `DiffieHellman ${operation}() failed: `; + if (code === -1) + throw new Error( + prefix + + (compute + ? "could not validate peer public key" + : "could not validate public key"), + ); + if (code & 1) + throw new RangeError( + prefix + (compute ? "Supplied key is too small" : "key is too small"), + ); + if (code & 2) + throw new RangeError( + prefix + (compute ? "Supplied key is too large" : "key is too large"), + ); + throw new Error( + prefix + (compute ? "invalid peer public key" : "invalid public key"), + ); +} +export class NativeDH { + private pointer: number; + private verifyError: number; + constructor( + prime: number | ArrayBuffer | ArrayBufferView, + generator: number | ArrayBuffer | ArrayBufferView, + group = false, + ) { + const api = library(); + let pointer = 0; + if (group) pointer = api.dh_group(prime); + else if (typeof prime === "number") { + if (typeof generator !== "number") + throw new Error("DiffieHellman init failed: invalid parameters"); + if (!hasActiveRequest()) + throw new Error( + "DiffieHellman key generation requires an active request", + ); + if (prime > 10000) + throw new RangeError( + "DiffieHellman init failed: requested prime size too large", + ); + pointer = api.dh_parameters(prime, generator); + if (!pointer) + throw new Error( + "DiffieHellman init failed: could not generate parameters", + ); + } else { + const p = bytes(prime, "prime"); + if (p.length > 1250) + throw new RangeError("DiffieHellman init failed: key is too large"); + if (!p.length) throw new Error("DiffieHellman init failed: invalid key"); + let g: Uint8Array; + if (typeof generator === "number") { + if (generator < 2) + throw new RangeError( + "DiffieHellman init failed: generator too small", + ); + g = numeric(generator); + } else { + g = bytes(generator, "generator"); + if (g.length > 1250) + throw new RangeError( + "DiffieHellman init failed: generator is too large", + ); + if (!g.length || integer(g) < 2n) + throw new Error("DiffieHellman init failed: invalid generator"); + } + if (!(integer(p) & 1n) || integer(g) >= integer(p)) + throw new Error( + "DiffieHellman init failed: Invalid DH prime generated", + ); + pointer = inputs([p, g], (api, [pp, gg]) => + api.dh_create(pp, p.length, gg, g.length), + ); + if (!pointer) + throw new Error("DiffieHellman init failed: could not set keys"); + } + if (!pointer) + throw new Error("DiffieHellmanGroup init failed: could not set keys"); + try { + if (!api.dh_valid(pointer)) + throw new Error( + "DiffieHellman init failed: Invalid DH prime generated", + ); + // RFC 3526 primes are fixed safe primes. Preserve BoringSSL's generator + // suitability flag (including its p mod 24 rule) without re-testing primality. + const verify = group + ? api.dh_group_check(pointer) + : api.dh_check(pointer); + if (verify < 0) throw new Error("DiffieHellman init failed"); + this.pointer = pointer; + this.verifyError = verify; + released.register(this, pointer, this); + } catch (error) { + api.dh_free(pointer); + throw error; + } + } + + static group(name: unknown): NativeDH { + const groups: Record = { + modp14: 2048, + modp15: 3072, + modp16: 4096, + modp17: 6144, + modp18: 8192, + }; + const normalized = `${name}`.split("\0")[0]!.toLowerCase(); + const bits = Object.hasOwn(groups, normalized) + ? groups[normalized] + : undefined; + if (!bits) + throw new Error( + "Failed to init DiffieHellmanGroup: invalid group. Only groups {modp14, modp15, modp16, modp17, modp18} are supported.", + ); + return new NativeDH(bits, 2, true); + } + getVerifyError() { + return this.verifyError; + } + private get(kind: number): Uint8Array { + const api = library(), + length = api.dh_get(this.pointer, kind, 0); + if (length < 0) + throw new Error( + kind === 2 + ? "No public key" + : kind === 3 + ? "No private key" + : kind === 0 + ? "No DiffieHellman prime" + : "No DiffieHellman generator", + ); + return inputs([length], (api, [out]) => { + if (api.dh_get(this.pointer, kind, out) < 0) + throw new Error("Error while retrieving DiffieHellman key"); + return output(api, out, length); + }); + } + getPrime() { + return this.get(0); + } + getGenerator() { + return this.get(1); + } + getPublicKey() { + return this.get(2); + } + getPrivateKey() { + return this.get(3); + } + generateKeys() { + if (!library().dh_generate(this.pointer)) throw new Error("internal error"); + return this.getPublicKey(); + } + setPrivateKey(value: ArrayBuffer | ArrayBufferView) { + this.set(value, true); + } + setPublicKey(value: ArrayBuffer | ArrayBufferView) { + this.set(value, false); + } + private set(value: ArrayBuffer | ArrayBufferView, privateKey: boolean) { + const data = bytes(value, "key"); + inputs([data], (api, [p]) => { + const code = api.dh_set(this.pointer, +privateKey, p, data.length); + if (!privateKey) peerError(code, "setPublicKey"); + else if (code !== 0) throw new Error("internal error"); + }); + } + computeSecret(value: ArrayBuffer | ArrayBufferView): Uint8Array { + const data = bytes(value, "key"); + if (data.length > 0x7fffffff) + throw new RangeError( + "DiffieHellman computeSecret() failed: key is too large", + ); + if (!data.length) + throw new Error("DiffieHellman computeSecret() failed: invalid key"); + return inputs([data, this.getPrime().length], (api, [p, out]) => { + peerError( + api.dh_peer_check(this.pointer, p, data.length), + "computeSecret", + ); + const length = api.dh_compute(this.pointer, p, data.length, out); + if (length < 0) + throw new Error( + "DiffieHellman computeSecret() failed: error computing shared secret", + ); + return output(api, out, length); + }); + } +} diff --git a/src/compat/crypto/dh.spec.ts b/src/compat/crypto/dh.spec.ts new file mode 100644 index 0000000..5a8ea44 --- /dev/null +++ b/src/compat/crypto/dh.spec.ts @@ -0,0 +1,45 @@ +import { test, expect, expectTypeOf } from "vitest"; +import { runWithRequestScope } from "../../shared/request-scope.js"; +// @ts-expect-error Pinned upstream JavaScript fixture. +import * as fixtures from "./upstream-dh.fixture.mjs"; +for (const [name, fixture] of Object.entries(fixtures)) + test( + "pinned Workers DH: " + name, + () => + runWithRequestScope(() => (fixture as { test: () => unknown }).test()), + 30000, + ); + +import { Buffer } from "node:buffer"; +import { + DiffieHellman, + DiffieHellmanGroup, + createDiffieHellman, + ECDH, +} from "./index.js"; +test("DH requires request scope for numeric generation", () => { + expect(() => createDiffieHellman(64)).toThrow( + new Error("DiffieHellman key generation requires an active request"), + ); +}); +test("DH and ECDH consumer declarations", () => { + const d = DiffieHellman(new Uint8Array([23])); + expectTypeOf(d.getPrime()).toEqualTypeOf(); + expectTypeOf(d.getPrime("hex")).toEqualTypeOf(); + const ec = ECDH("prime256v1"); + expectTypeOf(ec.generateKeys()).toEqualTypeOf(); + expectTypeOf(ec.getPublicKey("hex")).toEqualTypeOf(); + expectTypeOf(ec.getPrivateKey(undefined)).toEqualTypeOf(); + expectTypeOf(d.getPrime(undefined)).toEqualTypeOf(); + expectTypeOf( + ECDH.convertKey(ec.getPublicKey(), "prime256v1"), + ).toEqualTypeOf(); + expectTypeOf( + ECDH.convertKey(ec.getPublicKey(), "prime256v1", undefined, "hex"), + ).toEqualTypeOf(); +}); +function invalidGroup(group: DiffieHellmanGroup) { + // @ts-expect-error Fixed groups do not expose setters. + group.setPrivateKey(new Uint8Array([2])); +} +void invalidGroup; diff --git a/src/compat/crypto/dh.ts b/src/compat/crypto/dh.ts new file mode 100644 index 0000000..b316c70 --- /dev/null +++ b/src/compat/crypto/dh.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +// Adapted from workerd@5358e3d337336eaf696a48bc1563d808b10feba5. +import { Buffer } from "node:buffer"; +import { NativeDH } from "./dh-handle.js"; +import { bytes, isAnyBuffer } from "./validation.js"; +import { validateInt32 } from "../net/validators.js"; +import { ERR_INVALID_ARG_TYPE } from "../net/errors.js"; +const kHandle = Symbol("kHandle"); +type Input = string | ArrayBuffer | SharedArrayBuffer | ArrayBufferView; +type Encoding = BufferEncoding | "buffer"; +interface SharedDH { + [kHandle]: NativeDH; + readonly verifyError: number; + generateKeys(encoding?: undefined): Buffer; + generateKeys(encoding: "buffer"): Buffer; + generateKeys(encoding: BufferEncoding): string; + computeSecret(key: Input, inputEncoding?: Encoding): Buffer; + computeSecret( + key: Input, + inputEncoding: Encoding | undefined, + outputEncoding: "buffer", + ): Buffer; + computeSecret( + key: Input, + inputEncoding: Encoding | undefined, + outputEncoding: BufferEncoding, + ): string; + getPrime(encoding?: undefined): Buffer; + getPrime(encoding: "buffer"): Buffer; + getPrime(encoding: BufferEncoding): string; + getGenerator(encoding?: undefined): Buffer; + getGenerator(encoding: "buffer"): Buffer; + getGenerator(encoding: BufferEncoding): string; + getPublicKey(encoding?: undefined): Buffer; + getPublicKey(encoding: "buffer"): Buffer; + getPublicKey(encoding: BufferEncoding): string; + getPrivateKey(encoding?: undefined): Buffer; + getPrivateKey(encoding: "buffer"): Buffer; + getPrivateKey(encoding: BufferEncoding): string; +} +export interface DiffieHellman extends SharedDH { + setPrivateKey(key: Input, encoding?: Encoding): this; + setPublicKey(key: Input, encoding?: Encoding): this; +} +export interface DiffieHellmanGroup extends SharedDH {} +type Arguments = [ + sizeOrKey: number | Input, + keyEncoding?: number | Input, + generator?: number | Input, + genEncoding?: Encoding, +]; +function decode( + value: Input, + encoding?: Encoding, +): ArrayBuffer | ArrayBufferView { + if (typeof value === "string") + return Buffer.from(value, encoding === "buffer" ? "utf8" : encoding); + return bytes(value, "key"); +} +function encode(value: Uint8Array, encoding?: Encoding): Buffer | string { + const result = Buffer.from(value); + return encoding && encoding !== "buffer" ? result.toString(encoding) : result; +} +export const DiffieHellman = function ( + this: DiffieHellman, + sizeOrKey: number | Input, + keyEncoding?: number | Input, + generator?: number | Input, + genEncoding?: Encoding, +): DiffieHellman { + if (!(this instanceof DiffieHellman)) + return new DiffieHellman(sizeOrKey, keyEncoding, generator, genEncoding); + if ( + typeof sizeOrKey !== "number" && + typeof sizeOrKey !== "string" && + !ArrayBuffer.isView(sizeOrKey) && + !isAnyBuffer(sizeOrKey) + ) + throw new ERR_INVALID_ARG_TYPE( + "sizeOrKey", + ["number", "string", "ArrayBuffer", "Buffer", "TypedArray", "DataView"], + sizeOrKey, + ); + if (typeof sizeOrKey === "number") validateInt32(sizeOrKey, "sizeOrKey"); + if ( + keyEncoding && + keyEncoding !== "buffer" && + !Buffer.isEncoding(keyEncoding as string) + ) { + genEncoding = generator as Encoding; + generator = keyEncoding; + keyEncoding = "utf8"; + } + keyEncoding ??= "utf8"; + genEncoding ??= "utf8"; + if (typeof sizeOrKey !== "number") + sizeOrKey = decode(sizeOrKey, keyEncoding as Encoding); + if (!generator) generator = 2; + else if (typeof generator === "number") validateInt32(generator, "generator"); + else if (typeof generator === "string") + generator = decode(generator, genEncoding); + else if (!ArrayBuffer.isView(generator) && !isAnyBuffer(generator)) + throw new ERR_INVALID_ARG_TYPE( + "generator", + ["number", "string", "ArrayBuffer", "Buffer", "TypedArray", "DataView"], + generator, + ); + this[kHandle] = new NativeDH( + sizeOrKey as number | ArrayBuffer | ArrayBufferView, + generator as number | ArrayBuffer | ArrayBufferView, + ); + Object.defineProperty(DiffieHellman.prototype, "verifyError", { + get(this: DiffieHellman) { + return this[kHandle].getVerifyError(); + }, + enumerable: true, + configurable: true, + }); + return this; +} as unknown as { + new (...args: Arguments): DiffieHellman; + (...args: Arguments): DiffieHellman; + prototype: DiffieHellman; +}; +export const DiffieHellmanGroup = function ( + this: DiffieHellmanGroup, + name: string, +): DiffieHellmanGroup { + if (!(this instanceof DiffieHellmanGroup)) + return new DiffieHellmanGroup(name); + this[kHandle] = NativeDH.group(name); + Object.defineProperty(DiffieHellmanGroup.prototype, "verifyError", { + get(this: DiffieHellmanGroup) { + return this[kHandle].getVerifyError(); + }, + enumerable: true, + configurable: true, + }); + return this; +} as unknown as { + new (name: string): DiffieHellmanGroup; + (name: string): DiffieHellmanGroup; + prototype: DiffieHellmanGroup; +}; +const methods = { + generateKeys(this: SharedDH, encoding?: Encoding) { + return encode(this[kHandle].generateKeys(), encoding); + }, + computeSecret( + this: SharedDH, + key: Input, + inputEncoding?: Encoding, + outputEncoding?: Encoding, + ) { + return encode( + this[kHandle].computeSecret(decode(key, inputEncoding)), + outputEncoding, + ); + }, + getPrime(this: SharedDH, encoding?: Encoding) { + return encode(this[kHandle].getPrime(), encoding); + }, + getGenerator(this: SharedDH, encoding?: Encoding) { + return encode(this[kHandle].getGenerator(), encoding); + }, + getPublicKey(this: SharedDH, encoding?: Encoding) { + return encode(this[kHandle].getPublicKey(), encoding); + }, + getPrivateKey(this: SharedDH, encoding?: Encoding) { + return encode(this[kHandle].getPrivateKey(), encoding); + }, +}; +const setters = { + setPublicKey(this: DiffieHellman, key: Input, encoding?: Encoding) { + this[kHandle].setPublicKey(decode(key, encoding)); + return this; + }, + setPrivateKey(this: DiffieHellman, key: Input, encoding?: Encoding) { + this[kHandle].setPrivateKey(decode(key, encoding)); + return this; + }, +}; +for (const [name, method] of Object.entries({ ...methods, ...setters })) + Object.defineProperty(method, "name", { + value: "dh" + name[0]!.toUpperCase() + name.slice(1), + configurable: true, + }); +Object.assign(DiffieHellman.prototype, methods, setters); +Object.assign(DiffieHellmanGroup.prototype, methods); +export function createDiffieHellman( + sizeOrKey: number | Input, + keyEncoding?: number | Input, + generator?: number | Input, + genEncoding?: Encoding, +): DiffieHellman { + return new DiffieHellman(sizeOrKey, keyEncoding, generator, genEncoding); +} +export function createDiffieHellmanGroup(name: string): DiffieHellmanGroup { + return new DiffieHellmanGroup(name); +} +export function getDiffieHellman(name: string): DiffieHellmanGroup { + return createDiffieHellmanGroup(name); +} diff --git a/src/compat/crypto/digest-stream.spec.ts b/src/compat/crypto/digest-stream.spec.ts new file mode 100644 index 0000000..d975cf6 --- /dev/null +++ b/src/compat/crypto/digest-stream.spec.ts @@ -0,0 +1,120 @@ +import { expect, test } from "vitest"; +import { DigestStream } from "./digest-stream.js"; +import { dispose } from "../../shared/globals/symbols.js"; +const hex = (value: ArrayBuffer) => Buffer.from(value).toString("hex"); +for (const [algorithm, expected] of Object.entries({ + "SHA-256": "15e2b0d3c33891ebb0f1ef609ec419420c20e320ce94c65fbc8c3312448eb225", + crc32: "cbf43926", + crc32c: "e3069283", + crc64nvme: "ae8b14860a799888", +})) + test(`DigestStream ${algorithm} split input`, async () => { + const stream = new DigestStream(algorithm); + expect(stream.digest).toBe(stream.digest); + const writer = stream.getWriter(); + await writer.write("1234"); + await writer.write(new TextEncoder().encode("56789")); + await writer.close(); + expect(stream.bytesWritten).toBe(9n); + expect(hex(await stream.digest)).toBe(expected); + }); +test("WTF-8 chunks preserve independent lone surrogates", async () => { + const stream = new DigestStream("SHA-256"); + const writer = stream.getWriter(); + await writer.write("\ud800"); + expect(stream.bytesWritten).toBe(3n); + await writer.write("\udc00"); + await writer.close(); + expect(hex(await stream.digest)).toBe( + "b67d2ab17d9e4a31bfb0c9e3bf5acbbce5f3df109c4afde0c3fef9c16f712b37", + ); +}); +test("well-formed encoding carries a high surrogate until the next string", async () => { + const stream = new DigestStream("SHA-256", { toWellFormed: true }); + const writer = stream.getWriter(); + await writer.write("\ud800"); + expect(stream.bytesWritten).toBe(0n); + await writer.write("\udc00"); + expect(stream.bytesWritten).toBe(4n); + await writer.close(); + const expected = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode("\ud800\udc00"), + ); + expect(hex(await stream.digest)).toBe(hex(expected)); +}); +test("dispose rejects digest while an empty write still succeeds", async () => { + const stream = new DigestStream("SHA-256"); + const writer = stream.getWriter(); + const digest = stream.digest.catch((error) => error); + stream[dispose](); + stream[dispose](); + await writer.write(""); + await expect(writer.write("a")).rejects.toThrow( + "The DigestStream was disposed.", + ); + expect((await digest).message).toBe("The DigestStream was disposed."); + expect(stream.bytesWritten).toBe(0n); +}); +test("abort keeps the exact reason", async () => { + const stream = new DigestStream("SHA-256"); + const reason = { cancel: true }; + const digest = stream.digest.catch((error) => error); + await stream.abort(reason); + expect(await digest).toBe(reason); +}); +test("empty string preserves a pending surrogate and close flushes a lone lead", async () => { + const stream = new DigestStream("SHA-256", { toWellFormed: true }), + writer = stream.getWriter(); + await writer.write("\ud800"); + await writer.write(""); + expect(stream.bytesWritten).toBe(0n); + await writer.close(); + expect(stream.bytesWritten).toBe(3n); + const expected = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode("\ufffd"), + ); + expect(hex(await stream.digest)).toBe(hex(expected)); + stream[dispose](); + writer.releaseLock(); + await stream.abort("ignored"); + expect(hex(await stream.digest)).toBe(hex(expected)); +}); +test("invalid writes error the writer but leave digest pending until disposal", async () => { + const stream = new DigestStream("SHA-256"), + writer = stream.getWriter(); + let settled = false; + void stream.digest.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await expect(writer.write({} as never)).rejects.toThrow( + "non-ArrayBuffer/ArrayBufferView/string", + ); + await writer.abort("ignored because writer is already errored"); + await Promise.resolve(); + expect(settled).toBe(false); + stream[dispose](); + await Promise.resolve(); + expect(settled).toBe(true); +}); +test("SAB views are bytes while a bare SAB is rejected", async () => { + const buffer = new SharedArrayBuffer(2); + new Uint8Array(buffer).set([1, 2]); + const stream = new DigestStream("crc32"), + writer = stream.getWriter(); + await writer.write(new Uint8Array(buffer, 2, 0)); + await writer.write(new DataView(buffer)); + await writer.close(); + expect(stream.bytesWritten).toBe(2n); + const invalid = new DigestStream("crc32"); + await expect(invalid.getWriter().write(buffer as never)).rejects.toThrow( + "non-ArrayBuffer/ArrayBufferView/string", + ); + invalid[dispose](); +}); diff --git a/src/compat/crypto/digest-stream.ts b/src/compat/crypto/digest-stream.ts new file mode 100644 index 0000000..7aabf94 --- /dev/null +++ b/src/compat/crypto/digest-stream.ts @@ -0,0 +1,247 @@ +// Workers DigestStream contract, pinned to workerd 5358e3d337336eaf696a48bc1563d808b10feba5. +// Stream state and string conversion follow src/workerd/api/crypto/crypto.c++. +import { WritableStream } from "../stream-web/runtime.js"; +import { dispose } from "../../shared/globals/symbols.js"; +import { viewBytes } from "./validation.js"; +import { NativeDigest } from "./native-crypto.js"; + +type Chunk = ArrayBuffer | ArrayBufferView | string; +interface Context { + update(bytes: Uint8Array): void; + final(): Uint8Array; + dispose(): void; +} +const crcTables = new Map(); +class CrcDigest implements Context { + private value: bigint; + private mask: bigint; + private table: bigint[]; + constructor( + private width: number, + polynomial: bigint, + ) { + this.mask = (1n << BigInt(width)) - 1n; + this.value = this.mask; + let table = crcTables.get(polynomial); + if (!table) { + table = Array.from({ length: 256 }, (_, n) => { + let v = BigInt(n); + for (let bit = 0; bit < 8; bit++) + v = (v >> 1n) ^ (v & 1n ? polynomial : 0n); + return v; + }); + crcTables.set(polynomial, table); + } + this.table = table; + } + update(bytes: Uint8Array) { + for (const byte of bytes) + this.value = + (this.value >> 8n) ^ + this.table[Number((this.value ^ BigInt(byte)) & 255n)]!; + } + final() { + let value = this.value ^ this.mask; + const result = new Uint8Array(this.width / 8); + for (let index = result.length - 1; index >= 0; index--) { + result[index] = Number(value & 255n); + value >>= 8n; + } + return result; + } + dispose() { + this.value = 0n; + } +} +function context(name: string): Context { + name = name.split("\0")[0]!.toLowerCase(); + switch (name) { + case "crc32": + return new CrcDigest(32, 0xedb88320n); + case "crc32c": + return new CrcDigest(32, 0x82f63b78n); + case "crc64nvme": + return new CrcDigest(64, 0x9a6c9329ac4bc9b5n); + case "sha-1": + case "sha-256": + case "sha-384": + case "sha-512": + case "md5": + return new NativeDigest(name.replace("-", "")); + default: + throw new DOMException( + "Unrecognized or unimplemented digest algorithm requested.", + "NotSupportedError", + ); + } +} +function isArrayBuffer(value: unknown): value is ArrayBuffer { + try { + Object.getOwnPropertyDescriptor( + ArrayBuffer.prototype, + "byteLength", + )!.get!.call(value); + return true; + } catch { + return false; + } +} +function encodeWtf8(text: string): Uint8Array { + const output: number[] = []; + for (let i = 0; i < text.length; i++) { + let code = text.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + const trail = text.charCodeAt(i + 1); + if (trail >= 0xdc00 && trail <= 0xdfff) { + code = 0x10000 + ((code - 0xd800) << 10) + trail - 0xdc00; + i++; + } + } + if (code < 0x80) output.push(code); + else if (code < 0x800) output.push(0xc0 | (code >> 6), 0x80 | (code & 63)); + else if (code < 0x10000) + output.push( + 0xe0 | (code >> 12), + 0x80 | ((code >> 6) & 63), + 0x80 | (code & 63), + ); + else + output.push( + 0xf0 | (code >> 18), + 0x80 | ((code >> 12) & 63), + 0x80 | ((code >> 6) & 63), + 0x80 | (code & 63), + ); + } + return Uint8Array.from(output); +} +interface State { + context: Context; + status: "ready" | "closed" | "errored"; + reason: unknown; + promise: Promise; + resolve(value: ArrayBuffer): void; + reject(reason: unknown): void; + count: bigint; + pending: string; +} +function fail(state: State, reason: unknown) { + if (state.status !== "ready") return; + state.status = "errored"; + state.reason = reason; + state.context.dispose(); + state.reject(reason); +} +export class DigestStream extends WritableStream { + #state: State; + constructor( + algorithm: string | { name: string }, + options?: { toWellFormed?: boolean }, + ) { + if (algorithm == null) + throw new TypeError( + "Cannot initialize N7workerd3api12SubtleCrypto13HashAlgorithmE with required members from an undefined or null value.", + ); + const name = + typeof algorithm === "object" || typeof algorithm === "function" + ? `${algorithm.name}` + : `${algorithm}`; + if ( + options != null && + typeof options !== "object" && + typeof options !== "function" + ) + throw new TypeError( + "Failed to construct 'DigestStream': constructor parameter 2 is not of type 'Options'.", + ); + const wellFormed = !!options?.toWellFormed; + const native = context(name); + let resolve!: State["resolve"], reject!: State["reject"]; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + void promise.catch(() => {}); + const state: State = { + context: native, + status: "ready", + reason: undefined, + promise, + resolve, + reject, + count: 0n, + pending: "", + }; + const write = (bytes: Uint8Array) => { + if (!bytes.length) return; + if (state.status === "errored") throw state.reason; + if (state.status === "closed") return; + native.update(bytes); + state.count += BigInt(bytes.length); + }; + super({ + write(chunk) { + if (typeof chunk === "string") { + if (!wellFormed) return write(encodeWtf8(chunk)); + let text = state.pending + chunk; + state.pending = ""; + const last = text.charCodeAt(text.length - 1); + if (last >= 0xd800 && last <= 0xdbff) { + state.pending = text.slice(-1); + text = text.slice(0, -1); + } + return write(new TextEncoder().encode(text)); + } + if (!isArrayBuffer(chunk) && !ArrayBuffer.isView(chunk)) + throw new TypeError( + "DigestStream is a byte stream but received an object of non-ArrayBuffer/ArrayBufferView/string type on its writable side.", + ); + let bytes: Uint8Array; + try { + bytes = isArrayBuffer(chunk) + ? new Uint8Array(chunk) + : viewBytes(chunk); + } catch { + return; + } // Detached/out-of-bounds sources have zero extent in Workers. + write(bytes); + }, + close() { + if (state.status === "errored") throw state.reason; + if (state.status === "closed") return; + if (state.pending) { + write(new Uint8Array([0xef, 0xbf, 0xbd])); + state.pending = ""; + } + const result = Uint8Array.from(native.final()).buffer; + native.dispose(); + state.status = "closed"; + state.resolve(result); + }, + abort(reason) { + fail(state, reason); + }, + }); + this.#state = state; + } + get digest() { + if (this == null || typeof this !== "object" || !(#state in this)) + throw new TypeError("Illegal invocation"); + return this.#state.promise; + } + get bytesWritten() { + if (this == null || typeof this !== "object" || !(#state in this)) + throw new TypeError("Illegal invocation"); + return this.#state.count; + } + [dispose]() { + if (this == null || typeof this !== "object" || !(#state in this)) + throw new TypeError("Illegal invocation"); + fail(this.#state, new TypeError("The DigestStream was disposed.")); + } +} +Object.defineProperties(DigestStream.prototype, { + digest: { enumerable: true }, + bytesWritten: { enumerable: true }, + [Symbol.toStringTag]: { value: "DigestStream", configurable: true }, +}); diff --git a/src/compat/crypto/digests.ts b/src/compat/crypto/digests.ts new file mode 100644 index 0000000..e352e10 --- /dev/null +++ b/src/compat/crypto/digests.ts @@ -0,0 +1,36 @@ +export const hashNames = [ + "md4", + "md5", + "sha1", + "sha224", + "sha256", + "sha384", + "sha512", + "md5-sha1", + "RSA-MD5", + "RSA-SHA1", + "RSA-SHA224", + "RSA-SHA256", + "RSA-SHA384", + "RSA-SHA512", + "DSA-SHA", + "DSA-SHA1", + "ecdsa-with-SHA1", +]; +export const aliases: Record = Object.assign( + Object.create(null), + Object.fromEntries( + hashNames.map((name) => [ + name.toLowerCase(), + name + .toLowerCase() + .replace(/^rsa-/, "") + .replace(/^(dsa-sha1?|ecdsa-with-sha1)$/, "sha1"), + ]), + ), +); +export function signatureDigest(algorithm: string): string { + const value = aliases[algorithm.split("\0")[0]!.toLowerCase()]; + if (!value) throw new Error(`Unknown digest: ${algorithm}`); + return value; +} diff --git a/src/compat/crypto/ecdh.ts b/src/compat/crypto/ecdh.ts index a27f1ff..b15028c 100644 --- a/src/compat/crypto/ecdh.ts +++ b/src/compat/crypto/ecdh.ts @@ -7,20 +7,27 @@ import { ERR_INVALID_ARG_TYPE } from "../net/errors.js"; import { isArrayBufferView } from "../util/types/index.js"; const cryptoImpl = { ECDHHandle: NativeECDH }; const kHandle = Symbol("kHandle"); +export type ECDHFormat = "compressed" | "uncompressed" | "hybrid"; +type ECDHInput = string | ArrayBufferView; export interface ECDH { - [kHandle]: cryptoImpl.ECDHHandle; + [kHandle]: NativeECDH; computeSecret( - otherPublicKey: string | ArrayBufferView | ArrayBuffer, - inputEncoding?: string, - outputEncoding?: string, - ): Buffer | string; - generateKeys(encoding?: string, format?: string): Buffer | string; - getPrivateKey(encoding?: string): Buffer | string; - getPublicKey(encoding?: string, format?: string): Buffer | string; - setPrivateKey( - key: string | ArrayBufferView | ArrayBuffer, - encoding?: string, - ): void; + key: ECDHInput, + inputEncoding?: BufferEncoding, + outputEncoding?: undefined, + ): Buffer; + computeSecret( + key: ECDHInput, + inputEncoding: BufferEncoding | undefined, + outputEncoding: BufferEncoding, + ): string; + generateKeys(encoding?: undefined, format?: ECDHFormat): Buffer; + generateKeys(encoding: BufferEncoding, format?: ECDHFormat): string; + getPrivateKey(encoding?: undefined): Buffer; + getPrivateKey(encoding: BufferEncoding): string; + getPublicKey(encoding?: undefined, format?: ECDHFormat): Buffer; + getPublicKey(encoding: BufferEncoding, format?: ECDHFormat): string; + setPrivateKey(key: ECDHInput, encoding?: BufferEncoding): void; } export const ECDH = function (this: ECDH, curveName: string) { @@ -32,6 +39,21 @@ export const ECDH = function (this: ECDH, curveName: string) { return this; } as unknown as { new (curveName: string): ECDH; + (curveName: string): ECDH; + convertKey( + key: ECDHInput, + curve: string, + inputEncoding?: BufferEncoding, + outputEncoding?: undefined, + format?: ECDHFormat, + ): Buffer; + convertKey( + key: ECDHInput, + curve: string, + inputEncoding: BufferEncoding | undefined, + outputEncoding: BufferEncoding, + format?: ECDHFormat, + ): string; }; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access diff --git a/src/compat/crypto/hash-handle.ts b/src/compat/crypto/hash-handle.ts new file mode 100644 index 0000000..6badad1 --- /dev/null +++ b/src/compat/crypto/hash-handle.ts @@ -0,0 +1,76 @@ +import { bufferBytes, viewBytes } from "../../shared/buffer-source.js"; +import { validateCryptoLength } from "./validation.js"; +import { secretMaterial, KeyObject } from "./keys.js"; +import { Buffer } from "node:buffer"; +import { NativeDigest, NativeHmac } from "./native-crypto.js"; +import { aliases } from "./digests.js"; +export { hashNames, aliases } from "./digests.js"; +class HashHandle { + private hash: NativeDigest; + private cached?: Buffer; + constructor(algorithm: string | NativeDigest, length?: number) { + if (algorithm instanceof NativeDigest) this.hash = algorithm; + else { + const normalized = aliases[algorithm.split("\0")[0]!.toLowerCase()]; + if (!normalized) throw new Error("Digest method not supported"); + this.hash = new NativeDigest(normalized, "Hash"); + } + if (length !== undefined && length !== this.hash.size) { + this.hash.dispose(); + throw new Error("invalid digest size"); + } + } + update(data: ArrayBufferView) { + if (this.cached) + throw new DOMException( + "Hash context has already been finalized.", + "OperationError", + ); + const input = viewBytes(data); + validateCryptoLength(input.byteLength, "data is too long"); + this.hash.update(input); + return 1; + } + digest() { + this.cached ??= Buffer.from(this.hash.final()); + return Buffer.from(this.cached); + } + copy(length?: number) { + if (this.cached) + throw new DOMException( + "Hash context has already been finalized.", + "OperationError", + ); + return new HashHandle(this.hash.copy(), length); + } +} +class HmacHandle { + private hmac: NativeHmac; + private cached?: Buffer; + constructor(algorithm: string, key: any) { + if (typeof CryptoKey !== "undefined" && key instanceof CryptoKey) + key = KeyObject.from(key); + key = secretMaterial(key); + const data = ArrayBuffer.isView(key) ? viewBytes(key) : bufferBytes(key); + validateCryptoLength(data.byteLength, "key is too long"); + const normalized = aliases[algorithm.split("\0")[0]!.toLowerCase()]; + if (!normalized) throw new Error("Digest method not supported"); + this.hmac = new NativeHmac(normalized, data); + } + update(data: ArrayBufferView) { + if (this.cached) + throw new DOMException( + "HMAC context has already been finalized.", + "OperationError", + ); + const input = viewBytes(data); + validateCryptoLength(input.byteLength, "data is too long"); + this.hmac.update(input); + return 1; + } + digest() { + this.cached ??= Buffer.from(this.hmac.final()); + return Buffer.from(this.cached); + } +} +export default { HashHandle, HmacHandle }; diff --git a/src/compat/crypto/hash-types.spec.ts b/src/compat/crypto/hash-types.spec.ts new file mode 100644 index 0000000..8a328fb --- /dev/null +++ b/src/compat/crypto/hash-types.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from "vitest"; +import { Hash, Hmac, createHash, createHmac } from "./hash.js"; +import { pbkdf2Sync } from "./pbkdf2.js"; +import { Buffer } from "node:buffer"; + +test("digest and KDF declarations describe ordinary public API usage", () => { + const callableHash: Hash = Hash("sha256"); + const callableHmac: Hmac = Hmac("sha1", "key"); + const hash: string = callableHash.update("abc").digest("hex"); + const hmac: string = callableHmac.update("data").digest("hex"); + const binary: Buffer = createHash("sha256").update("abc").digest(); + const explicitBinary: Buffer = createHmac("sha1", "key") + .update("data") + .digest("buffer"); + const key: Buffer = pbkdf2Sync( + new Uint8Array([1]), + new ArrayBuffer(1), + 1, + 8, + "sha256", + ); + const constructorHash: string = new Hash("sha256") + .update("abc") + .copy() + .digest("hex"); + const constructorHmac: string = new Hmac("sha1", "key") + .update("data") + .digest("hex"); + + // This closure is never executed: the declaration must reject a method that + // the Hmac prototype does not expose, without invoking it at runtime. + const invalidHmacCopy = () => { + // @ts-expect-error Hmac has no copy method. + return callableHmac.copy(); + }; + void invalidHmacCopy; + + expect(hash).toBe( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ); + expect(hmac).toBe("104152c5bfdca07bc633eebd46199f0255c9f49d"); + expect(binary.toString("hex")).toBe(hash); + expect(explicitBinary.toString("hex")).toBe(hmac); + expect(constructorHash).toBe(hash); + expect(constructorHmac).toBe(hmac); + expect(key.length).toBe(8); +}); diff --git a/src/compat/crypto/hash.spec.ts b/src/compat/crypto/hash.spec.ts new file mode 100644 index 0000000..9d07ae7 --- /dev/null +++ b/src/compat/crypto/hash.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from "vitest"; +import { createHash } from "./hash.js"; +import { getHashes } from "./metadata.js"; +// @ts-expect-error Upstream JavaScript fixture has no declarations. +import * as cases from "./upstream-hash.fixture.mjs"; +for (const [name, fixture] of Object.entries(cases)) + test(`pinned Workers hash: ${name}`, () => + (fixture as { test: () => unknown }).test()); +test("all pinned digest names support incremental and copied input", () => { + for (const name of getHashes()) { + const hash = createHash(name).update("a"); + const copy = hash.copy(); + expect(hash.update("bc").digest("hex")).toBe( + createHash(name).update("abc").digest("hex"), + ); + expect(copy.update("d").digest("hex")).toBe( + createHash(name).update("ad").digest("hex"), + ); + } +}); + +// @ts-expect-error Upstream JavaScript fixture has no declarations. +import * as hmacCases from "./upstream-hmac.fixture.mjs"; +for (const [name, fixture] of Object.entries(hmacCases)) + test(`pinned Workers hmac: ${name}`, () => + (fixture as { test: () => unknown }).test()); + +// @ts-expect-error Upstream JavaScript fixture has no declarations. +import * as pbkdfCases from "./upstream-pbkdf2.fixture.mjs"; +for (const [name, fixture] of Object.entries(pbkdfCases)) + test(`pinned Workers pbkdf2: ${name}`, () => + (fixture as { test: () => unknown }).test()); + +// @ts-expect-error Upstream JavaScript fixture has no declarations. +import * as hkdfCases from "./upstream-hkdf.fixture.mjs"; +for (const [name, fixture] of Object.entries(hkdfCases)) + test(`pinned Workers hkdf: ${name}`, () => + (fixture as { test: () => unknown }).test()); + +import { validateCryptoLength } from "./validation.js"; +test("native crypto length guard rejects before narrowing to signed int", () => { + for (const length of [0, 0x7fffffff]) + expect(() => validateCryptoLength(length, "too large")).not.toThrow(); + for (const length of [0x80000000, 0x100000000]) + expect(() => validateCryptoLength(length, "too large")).toThrow( + new RangeError("too large"), + ); +}); diff --git a/src/compat/crypto/hash.ts b/src/compat/crypto/hash.ts new file mode 100644 index 0000000..4ae4647 --- /dev/null +++ b/src/compat/crypto/hash.ts @@ -0,0 +1,325 @@ +// @ts-nocheck +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +// Ported from workerd@5358e3d337336eaf696a48bc1563d808b10feba5 crypto_hash.ts. +import cryptoImpl from "./hash-handle.js"; +import { Buffer } from "node:buffer"; +import { KeyObject } from "./keys.js"; +import { ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE } from "./sign-errors.js"; +const isCryptoKey = (value) => + typeof CryptoKey !== "undefined" && value instanceof CryptoKey; +import { isAnyBuffer as isAnyArrayBuffer } from "./validation.js"; +function getStringOption(options, key) { + let value; + if (options && (value = options[key]) != null) + validateString(value, `options.${key}`); + return value; +} +function getArrayBufferOrView(value, name, encoding) { + return typeof value === "string" + ? Buffer.from(value, encoding === "buffer" ? undefined : encoding) + : value; +} + +import { Transform } from "../stream/transform.js"; +import type { Transform as NodeTransform, TransformOptions } from "node:stream"; +import { validateString, validateUint32 } from "../net/validators.js"; +import { ERR_INVALID_ARG_TYPE } from "../net/errors.js"; +import { NodeError } from "../net/error-base.js"; +const kHandle = Symbol("kHandle"), + kState = Symbol("kState"), + kFinalized = Symbol("kFinalized"); +const isArrayBufferView = ArrayBuffer.isView; +class ERR_CRYPTO_HASH_FINALIZED extends NodeError { + constructor() { + super("ERR_CRYPTO_HASH_FINALIZED", "Digest already called"); + } +} +class ERR_CRYPTO_HASH_UPDATE_FAILED extends NodeError { + constructor() { + super("ERR_CRYPTO_HASH_UPDATE_FAILED", "Hash update failed"); + } +} +export interface HashOptions extends TransformOptions { + outputLength?: number; +} + +interface _kState { + [kFinalized]: boolean; +} + +declare class Hash extends NodeTransform { + [kHandle]: InstanceType; + [kState]: _kState; + + constructor( + algorithm: string | InstanceType, + options?: HashOptions, + ); + + copy(options?: HashOptions): Hash; + update(data: string | Buffer | ArrayBufferView, encoding?: string): this; + digest(): Buffer; + digest(outputEncoding: "buffer"): Buffer; + digest(outputEncoding: BufferEncoding): string; + digest(outputEncoding?: string): Buffer | string; +} + +// These helper functions are needed because the constructors can +// use new, in which case V8 cannot inline the recursive constructor call +export function createHash(algorithm: string, options?: HashOptions): Hash { + return new Hash(algorithm, options); +} + +function Hash( + this: unknown, + algorithm: string | InstanceType, + options?: HashOptions, +): Hash { + if (!(this instanceof Hash)) { + return new Hash(algorithm, options); + } + + const xofLen = typeof options === "object" ? options.outputLength : undefined; + if (xofLen !== undefined) validateUint32(xofLen, "options.outputLength"); + if (algorithm instanceof cryptoImpl.HashHandle) { + this[kHandle] = algorithm.copy(xofLen as number); + } else { + validateString(algorithm, "algorithm"); + this[kHandle] = new cryptoImpl.HashHandle(algorithm, xofLen as number); + } + this[kState] = { + [kFinalized]: false, + }; + + Transform.call(this, options); + return this; +} + +Object.setPrototypeOf(Hash.prototype, Transform.prototype); +Object.setPrototypeOf(Hash, Transform); + +Hash.prototype.copy = function (this: Hash, options?: HashOptions): Hash { + const state = this[kState]; + if (state[kFinalized]) throw new ERR_CRYPTO_HASH_FINALIZED(); + + return new Hash(this[kHandle], options); +}; + +Hash.prototype._transform = function ( + this: Hash | Hmac, + chunk: string | Buffer | ArrayBufferView, + encoding: string, + callback: TransformCallback, +): void { + if (typeof chunk === "string") { + chunk = Buffer.from(chunk, encoding); + } + this[kHandle].update(chunk); + callback(); +}; + +Hash.prototype._flush = function ( + this: Hash | Hmac, + callback: TransformCallback, +): void { + this.push(Buffer.from(this[kHandle].digest())); + callback(); +}; + +Hash.prototype.update = function ( + this: Hash | Hmac, + data: string | Buffer | ArrayBufferView, + encoding?: string, +): Hash | Hmac { + encoding ??= "utf8"; + if (encoding === "buffer") { + encoding = undefined; + } + + const state = this[kState]; + if (state[kFinalized]) throw new ERR_CRYPTO_HASH_FINALIZED(); + + if (typeof data === "string") { + data = Buffer.from(data, encoding); + } else if (!isArrayBufferView(data)) { + throw new ERR_INVALID_ARG_TYPE( + "data", + ["string", "Buffer", "TypedArray", "DataView"], + data, + ); + } + + if (!this[kHandle].update(data)) throw new ERR_CRYPTO_HASH_UPDATE_FAILED(); + return this; +}; + +Hash.prototype.digest = function ( + this: Hash, + outputEncoding?: string, +): Buffer | string { + const state = this[kState]; + if (state[kFinalized]) throw new ERR_CRYPTO_HASH_FINALIZED(); + + // Explicit conversion for backward compatibility. + const ret = Buffer.from(this[kHandle].digest()); + state[kFinalized] = true; + if (outputEncoding !== undefined && outputEncoding !== "buffer") { + return ret.toString(outputEncoding); + } else { + return ret; + } +}; + +export function hash( + algorithm: string, + data: string | ArrayBufferView, + outputEncoding: string = "hex", +): string | Buffer { + validateString(algorithm, "algorithm"); + validateString(outputEncoding, "outputEncoding"); + + if (typeof data === "string") { + const hash = createHash(algorithm); + hash.update(data, "utf8"); + return hash.digest(outputEncoding); + } else if (isArrayBufferView(data)) { + const hash = createHash(algorithm); + hash.update(data, "utf8"); + return hash.digest(outputEncoding); + } + + throw new ERR_INVALID_ARG_TYPE( + "data", + ["string", "Buffer", "TypedArray", "DataView"], + data, + ); +} + +declare class Hmac extends NodeTransform { + [kHandle]: InstanceType; + [kState]: _kState; + constructor(hmac: string, key: unknown, options?: TransformOptions); + update(data: string | Buffer | ArrayBufferView, encoding?: string): this; + digest(): Buffer; + digest(outputEncoding: "buffer"): Buffer; + digest(outputEncoding: BufferEncoding): string; + digest(outputEncoding?: string): Buffer | string; +} + +export function createHmac( + hmac: string, + key: unknown, + options?: TransformOptions, +): Hmac { + return new Hmac(hmac, key, options); +} + +function Hmac( + this: unknown, + hmac: string, + key: unknown, + options?: TransformOptions, +): Hmac { + if (!(this instanceof Hmac)) { + return new Hmac(hmac, key, options); + } + validateString(hmac, "hmac"); + const encoding = getStringOption(options, "encoding"); + + if (key instanceof KeyObject) { + if (key.type !== "secret") { + throw new ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE(key.type, "secret"); + } + this[kHandle] = new cryptoImpl.HmacHandle(hmac, key); + } else if (isCryptoKey(key)) { + if (key.type !== "secret") { + throw new ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE(key.type, "secret"); + } + this[kHandle] = new cryptoImpl.HmacHandle(hmac, key); + } else if ( + typeof key !== "string" && + !isArrayBufferView(key) && + !isAnyArrayBuffer(key) + ) { + throw new ERR_INVALID_ARG_TYPE( + "key", + [ + "ArrayBuffer", + "Buffer", + "ArrayBufferView", + "string", + "KeyObject", + "CryptoKey", + ], + key, + ); + } else { + this[kHandle] = new cryptoImpl.HmacHandle( + hmac, + getArrayBufferOrView(key, "key", encoding), + ); + } + + this[kState] = { + [kFinalized]: false, + }; + Transform.call(this, options); + return this; +} +Object.setPrototypeOf(Hmac.prototype, Transform.prototype); +Object.setPrototypeOf(Hmac, Transform); + +// eslint-disable-next-line @typescript-eslint/unbound-method +Hmac.prototype.update = Hash.prototype.update; + +Hmac.prototype.digest = function ( + this: Hmac, + outputEncoding?: string, +): Buffer | string { + const state = this[kState]; + if (state[kFinalized]) { + return !outputEncoding || outputEncoding === "buffer" + ? Buffer.from("") + : ""; + } + + // Explicit conversion for backward compatibility. + const ret = Buffer.from(this[kHandle].digest()); + state[kFinalized] = true; + if (outputEncoding !== undefined && outputEncoding !== "buffer") { + return ret.toString(outputEncoding); + } else { + return ret; + } +}; + +// eslint-disable-next-line @typescript-eslint/unbound-method +Hmac.prototype._flush = Hash.prototype._flush; +// eslint-disable-next-line @typescript-eslint/unbound-method +Hmac.prototype._transform = Hash.prototype._transform; + +export { Hash, Hmac }; diff --git a/src/compat/crypto/hkdf.ts b/src/compat/crypto/hkdf.ts index 5f7ed0c..238b46b 100644 --- a/src/compat/crypto/hkdf.ts +++ b/src/compat/crypto/hkdf.ts @@ -1,9 +1,14 @@ -import { secretMaterial } from "./keys.js"; +import { KeyObject } from "./keys.js"; +import { createHmac } from "./hash.js"; +import { aliases } from "./hash-handle.js"; // RFC 5869 extract-and-expand, using the existing HMAC implementation. import { Buffer } from "node:buffer"; -// @ts-expect-error dependency has no declarations -import original from "crypto-browserify/index.js"; -import { bytes, validateInteger } from "./validation.js"; +import { + arrayBufferOrView, + inputBytes, + validateInteger, + validateCryptoLength, +} from "./validation.js"; import { validateString, validateFunction } from "../net/validators.js"; import { ERR_OUT_OF_RANGE } from "../net/errors.js"; function parameters( @@ -13,10 +18,30 @@ function parameters( info: unknown, length: number, ) { + if (key instanceof KeyObject) key = key.export(); validateString(digest, "digest"); - const ikm = bytes(secretMaterial(key), "ikm"), - s = bytes(salt, "salt"), - i = bytes(info, "info"); + const ikm = arrayBufferOrView(key, "ikm", [ + "string", + "SecretKeyObject", + "ArrayBuffer", + "TypedArray", + "DataView", + "Buffer", + ]), + s = arrayBufferOrView(salt, "salt", [ + "string", + "ArrayBuffer", + "TypedArray", + "DataView", + "Buffer", + ]), + i = arrayBufferOrView(info, "info", [ + "string", + "ArrayBuffer", + "TypedArray", + "DataView", + "Buffer", + ]); validateInteger(length, "length", 0, 2147483647); if (i.byteLength > 1024) throw new ERR_OUT_OF_RANGE( @@ -33,21 +58,29 @@ function derive({ i, length, }: ReturnType): ArrayBuffer { - const prk = original.createHmac(digest, s).update(ikm).digest(); - if (length > 255 * prk.length) - throw Object.assign(new RangeError("Invalid key length"), { - code: "ERR_CRYPTO_INVALID_KEYLEN", - }); - const output = Buffer.alloc(length); - let previous = Buffer.alloc(0); + const keyBytes = inputBytes(ikm, "getHkdf", 2), + saltBytes = inputBytes(s, "getHkdf", 3), + infoBytes = inputBytes(i, "getHkdf", 4); + if (!aliases[digest.split("\0")[0]!.toLowerCase()]) + throw new TypeError("Invalid Hkdf digest: " + digest); + validateCryptoLength(infoBytes.byteLength, "Hkdf failed: info is too large"); + validateCryptoLength(saltBytes.byteLength, "Hkdf failed: salt is too large"); + validateCryptoLength(keyBytes.byteLength, "Hkdf failed: key is too large"); + const prk = createHmac(digest, saltBytes).update(keyBytes).digest(); + + let previous: Buffer = Buffer.alloc(0); try { + if (length > 255 * prk.length) + throw new RangeError("Invalid Hkdf key length"); + const output = Buffer.alloc(length); for (let offset = 0, counter = 1; offset < length; counter++) { - previous = original - .createHmac(digest, prk) + const next = createHmac(digest, prk) .update(previous) - .update(i) + .update(infoBytes) .update(Buffer.from([counter])) .digest(); + previous.fill(0); + previous = next; const size = Math.min(previous.length, length - offset); output.set(previous.subarray(0, size), offset); offset += size; diff --git a/src/compat/crypto/index.ts b/src/compat/crypto/index.ts index 942e2da..18fe264 100644 --- a/src/compat/crypto/index.ts +++ b/src/compat/crypto/index.ts @@ -1,3 +1,10 @@ +import * as dh from "./dh.js"; +export * from "./dh.js"; +import { pbkdf2, pbkdf2Sync } from "./pbkdf2.js"; +export { pbkdf2, pbkdf2Sync }; +import { Hash, Hmac, createHmac, createHash, hash } from "./hash.js"; +export { Hash, Hmac, createHmac, createHash, hash }; +export type { HashOptions } from "./hash.js"; import { X509Certificate } from "./x509.js"; export { X509Certificate }; import { Certificate } from "./certificate.js"; @@ -8,6 +15,11 @@ import * as signatures from "./signatures.js"; export * from "./signatures.js"; import * as prime from "./prime.js"; export * from "./prime.js"; +export type { + PrimeNum as primeNum, + GeneratePrimeOptions as generatePrimeOptions, + CheckPrimeOptions as checkPrimeOptions, +} from "./prime.js"; import * as asymmetric from "./asymmetric.js"; export { createPrivateKey, @@ -29,58 +41,28 @@ export { } from "./keys.js"; import * as metadata from "./metadata.js"; export * from "./metadata.js"; -// Existing algorithms retain the dependency implementation. Additional APIs -// below are separately verified against the pinned Workers profile. -// @ts-expect-error crypto-browserify has no declarations -import original from "crypto-browserify/index.js"; import { hkdf, hkdfSync } from "./hkdf.js"; -import { randomInt, randomUUID } from "./random.js"; -import { scrypt, scryptSync } from "./scrypt.js"; -import { validateString } from "../net/validators.js"; -import { bytes } from "./validation.js"; -export { hkdf, hkdfSync, randomInt, randomUUID, scrypt, scryptSync }; -export const { - prng, - pseudoRandomBytes, - rng, +import { + randomInt, + randomUUID, randomBytes, - Hash, - createHash, - getHashes, - pbkdf2, - pbkdf2Sync, - Cipher, - createCipher, - Decipher, - createDecipher, - getCiphers, - listCiphers, - DiffieHellmanGroup, - createDiffieHellmanGroup, - getDiffieHellman, - createDiffieHellman, - DiffieHellman, randomFill, randomFillSync, - createCredentials, - constants, -} = original; +} from "./random.js"; +export { randomBytes, randomFill, randomFillSync }; +export const pseudoRandomBytes = randomBytes; +import { scrypt, scryptSync } from "./scrypt.js"; +export { hkdf, hkdfSync, randomInt, randomUUID, scrypt, scryptSync }; +export type { ArrayLike as arrayLike } from "./scrypt.js"; +import { constants } from "./constants.js"; +export { constants }; import { - Hmac, Cipheriv, Decipheriv, - createHmac, createCipheriv, createDecipheriv, } from "./symmetric.js"; -export { - Hmac, - Cipheriv, - Decipheriv, - createHmac, - createCipheriv, - createDecipheriv, -}; +export { Cipheriv, Decipheriv, createCipheriv, createDecipheriv }; import { publicEncrypt, privateEncrypt, @@ -88,29 +70,31 @@ import { privateDecrypt, } from "./rsa.js"; export { publicEncrypt, privateEncrypt, publicDecrypt, privateDecrypt }; -export const webcrypto = globalThis.crypto; -export const subtle = webcrypto.subtle; +import { webcrypto, subtle } from "./webcrypto.js"; +export { webcrypto, subtle }; export const getRandomValues = webcrypto.getRandomValues.bind(webcrypto); export const CryptoKey = globalThis.CryptoKey; -export function hash( - algorithm: string, - data: unknown, - outputEncoding: string = "hex", -) { - validateString(algorithm, "algorithm"); - validateString(outputEncoding, "outputEncoding"); - const result = original - .createHash(algorithm) - .update(bytes(data, "data")) - .digest(); - return outputEncoding === "buffer" ? result : result.toString(outputEncoding); -} export default { X509Certificate, Certificate, - ...original, + ...dh, + constants, + pbkdf2, + pbkdf2Sync, + Hash, + createHash, ...metadata, - ...asymmetric, + get fips() { + return metadata.getFips(); + }, + set fips(value: boolean) { + metadata.setFips(value); + }, + createPrivateKey: asymmetric.createPrivateKey, + createPublicKey: asymmetric.createPublicKey, + generateKeyPair: asymmetric.generateKeyPair, + generateKeyPairSync: asymmetric.generateKeyPairSync, + timingSafeEqual: asymmetric.timingSafeEqual, ...signatures, ...prime, publicEncrypt, @@ -140,6 +124,10 @@ export default { CryptoKey, randomInt, randomUUID, + pseudoRandomBytes, + randomBytes, + randomFill, + randomFillSync, hash, scrypt, scryptSync, diff --git a/src/compat/crypto/kdf-input.spec.ts b/src/compat/crypto/kdf-input.spec.ts new file mode 100644 index 0000000..71f6257 --- /dev/null +++ b/src/compat/crypto/kdf-input.spec.ts @@ -0,0 +1,57 @@ +import { expect, test } from "vitest"; +import * as crypto from "./index.js"; + +test("KDF native boundaries reject bare shared buffers and accept shared views", async () => { + const shared = new SharedArrayBuffer(3); + new Uint8Array(shared).set([9, 1, 8]); + const sharedView = new Uint8Array(shared, 1, 1); + for (const [method, base, fields, native] of [ + [ + "hkdf", + [ + "sha256", + new Uint8Array([1]), + new Uint8Array([1]), + new Uint8Array([1]), + 8, + ], + [1, 2, 3], + "getHkdf", + ], + [ + "scrypt", + [new Uint8Array([1]), new Uint8Array([1]), 8, { N: 16, r: 1, p: 1 }], + [0, 1], + "getScrypt", + ], + [ + "pbkdf2", + [new Uint8Array([1]), new Uint8Array([1]), 2, 8, "sha256"], + [0, 1], + "getPbkdf", + ], + ] as const) { + const sync = (crypto as any)[method + "Sync"], + callback = (crypto as any)[method]; + const bytes = (value: ArrayBuffer | Uint8Array) => + Array.from(value instanceof ArrayBuffer ? new Uint8Array(value) : value); + const expected = bytes(sync(...base)); + for (const field of fields) { + const args: unknown[] = [...base]; + args[field] = sharedView; + expect(bytes(sync(...args))).toEqual(expected); + args[field] = shared; + const message = `Failed to execute '${native}' on 'CryptoImpl': parameter ${field + 1} is not of type 'BufferSource'.`; + expect(() => sync(...args)).toThrow(message); + let returned = false; + const error = await new Promise((resolve) => { + callback(...args, (error: Error) => { + expect(returned).toBe(true); + resolve(error); + }); + returned = true; + }); + expect(error.message).toBe(message); + } + } +}); diff --git a/src/compat/crypto/kdf-limits.spec.ts b/src/compat/crypto/kdf-limits.spec.ts new file mode 100644 index 0000000..78f0e88 --- /dev/null +++ b/src/compat/crypto/kdf-limits.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from "vitest"; +import { generateKeyPairSync } from "./asymmetric.js"; +// @ts-expect-error Upstream JavaScript fixture. +import * as scrypt from "./upstream-scrypt.fixture.mjs"; +// @ts-expect-error Upstream JavaScript fixture. +import * as limits from "./upstream-scrypt-limits.fixture.mjs"; +for (const [name, fixture] of Object.entries({ ...scrypt, ...limits })) + test("pinned Workers scrypt: " + name, () => + (fixture as { test: () => unknown }).test(), + ); +test("RSA rejects excessive modulus size before key generation", () => { + expect(() => generateKeyPairSync("rsa", { modulusLength: 16385 })).toThrow( + new RangeError("RSA modulusLength exceeds maximum (16384)"), + ); +}); + +// @ts-expect-error Upstream JavaScript fixture. +import * as generation from "./upstream-keygen.fixture.mjs"; +for (const [name, fixture] of Object.entries(generation)) + test("pinned Workers key generation: " + name, () => + (fixture as { test: () => unknown }).test(), + ); + +test("RSA generation errors follow Workers stages", () => { + for (const modulusLength of [0, 1, 16, 255]) + expect(() => generateKeyPairSync("rsa", { modulusLength })).toThrow( + new Error("Failed to set modulus length"), + ); + for (const modulusLength of [256, 511]) + expect(() => generateKeyPairSync("rsa", { modulusLength })).toThrow( + new Error("Failed to generate key"), + ); + for (const publicExponent of [0, 1, 2, 4]) + expect(() => + generateKeyPairSync("rsa", { modulusLength: 512, publicExponent }), + ).toThrow(new Error("Failed to generate key")); +}); +test("DH generation accepts Workers small primes", () => { + for (const p of [5, 23, 37, 255]) { + const pair = generateKeyPairSync("dh", { + prime: new Uint8Array([p]), + generator: 2, + }); + expect(pair.publicKey.asymmetricKeyType).toBe("dh"); + expect(() => + pair.privateKey.export({ type: "pkcs8", format: "der" }), + ).toThrow(new Error("Failed to export key")); + } +}); +test("EC SEC1 parameter flags match Workers", () => { + for (const [paramEncoding, length] of [ + ["named", 109], + ["explicit", 121], + ]) { + const pair = generateKeyPairSync("ec", { + namedCurve: "P-256", + paramEncoding, + }); + expect(pair.privateKey.export({ type: "sec1", format: "der" }).length).toBe( + length, + ); + } +}); diff --git a/src/compat/crypto/key-errors.ts b/src/compat/crypto/key-errors.ts new file mode 100644 index 0000000..31c2d19 --- /dev/null +++ b/src/compat/crypto/key-errors.ts @@ -0,0 +1,23 @@ +// From pinned workerd internal_errors.ts (Apache-2.0 / Node MIT). +import { NodeError } from "../net/error-base.js"; +export class ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS extends NodeError { + constructor(type: string, reason: string) { + super( + "ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS", + `The selected key encoding ${type} ${reason}.`, + ); + } +} +export class ERR_INCOMPATIBLE_OPTION_PAIR extends NodeError { + constructor(a: string, b: string) { + super( + "ERR_INCOMPATIBLE_OPTION_PAIR", + `The options "${a}" and "${b}" are mutually exclusive.`, + ); + } +} +export class ERR_MISSING_OPTION extends NodeError { + constructor(name: string) { + super("ERR_MISSING_OPTION", name); + } +} diff --git a/src/compat/crypto/key-export.ts b/src/compat/crypto/key-export.ts new file mode 100644 index 0000000..b65b7e4 --- /dev/null +++ b/src/compat/crypto/key-export.ts @@ -0,0 +1,70 @@ +import { dictionaryString } from "./key-formats.js"; +import { encodeKey } from "./native-cipher.js"; +import type { NativeKey } from "./native-crypto.js"; +import { + bufferBytes, + isAnyBuffer, + viewBytes, +} from "../../shared/buffer-source.js"; + +// JSG KeyExportOptions is converted after the JavaScript frontend validation. +export function exportOptions(value: any) { + const result: { + type?: string; + format?: string; + cipher?: string; + passphrase?: Uint8Array; + } = {}; + for (const name of ["type", "format", "cipher"] as const) { + const field = value[name]; + if (field !== undefined) result[name] = dictionaryString(field); + } + const passphrase = value.passphrase; + if (passphrase !== undefined) { + if (!isAnyBuffer(passphrase) && !ArrayBuffer.isView(passphrase)) + throw new TypeError( + "Incorrect type for the 'passphrase' field on 'KeyExportOptions': the provided value is not of type 'BufferSource'.", + ); + result.passphrase = isAnyBuffer(passphrase) + ? bufferBytes(passphrase) + : viewBytes(passphrase); + } + return result; +} + +export function exportAsymmetric( + key: NativeKey, + privateKey: boolean, + options: ReturnType, +) { + const { format, type, cipher, passphrase } = options; + if (type === undefined) throw new TypeError("Missing type option"); + if (format !== "pem" && format !== "der") + throw new Error("Invalid key format"); + if (!["pkcs1", "pkcs8", "sec1", "spki"].includes(type)) + throw new Error("Invalid key encoding"); + if (type === "pkcs1" && key.type !== "rsa" && key.type !== "rsa-pss") + throw new Error("PKCS1 can only be used for RSA keys"); + if (type === "sec1" && key.type !== "ec") + throw new Error("SEC1 can only be used for EC keys"); + if (key.type === "dh") throw new Error("Failed to export key"); + let canonical: Uint8Array; + try { + canonical = key.export(privateKey, false, privateKey ? "pkcs8" : "spki"); + } catch { + throw new Error("Failed to export key"); + } + try { + return encodeKey( + canonical, + privateKey, + format === "pem", + type, + cipher, + passphrase, + key.ecEncoding, + ); + } finally { + canonical.fill(0); + } +} diff --git a/src/compat/crypto/key-formats.ts b/src/compat/crypto/key-formats.ts index 1c52409..da503b5 100644 --- a/src/compat/crypto/key-formats.ts +++ b/src/compat/crypto/key-formats.ts @@ -1,11 +1,12 @@ import { Buffer } from "node:buffer"; import { NativeKey } from "./native-crypto.js"; +import { curveName, validateEcPrivate } from "./native-cipher.js"; // DER encodings follow RFC 5208/5915/8410; JWK members follow RFC 7518/8037. const curves: Record = { "P-256": { oid: "2a8648ce3d030107", size: 32, native: "prime256v1" }, "P-384": { oid: "2b81040022", size: 48, native: "secp384r1" }, "P-521": { oid: "2b81040023", size: 66, native: "secp521r1" }, - secp256k1: { oid: "2b8104000a", size: 32, native: "secp256k1" }, + "": { oid: "2b81040021", size: 28, native: "secp224r1" }, }; export function der(tag: number, ...parts: Uint8Array[]): Buffer { const data = Buffer.concat(parts); @@ -24,74 +25,211 @@ export function der(tag: number, ...parts: Uint8Array[]): Buffer { export function integer(bytes: Uint8Array): Buffer { let offset = 0; while (offset < bytes.length - 1 && bytes[offset] === 0) offset++; - const data = bytes.subarray(offset); + const data = bytes.length ? bytes.subarray(offset) : Buffer.from([0]); return der(2, ...(data[0] & 128 ? [Buffer.from([0]), data] : [data])); } -function field(jwk: any, name: string, size?: number): Buffer { - if (typeof jwk[name] !== "string") - throw new TypeError(`Missing JWK member: ${name}`); - const bytes = Buffer.from(jwk[name], "base64url"); - if (size !== undefined && bytes.length !== size) - throw new TypeError(`Invalid JWK member length: ${name}`); - if (!bytes.length) throw new TypeError(`Empty JWK member: ${name}`); - return bytes; +function required(jwk: any, name: string): string { + if (jwk[name] === undefined) + throw new Error(`${jwk.kty} JWK missing ${name} parameter`); + return `${jwk[name]}`; } -export function importJwk(jwk: any, privateKey: boolean): NativeKey { +function field(jwk: any, name: string): Buffer { + const value = required(jwk, name).replace(/[\t\n\f\r ]/g, ""); + if ( + !/^[A-Za-z0-9_-]*={0,2}$/.test(value) || + value.replace(/=+$/, "").length % 4 === 1 || + (value.includes("=") && value.length % 4 !== 0) + ) + throw new Error(`${jwk.kty} JWK invalid ${name} parameter`); + return Buffer.from(value, "base64url"); +} +function fixed(bytes: Buffer, length: number): Buffer { + let start = 0; + while (start < bytes.length && bytes[start] === 0) start++; + bytes = bytes.subarray(start); + if (bytes.length > length) throw new Error("EC JWK invalid public key"); + return Buffer.concat([Buffer.alloc(length - bytes.length), bytes]); +} +export function dictionaryString(value: any): string { + const object = (item: any) => + item !== null && (typeof item === "object" || typeof item === "function"); + if (object(value)) { + const primitive = value[Symbol.toPrimitive]; + if (primitive != null) { + value = Reflect.apply(primitive, value, ["string"]); + if (object(value)) + throw new TypeError("Cannot convert object to primitive value"); + } else { + const original = value; + for (const name of ["toString", "valueOf"]) { + const method = original[name]; + if (typeof method !== "function") continue; + value = Reflect.apply(method, original, []); + if (!object(value)) break; + } + if (object(value)) + throw new TypeError("Cannot convert object to primitive value"); + } + } + if (typeof value === "symbol") + throw new TypeError("Cannot convert a Symbol value to a string"); + return String(value).replace( + /[\ud800-\udbff](?![\udc00-\udfff])|(? { + const result: Record = {}; + for (const name of [ + "kty", + "use", + "key_ops", + "alg", + "ext", + "crv", + "x", + "y", + "d", + "n", + "e", + "p", + "q", + "dp", + "dq", + "qi", + "oth", + "k", + "pub", + "priv", + ]) { + const field = value[name]; + if (field === undefined && name !== "kty") continue; + if (name === "key_ops" || name === "oth") { + if (!Array.isArray(field)) + throw new TypeError( + `Incorrect type for the '${name}' field on 'JsonWebKey': the provided value is not of type 'Array'.`, + ); + result[name] = []; + const length = field.length; + for (let i = 0; i < length; i++) { + const item = field[i]; + if (name === "key_ops") result[name].push(dictionaryString(item)); + else { + if ( + item != null && + typeof item !== "object" && + typeof item !== "function" + ) + throw new TypeError( + `Incorrect type for array element ${i}: the provided value is not of type 'RsaOtherPrimesInfo'.`, + ); + const prime: Record = {}; + for (const member of ["r", "d", "t"]) { + const part = item?.[member]; + if (part !== undefined) prime[member] = dictionaryString(part); + } + result[name].push(prime); + } + } + } else + result[name] = name === "ext" ? Boolean(field) : dictionaryString(field); + } + return result; +} +export function importJwk(value: any, privateKey: boolean): NativeKey { + const jwk = dictionary(value); + const unsupported = () => + new Error( + `JWK ${privateKey ? "private" : "public"} key import is not implemented for this key type`, + ); if (jwk?.kty === "RSA") { - const names = privateKey - ? ["n", "e", "d", "p", "q", "dp", "dq", "qi"] - : ["n", "e"]; + required(jwk, "n"); + required(jwk, "e"); + const values = [field(jwk, "n"), field(jwk, "e")]; + if (privateKey) { + const names = ["d", "p", "q", "dp", "dq", "qi"]; + for (const name of names) required(jwk, name); + values.push(...names.map((name) => field(jwk, name))); + } return NativeKey.parse( der( 48, ...(privateKey ? [integer(Buffer.from([0]))] : []), - ...names.map((name) => integer(field(jwk, name))), + ...values.map(integer), ), false, privateKey, ); } if (jwk?.kty === "EC") { - const curve = curves[jwk.crv]; - if (!curve) throw new TypeError("Unsupported JWK curve"); + const crv = required(jwk, "crv"); + let native: string; + try { + native = curveName(crv); + } catch (error) { + throw new Error( + (error as Error).message === "Failed to initialize key" + ? "EC JWK unsupported curve" + : "EC JWK unsupported crv parameter", + ); + } + const curve = Object.values(curves).find( + (value) => value.native === native, + )!; + required(jwk, "x"); + required(jwk, "y"); + const x = field(jwk, "x"), + y = field(jwk, "y"); const oid = der(6, Buffer.from(curve.oid, "hex")); const point = Buffer.concat([ Buffer.from([4]), - field(jwk, "x", curve.size), - field(jwk, "y", curve.size), + fixed(x, curve.size), + fixed(y, curve.size), ]); const bits = der(3, Buffer.from([0]), point); - const data = privateKey - ? der( - 48, - integer(Buffer.from([1])), - der(4, field(jwk, "d", curve.size)), - der(160, oid), - der(161, bits), - ) - : der( - 48, - der(48, der(6, Buffer.from("2a8648ce3d0201", "hex")), oid), - bits, - ); - return NativeKey.parse(data, false, privateKey); + const publicData = der( + 48, + der(48, der(6, Buffer.from("2a8648ce3d0201", "hex")), oid), + bits, + ); + let publicKey: NativeKey; + try { + publicKey = NativeKey.parse(publicData, false, false); + } catch { + throw new Error("EC JWK invalid public key"); + } + if (!privateKey) return publicKey; + publicKey.dispose(); + const d = field(jwk, "d"); + validateEcPrivate(native, d); + return NativeKey.parse( + der( + 48, + integer(Buffer.from([1])), + der(4, d), + der(160, oid), + der(161, bits), + ), + false, + true, + ); } - if (jwk?.kty === "OKP" && ["Ed25519", "X25519"].includes(jwk.crv)) { + if (jwk?.kty === "OKP") { + const crv = required(jwk, "crv"); + if (!["Ed25519", "X25519"].includes(crv)) + throw new Error("OKP JWK unsupported crv parameter"); + const value = field(jwk, privateKey ? "d" : "x"); + if (value.length !== 32) throw unsupported(); const algorithm = der( 48, - der(6, Buffer.from([43, 101, jwk.crv === "Ed25519" ? 112 : 110])), + der(6, Buffer.from([43, 101, crv === "Ed25519" ? 112 : 110])), ); const data = privateKey - ? der( - 48, - integer(Buffer.from([0])), - algorithm, - der(4, der(4, field(jwk, "d", 32))), - ) - : der(48, algorithm, der(3, Buffer.from([0]), field(jwk, "x", 32))); + ? der(48, integer(Buffer.from([0])), algorithm, der(4, der(4, value))) + : der(48, algorithm, der(3, Buffer.from([0]), value)); return NativeKey.parse(data, false, privateKey); } - throw new TypeError("Unsupported JWK key type"); + throw unsupported(); } function encode(bytes: Uint8Array, size?: number): string { if (size !== undefined && bytes.length < size) @@ -139,5 +277,5 @@ export function exportJwk(key: NativeKey, privateKey: boolean): any { x: encode(key.raw(false)), ...(privateKey ? { d: encode(key.raw(true)) } : {}), }; - throw new TypeError("Unsupported JWK key type"); + throw new Error("Key type is invalid for JWK export"); } diff --git a/src/compat/crypto/key-options.ts b/src/compat/crypto/key-options.ts new file mode 100644 index 0000000..fb0e4ae --- /dev/null +++ b/src/compat/crypto/key-options.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT ORs +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { Buffer } from "node:buffer"; +import { validateObject, validateString } from "../net/validators.js"; +import { ERR_INVALID_ARG_TYPE } from "../net/errors.js"; +import { ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS } from "./key-errors.js"; +import { isUint8Array } from "../util/types/index.js"; +export function validateExportOptions( + options: any, + type: string, + name = "options", + asymmetricKeyType?: string, +) { + validateObject(options, name); + // Yes, converting to any is a bit of a cheat, but it allows us to check + // each option individually without having to do a bunch of type guards. + const opts = options; + if (opts.format !== undefined) { + validateString(opts.format, `${name}.format`); + } else { + options.format = "buffer"; + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if ("type" in opts && opts.type !== undefined) { + validateString(opts.type, `${name}.type`); + } + if (type === "private") { + if ( + "type" in opts && + opts.type === "pkcs1" && + asymmetricKeyType !== undefined && + asymmetricKeyType !== "rsa" + ) { + throw new ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS( + opts.type, + "can only be used for RSA keys", + ); + } + if ( + "type" in opts && + opts.type === "sec1" && + asymmetricKeyType !== undefined && + asymmetricKeyType !== "ec" && + (asymmetricKeyType as string) !== "ecdh" + ) { + throw new ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS( + opts.type, + "can only be used for EC keys", + ); + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if ("cipher" in opts && opts.cipher !== undefined) { + validateString(opts.cipher, `${name}.cipher`); + if ( + opts.format === "der" && + (opts.type === "pkcs1" || opts.type === "sec1") + ) { + throw new ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS( + opts.type, + "does not support encryption", + ); + } + if (typeof opts.passphrase === "string") { + opts.passphrase = Buffer.from(opts.passphrase, opts.encoding); + } + if (!isUint8Array(opts.passphrase)) { + throw new ERR_INVALID_ARG_TYPE( + `${name}.passphrase`, + ["string", "Uint8Array"], + opts.passphrase, + ); + } + } + } +} diff --git a/src/compat/crypto/key-state.ts b/src/compat/crypto/key-state.ts index 3e771d0..daba033 100644 --- a/src/compat/crypto/key-state.ts +++ b/src/compat/crypto/key-state.ts @@ -5,6 +5,7 @@ export interface KeyState { bytes?: Uint8Array; asymmetric?: NativeKey; native?: CryptoKey; + nativeAlgorithm?: KeyAlgorithm; } export const keyStates = new WeakMap(); export function isKeyObject(value: unknown): boolean { diff --git a/src/compat/crypto/keys.spec.ts b/src/compat/crypto/keys.spec.ts new file mode 100644 index 0000000..16c7f0e --- /dev/null +++ b/src/compat/crypto/keys.spec.ts @@ -0,0 +1,131 @@ +import { expect, test } from "vitest"; +import { + createPrivateKey, + createPublicKey, + generateKeyPairSync, + KeyObject, +} from "./index.js"; + +test("JWK conversion reads unused dictionary members once before parsing", () => { + let reads = 0; + const key = createPublicKey({ + key: { + kty: new String("RSA"), + get n() { + return ++reads === 1 ? "AQ" : "@@"; + }, + e: "AQAB", + }, + }); + expect(reads).toBe(1); + expect(key.export({ format: "jwk" })).toEqual({ + kty: "RSA", + n: "AQ", + e: "AQAB", + }); + const failure = new Error("unused d"); + expect(() => + createPublicKey({ + key: { + kty: "RSA", + n: "AQ", + e: "AQAB", + get d() { + throw failure; + }, + }, + }), + ).toThrow(failure); + for (const name of ["key_ops", "oth"]) { + const array = [null]; + Object.defineProperty(array, 0, { + get() { + Object.defineProperty(array, 1, { + get() { + throw new Error("added item"); + }, + }); + return name === "key_ops" ? "sign" : {}; + }, + }); + expect( + createPublicKey({ + key: { kty: "RSA", n: "AQ", e: "AQAB", [name]: array }, + }).type, + ).toBe("public"); + } +}); + +test("EC private scalars use the Workers range without requiring point consistency", () => { + const { privateKey } = generateKeyPairSync("ec", { + namedCurve: "prime256v1", + }); + const jwk = privateKey.export({ format: "jwk" }); + for (const d of ["", "AA", "_".repeat(43)]) + expect(() => createPrivateKey({ key: { ...jwk, d } })).toThrow( + "EW JWK invalid private key", + ); + expect( + createPrivateKey({ key: { ...jwk, d: "AQ" } }).export({ format: "jwk" }).d, + ).toBe("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE"); +}); + +test("DER decoding respects the selected encoding", () => { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 512 }); + const key = privateKey.export({ format: "der", type: "pkcs8" }); + expect( + createPrivateKey({ key, format: "der", type: "pkcs8" }).equals(privateKey), + ).toBe(true); + expect( + createPrivateKey({ key, format: "der", type: "pkcs1" }).equals(privateKey), + ).toBe(true); + const pkcs1 = privateKey.export({ format: "der", type: "pkcs1" }); + expect(() => + createPrivateKey({ key: pkcs1, format: "der", type: "pkcs8" }), + ).toThrow("Failed to parse private key"); +}); + +test("host key metadata is copied from intrinsic getters", async () => { + const pair = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + Object.defineProperty(pair.publicKey, "algorithm", { + value: { name: "fake" }, + configurable: true, + }); + const key = KeyObject.from(pair.publicKey) as ReturnType< + typeof createPublicKey + >; + const get = Object.getOwnPropertyDescriptor( + CryptoKey.prototype, + "algorithm", + )!.get!; + get.call(pair.publicKey).namedCurve = "fake"; + expect(key.asymmetricKeyType).toBe("ec"); + expect(key.asymmetricKeyDetails).toEqual({ namedCurve: "prime256v1" }); +}); + +test("shared key buffers are decoded when the host provides shared memory", () => { + const { privateKey } = generateKeyPairSync("ec", { + namedCurve: "prime256v1", + }); + const data = new TextEncoder().encode( + privateKey.export({ format: "pem", type: "pkcs8" }), + ); + const shared = new SharedArrayBuffer(data.byteLength); + new Uint8Array(shared).set(data); + expect(createPrivateKey(shared).equals(privateKey)).toBe(true); + expect(createPrivateKey({ key: shared }).equals(privateKey)).toBe(true); +}); + +test("FIPS reporting describes the owned backend capability", async () => { + const crypto = await import("./index.js"); + expect(crypto.fips).toBe(false); + expect(crypto.getFips()).toBe(false); + expect(crypto.default.fips).toBe(false); + expect(() => { + crypto.default.fips = true; + }).toThrow("The setFips method is not implemented"); +}); diff --git a/src/compat/crypto/keys.ts b/src/compat/crypto/keys.ts index 4b70f2e..53662bc 100644 --- a/src/compat/crypto/keys.ts +++ b/src/compat/crypto/keys.ts @@ -1,15 +1,14 @@ +import { inspect } from "../util/inspect.js"; +import { exportOptions, exportAsymmetric } from "./key-export.js"; +import { validateExportOptions } from "./key-options.js"; import { exportJwk } from "./key-formats.js"; // API behavior follows workerd@5358e3d337336eaf696a48bc1563d808b10feba5 // crypto_keys.ts (Apache-2.0 and Node MIT). Host CryptoKey export is an explicit // synchronous-engine boundary; adapter-created secret keys own copied bytes. import { Buffer } from "node:buffer"; import { keyStates, type KeyState } from "./key-state.js"; -import { bytes, validateInteger } from "./validation.js"; -import { - validateObject, - validateString, - validateOneOf, -} from "../net/validators.js"; +import { isAnyBuffer, validateInteger } from "./validation.js"; +import { validateObject, validateOneOf } from "../net/validators.js"; import { ERR_INVALID_ARG_TYPE } from "../net/errors.js"; function state(value: object): KeyState { const result = keyStates.get(value); @@ -24,6 +23,30 @@ function boundary(): never { { code: "ERR_METHOD_NOT_IMPLEMENTED" }, ); } +const nativeAlgorithm = Object.getOwnPropertyDescriptor( + globalThis.CryptoKey.prototype, + "algorithm", +)!.get!; +const nativeType = Object.getOwnPropertyDescriptor( + globalThis.CryptoKey.prototype, + "type", +)!.get!; +function cloneAlgorithm(value: any): any { + if (ArrayBuffer.isView(value)) + return new Uint8Array( + value.buffer, + value.byteOffset, + value.byteLength, + ).slice(); + if (value && typeof value === "object") + return Object.fromEntries( + Object.entries(value).map(([name, entry]) => [ + name, + cloneAlgorithm(entry), + ]), + ); + return value; +} export function create( type: KeyState["type"], value: Omit, @@ -45,42 +68,31 @@ export abstract class KeyObject { static from(key: CryptoKey): KeyObject { if (!(key instanceof globalThis.CryptoKey)) throw new ERR_INVALID_ARG_TYPE("key", "CryptoKey", key); - return create(key.type, { native: key }); + return create(nativeType.call(key), { + native: key, + nativeAlgorithm: cloneAlgorithm(nativeAlgorithm.call(key)), + }); } abstract get type(): KeyState["type"]; - get [Symbol.toStringTag]() { - return "KeyObject"; - } export(options: any = {}): any { validateObject(options, "options"); - if (options.format === undefined) options.format = "buffer"; - validateString(options.format, "options.format"); - if (options.type !== undefined) - validateString(options.type, "options.type"); const current = state(this); + validateExportOptions( + options, + current.type, + "options", + current.asymmetric?.type, + ); + options = exportOptions(options); + if (options.format === undefined) + throw new TypeError("Missing format option"); if (current.asymmetric) { if (options.format === "jwk") return exportJwk(current.asymmetric, current.type === "private"); - validateOneOf(options.format, "options.format", ["pem", "der"]); - validateOneOf( - options.type, - "options.type", - current.type === "private" - ? ["pkcs1", "pkcs8", "sec1"] - : ["pkcs1", "spki"], - ); - if (options.cipher !== undefined) - validateString(options.cipher, "options.cipher"); - const passphrase = - options.passphrase === undefined - ? undefined - : bytes(options.passphrase, "options.passphrase"); - const result = current.asymmetric.export( + const result = exportAsymmetric( + current.asymmetric, current.type === "private", - options.format === "pem", - options.type, - options.cipher, - passphrase, + options, ); return options.format === "pem" ? new TextDecoder().decode(result) @@ -120,65 +132,148 @@ export abstract class KeyObject { difference |= a.bytes[i] ^ b.bytes[i]; return difference === 0; } + get [Symbol.toStringTag]() { + return "KeyObject"; + } } export class SecretKeyObject extends KeyObject { - get type(): "secret" { - return "secret"; - } - override export(options?: any) { - return super.export(options); - } get symmetricKeySize() { const key = state(this); return ( - key.bytes?.length ?? ((key.native?.algorithm as any)?.length ?? 0) | 0 + (key.bytes?.length ?? (key.native?.algorithm as any)?.length ?? 0) | 0 ); } - [Symbol.for("nodejs.util.inspect.custom")]() { - return `SecretKeyObject { size: ${this.symmetricKeySize} }`; + override export(options?: any) { + return super.export(options); + } + get type(): "secret" { + return "secret"; + } + [Symbol.for("nodejs.util.inspect.custom")]( + depth: number, + options: { depth?: number }, + ) { + if (depth < 0) return this; + const opts = { + ...options, + depth: options.depth == null ? null : options.depth - 1, + }; + return `${this.constructor.name} ${inspect({ size: this.symmetricKeySize }, opts)}`; } } abstract class AsymmetricKeyObject extends KeyObject { + get asymmetricKeyDetails() { + const details = (() => { + const current = state(this), + key = current.asymmetric; + if (!key) { + const algorithm = current.nativeAlgorithm as + | RsaHashedKeyAlgorithm + | EcKeyAlgorithm + | undefined; + if (!algorithm) return boundary(); + if ("modulusLength" in algorithm) { + let publicExponent = 0n; + for (const value of algorithm.publicExponent) + publicExponent = (publicExponent << 8n) | BigInt(value); + return { modulusLength: algorithm.modulusLength, publicExponent }; + } + if ("namedCurve" in algorithm) + return { + namedCurve: + ( + { + "P-256": "prime256v1", + "P-384": "secp384r1", + "P-521": "secp521r1", + } as Record + )[algorithm.namedCurve] ?? algorithm.namedCurve, + }; + return {}; + } + if (key.type === "rsa") + return { + modulusLength: key.bits, + publicExponent: BigInt( + "0x" + Buffer.from(key.parameter("e")).toString("hex"), + ), + }; + if (key.type === "ec") return { namedCurve: key.group }; + if (key.type === "dsa") { + const q = key.parameter("q"); + return { + modulusLength: key.bits, + divisorLength: q.length + ? (q.length - 1) * 8 + 32 - Math.clz32(q[0]) + : 0, + }; + } + return {}; + })(); + return { + modulusLength: undefined, + publicExponent: undefined, + divisorLength: undefined, + namedCurve: undefined, + ...details, + }; + } get asymmetricKeyType() { - const key = state(this).asymmetric; - if (!key) return boundary(); + const current = state(this), + key = current.asymmetric; + if (!key) { + const name = current.nativeAlgorithm?.name; + if (name === undefined) return boundary(); + return ( + ( + { + "RSASSA-PKCS1-v1_5": "rsa", + "RSA-PSS": "rsa", + "RSA-OAEP": "rsa", + ECDSA: "ec", + ECDH: "ecdh", + Ed25519: "ed25519", + "NODE-ED25519": "ed25519", + X25519: "x25519", + } as Record + )[name] ?? name + ); + } return key.type; } - get asymmetricKeyDetails() { - const key = state(this).asymmetric; - if (!key) return boundary(); - if (key.type === "rsa") - return { - modulusLength: key.bits, - publicExponent: BigInt( - "0x" + Buffer.from(key.parameter("e")).toString("hex"), - ), - }; - if (key.type === "ec") return { namedCurve: key.group }; - return {}; - } toCryptoKey() { throw Object.assign( new Error("The toCryptoKey method is not implemented"), { code: "ERR_METHOD_NOT_IMPLEMENTED" }, ); } + [Symbol.for("nodejs.util.inspect.custom")]( + depth: number, + options: { depth?: number }, + ) { + if (depth < 0) return this; + const opts = { + ...options, + depth: options.depth == null ? null : options.depth - 1, + }; + return `${this.constructor.name} ${inspect({ type: this.asymmetricKeyType, details: this.asymmetricKeyDetails }, opts)}`; + } } export class PublicKeyObject extends AsymmetricKeyObject { + override export(options?: any) { + return super.export(options); + } get type(): "public" { return "public"; } +} +export class PrivateKeyObject extends AsymmetricKeyObject { override export(options?: any) { return super.export(options); } -} -export class PrivateKeyObject extends AsymmetricKeyObject { get type(): "private" { return "private"; } - override export(options?: any) { - return super.export(options); - } } export function createSecretKey( value: unknown, @@ -186,12 +281,8 @@ export function createSecretKey( ): SecretKeyObject { if ( typeof value !== "string" && - !(value instanceof ArrayBuffer) && - !ArrayBuffer.isView(value) && - !( - typeof SharedArrayBuffer !== "undefined" && - value instanceof SharedArrayBuffer - ) + !isAnyBuffer(value) && + !ArrayBuffer.isView(value) ) throw new ERR_INVALID_ARG_TYPE( "key", @@ -201,10 +292,13 @@ export function createSecretKey( const data = typeof value === "string" ? Buffer.from(value, encoding) - : typeof SharedArrayBuffer !== "undefined" && - value instanceof SharedArrayBuffer + : isAnyBuffer(value) ? new Uint8Array(value) - : bytes(value, "key"); + : new Uint8Array( + (value as ArrayBufferView).buffer, + (value as ArrayBufferView).byteOffset, + (value as ArrayBufferView).byteLength, + ); return create("secret", { bytes: new Uint8Array(data) }) as SecretKeyObject; } export function secretMaterial(value: unknown): any { diff --git a/src/compat/crypto/metadata.ts b/src/compat/crypto/metadata.ts index 0a574ee..eecb552 100644 --- a/src/compat/crypto/metadata.ts +++ b/src/compat/crypto/metadata.ts @@ -1,3 +1,7 @@ +import { hashNames } from "./hash-handle.js"; +export function getHashes() { + return [...hashNames]; +} // Copyright (c) 2017-2022 Cloudflare, Inc. // Licensed under Apache-2.0: https://opensource.org/licenses/Apache-2.0 // Adapted from workerd@5358e3d337336eaf696a48bc1563d808b10feba5. @@ -18,5 +22,91 @@ export function setEngine(_engine: string, _flags?: number): never { export function setFips(_enabled: boolean): never { return unavailable("setFips"); } -// Do not copy Workers' fips=true: this adapter uses JavaScript algorithms, -// not Workers' FIPS backend. That capability remains an explicit boundary. +// The owned backends do not run in a validated FIPS mode. Report the actual +// capability rather than copying Workers' true value. +export const fips = false; +export function getFips(): boolean { + return fips; +} + +export { getCipherInfo } from "./cipher-info.js"; + +// Includes the duplicate GCM aliases exposed by the pinned backend. +const cipherNames = [ + "AES-128-CBC", + "AES-192-CBC", + "AES-256-CBC", + "AES-128-CTR", + "AES-192-CTR", + "AES-256-CTR", + "AES-128-ECB", + "AES-192-ECB", + "AES-256-ECB", + "AES-128-OFB", + "AES-192-OFB", + "AES-256-OFB", + "AES-128-GCM", + "AES-192-GCM", + "AES-256-GCM", + "DES-CBC", + "DES-ECB", + "DES-EDE", + "DES-EDE-CBC", + "DES-EDE3-CBC", + "RC2-CBC", + "RC4", + "aes-128-cbc", + "aes-192-cbc", + "aes-256-cbc", + "aes-128-ctr", + "aes-192-ctr", + "aes-256-ctr", + "aes-128-ecb", + "aes-192-ecb", + "aes-256-ecb", + "aes-128-ofb", + "aes-192-ofb", + "aes-256-ofb", + "aes-128-gcm", + "aes-192-gcm", + "aes-256-gcm", + "des-cbc", + "des-ecb", + "des-ede", + "des-ede-cbc", + "des-ede3-cbc", + "rc2-cbc", + "rc4", + "aes-256-eax", + "aes-128-ccm-matter", + "aes-128-ccm-bluetooth-8", + "aes-128-gcm-randnonce", + "aes-256-gcm-randnonce", + "aes-128-gcm-siv", + "aes-128-ccm-bluetooth", + "aes-256-ctr-hmac-sha256", + "aes-128-ctr-hmac-sha256", + "xchacha20-poly1305", + "aes-256-gcm-siv", + "aes-128-eax", + "chacha20-poly1305", + "aes-192-gcm", + "aes-256-gcm", + "aes-128-gcm", +]; +export function getCiphers(): string[] { + return [...cipherNames]; +} + +export function Cipher(): never { + return unavailable("Cipher"); +} +export function Decipher(): never { + return unavailable("Decipher"); +} +export function createCipher(): never { + return unavailable("createCipher"); +} +export function createDecipher(): never { + return unavailable("createDecipher"); +} diff --git a/src/compat/crypto/native-cipher.build.json b/src/compat/crypto/native-cipher.build.json new file mode 100644 index 0000000..e80707a --- /dev/null +++ b/src/compat/crypto/native-cipher.build.json @@ -0,0 +1,26 @@ +{ + "revisions": { + "emsdk": "e4fe26ef59168ff44f4c23c466e497bf60b3411e", + "boringssl": "427ec40cc8edc545253289232e885708c409e5ea" + }, + "toolVersions": { + "cmake": "4.2.0", + "ninja": "1.13.0", + "emcc": "emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 4.0.20 (6913738ec5371a88c4af5a80db0ab42bad3de681)" + }, + "definitions": "-DOPENSSL_NO_THREADS_CORRUPT_MEMORY_AND_LEAK_SECRETS_IF_THREADED -DOPENSSL_NO_FILESYSTEM -DOPENSSL_NO_POSIX_IO -DOPENSSL_NO_SOCK", + "flags": [ + "-O3", + "-sSTANDALONE_WASM=1", + "-sALLOW_MEMORY_GROWTH=1", + "-sFILESYSTEM=0", + "-sASSERTIONS=0", + "-sMALLOC=emmalloc", + "-sABORTING_MALLOC=0", + "--no-entry", + "-sEXPORTED_FUNCTIONS=[\"_malloc\",\"_free\"]" + ], + "libraries": ["libdecrepit.a", "libcrypto.a"], + "bindingSha256": "336a6d833fe3036bb32eef7eaa7f89422b73f3957aa36b44aab41d26a29f5f4d", + "sha256": "689e24ca556618e78adc25715ce367d55eaa0f2cabc54cb7558b96a15e5c583a" +} diff --git a/src/compat/crypto/native-cipher.c b/src/compat/crypto/native-cipher.c new file mode 100644 index 0000000..4646a34 --- /dev/null +++ b/src/compat/crypto/native-cipher.c @@ -0,0 +1,204 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#define API EMSCRIPTEN_KEEPALIVE +// Key decoding follows ncrypto v1.1.4 using the same BoringSSL parser as Workers. +static unsigned char *key_der; +static int key_der_size; +API void cipher_key_clear(void) { + if(key_der){if(key_der_size>0)OPENSSL_cleanse(key_der,key_der_size);OPENSSL_free(key_der);} + key_der=NULL;key_der_size=0; +} +API const unsigned char *cipher_key_data(void){return key_der;} +API int cipher_key_der_size(void){return key_der_size;} +struct key_password {const unsigned char *data;int size;}; +static int key_password_cb(char *out,int capacity,int writing,void *opaque){ + (void)writing;struct key_password *pass=opaque; + if(!pass || pass->size>capacity)return -1; + if(pass->size)memcpy(out,pass->data,pass->size);return pass->size; +} +static int key_encrypted(const unsigned char *data,int size){ + if(size<2 || data[0]!=0x30)return 0; + size_t offset=2,length=data[1]; + if(length&0x80){size_t count=length&0x7f;length=0; + if(count>sizeof(size_t) || count+2>(size_t)size)return 0; + for(size_t i=0;i=0)EC_KEY_set_enc_flags(ec,ec_encoding);ok=pem?PEM_write_bio_ECPrivateKey(bio,ec,algorithm,(const unsigned char*)pass,password_size,NULL,NULL):i2d_ECPrivateKey_bio(bio,ec);} + } + }else if(type==1){ + RSA *rsa=EVP_PKEY_get0_RSA(key); + if(rsa)ok=pem?PEM_write_bio_RSAPublicKey(bio,rsa):i2d_RSAPublicKey_bio(bio,rsa); + }else ok=pem?PEM_write_bio_PUBKEY(bio,key):i2d_PUBKEY_bio(bio,key); + if(ok==1){ + const unsigned char *encoded;size_t length; + if(BIO_mem_contents(bio,&encoded,&length) && length<=0x7fffffff){ + key_der=OPENSSL_malloc(length?length:1); + if(key_der){if(length)memcpy(key_der,encoded,length);key_der_size=(int)length;}else ok=0; + }else ok=0; + } + BIO_free(bio);EVP_PKEY_free(key);ERR_clear_error(); + if(ok!=1){cipher_key_clear();return 0;}return 1; +} +#define AEAD(name) if(!strcmp(algorithm,#name)) return EVP_aead_##name(); +API const EVP_AEAD *cipher_aead(const char *algorithm) { + AEAD(aes_128_gcm) AEAD(aes_192_gcm) AEAD(aes_256_gcm) + AEAD(aes_128_eax) AEAD(aes_256_eax) + AEAD(aes_128_ccm_matter) AEAD(aes_128_ccm_bluetooth) AEAD(aes_128_ccm_bluetooth_8) + AEAD(aes_128_gcm_randnonce) AEAD(aes_256_gcm_randnonce) + AEAD(aes_128_gcm_siv) AEAD(aes_256_gcm_siv) + AEAD(aes_128_ctr_hmac_sha256) AEAD(aes_256_ctr_hmac_sha256) + AEAD(xchacha20_poly1305) AEAD(chacha20_poly1305) + return NULL; +} +API EVP_AEAD_CTX *cipher_aead_new(const EVP_AEAD *algorithm,const unsigned char *key,size_t size,size_t tag) {return EVP_AEAD_CTX_new(algorithm,key,size,tag);} +API void cipher_aead_free(EVP_AEAD_CTX *ctx) {EVP_AEAD_CTX_free(ctx);} +API int cipher_aead_seal(EVP_AEAD_CTX *ctx,unsigned char *out,unsigned char *tag,size_t *tag_size,size_t max_tag,const unsigned char *nonce,size_t nonce_size,const unsigned char *input,size_t size,const unsigned char *aad,size_t aad_size) {ERR_clear_error();return EVP_AEAD_CTX_seal_scatter(ctx,out,tag,tag_size,max_tag,nonce,nonce_size,input,size,NULL,0,aad,aad_size);} +API int cipher_aead_open(EVP_AEAD_CTX *ctx,unsigned char *out,const unsigned char *nonce,size_t nonce_size,const unsigned char *input,size_t size,const unsigned char *tag,size_t tag_size,const unsigned char *aad,size_t aad_size) {ERR_clear_error();return EVP_AEAD_CTX_open_gather(ctx,out,nonce,nonce_size,input,size,tag,tag_size,aad,aad_size);} +API int cipher_aead_info(const EVP_AEAD *a,int kind){return kind==0?EVP_AEAD_key_length(a):kind==1?EVP_AEAD_nonce_length(a):EVP_AEAD_max_overhead(a);} +API const EVP_CIPHER *cipher_lookup(const char *name){return EVP_get_cipherbyname(name);} +API EVP_CIPHER_CTX *cipher_new(void){return EVP_CIPHER_CTX_new();} +API void cipher_free(EVP_CIPHER_CTX *ctx){EVP_CIPHER_CTX_free(ctx);} +API int cipher_init(EVP_CIPHER_CTX *ctx,const EVP_CIPHER *cipher,const unsigned char *key,const unsigned char *iv,int encrypt){return EVP_CipherInit_ex(ctx,cipher,NULL,key,iv,encrypt);} +API int cipher_key_size(EVP_CIPHER_CTX *ctx,int size){return EVP_CIPHER_CTX_set_key_length(ctx,size);} +API int cipher_padding(EVP_CIPHER_CTX *ctx,int padding){return EVP_CIPHER_CTX_set_padding(ctx,padding);} +API int cipher_iv_size(EVP_CIPHER_CTX *ctx,int size){return EVP_CIPHER_CTX_ctrl(ctx,EVP_CTRL_AEAD_SET_IVLEN,size,NULL);} +API int cipher_tag(EVP_CIPHER_CTX *ctx,int get,int size,unsigned char *data){return EVP_CIPHER_CTX_ctrl(ctx,get?EVP_CTRL_AEAD_GET_TAG:EVP_CTRL_AEAD_SET_TAG,size,data);} +API int cipher_update(EVP_CIPHER_CTX *ctx,unsigned char *out,int *size,const unsigned char *data,int length){return EVP_CipherUpdate(ctx,out,size,data,length);} +API int cipher_final(EVP_CIPHER_CTX *ctx,unsigned char *out,int *size){return EVP_CipherFinal_ex(ctx,out,size);} +API unsigned long cipher_error(void){return ERR_get_error();} +API const char *cipher_reason(unsigned long code){return ERR_reason_error_string(code);} +API int cipher_info(const EVP_CIPHER *cipher,int kind){return kind==0?EVP_CIPHER_key_length(cipher):kind==1?EVP_CIPHER_iv_length(cipher):kind==2?EVP_CIPHER_block_size(cipher):EVP_CIPHER_mode(cipher);} + +API int cipher_bad_decrypt(void){uint32_t code=ERR_peek_last_error();int result=ERR_GET_LIB(code)==ERR_LIB_CIPHER && ERR_GET_REASON(code)==CIPHER_R_BAD_DECRYPT;ERR_clear_error();return result;} + +API intptr_t cipher_curve_name(const char *name){ + int nid=EC_curve_nist2nid(name);if(nid==NID_undef)nid=OBJ_sn2nid(name); + if(nid==NID_undef)return 0;EC_GROUP *group=EC_GROUP_new_by_curve_name(nid);if(!group)return -1; + EC_GROUP_free(group);return (intptr_t)OBJ_nid2sn(nid); +} +API int cipher_ec_private_valid(const char *name,const unsigned char *data,int length){ + int nid=EC_curve_nist2nid(name);if(nid==NID_undef)nid=OBJ_sn2nid(name); + EC_KEY *key=EC_KEY_new_by_curve_name(nid);BIGNUM *scalar=BN_bin2bn(data,length,NULL); + int ok=key && scalar && EC_KEY_set_private_key(key,scalar); + BN_clear_free(scalar);EC_KEY_free(key);ERR_clear_error();return ok; +} + +API int cipher_dh_generate(const unsigned char *prime,int length,unsigned long generator,unsigned char *out){ + DH *dh=DH_new();BIGNUM *p=BN_bin2bn(prime,length,NULL),*g=BN_new();int ok=0; + if(!dh || !p || !g || !BN_set_word(g,generator) || !DH_set0_pqg(dh,p,NULL,g))goto done; + p=NULL;g=NULL;if(!DH_generate_key(dh))goto done; + const BIGNUM *pub,*priv;DH_get0_key(dh,&pub,&priv); + ok=BN_bn2bin_padded(out,length,pub) && BN_bn2bin_padded(out+length,length,priv); + done: BN_free(p);BN_free(g);DH_free(dh);return ok; +} +API int cipher_dh_derive(const unsigned char *prime,int plen,const unsigned char *generator,int glen,const unsigned char *priv,int xlen,const unsigned char *pub,int ylen,unsigned char *out){ + DH *dh=DH_new();BIGNUM *p=BN_bin2bn(prime,plen,NULL),*g=BN_bin2bn(generator,glen,NULL),*x=BN_bin2bn(priv,xlen,NULL),*y=BN_bin2bn(pub,ylen,NULL);int result=-1; + if(!dh || !p || !g || !x || !y || !DH_set0_pqg(dh,p,NULL,g))goto done; + p=NULL;g=NULL;if(!DH_set0_key(dh,NULL,x))goto done;x=NULL; + result=DH_compute_key_padded(out,y,dh); + done: BN_free(p);BN_free(g);BN_clear_free(x);BN_free(y);DH_free(dh);return result; +} + +API DH *dh_create(const unsigned char *prime,int plen,const unsigned char *generator,int glen){ + DH *dh=DH_new();BIGNUM *p=BN_bin2bn(prime,plen,NULL),*g=BN_bin2bn(generator,glen,NULL); + if(!dh || !p || !g || !DH_set0_pqg(dh,p,NULL,g)){DH_free(dh);BN_free(p);BN_free(g);return NULL;}return dh; +} +API DH *dh_parameters(int bits,int generator){ + DH *dh=DH_new();if(!dh || !DH_generate_parameters_ex(dh,bits,generator,NULL)){DH_free(dh);return NULL;}return dh; +} +API DH *dh_group(int bits){ + BIGNUM *p=bits==2048?BN_get_rfc3526_prime_2048(NULL):bits==3072?BN_get_rfc3526_prime_3072(NULL):bits==4096?BN_get_rfc3526_prime_4096(NULL):bits==6144?BN_get_rfc3526_prime_6144(NULL):bits==8192?BN_get_rfc3526_prime_8192(NULL):NULL; + BIGNUM *g=BN_new();DH *dh=DH_new();if(!p || !g || !dh || !BN_set_word(g,2) || !DH_set0_pqg(dh,p,NULL,g)){BN_free(p);BN_free(g);DH_free(dh);return NULL;}return dh; +} +API void dh_free(DH *dh){DH_free(dh);} +API int dh_check(DH *dh){int code=0;return DH_check(dh,&code)?code:-1;} +API int dh_generate(DH *dh){return DH_generate_key(dh);} +API int dh_get(DH *dh,int kind,unsigned char *out){ + const BIGNUM *bn=kind==0?DH_get0_p(dh):kind==1?DH_get0_g(dh):kind==2?DH_get0_pub_key(dh):DH_get0_priv_key(dh); + if(!bn)return -1;int length=BN_num_bytes(bn);if(out && !BN_bn2bin_padded(out,length,bn))return -1;return length; +} +API int dh_set(DH *dh,int private_key,const unsigned char *key,int length){ + BIGNUM *bn=BN_bin2bn(key,length,NULL);if(!bn)return -1; + if(!private_key){int code=0;if(!DH_check_pub_key(dh,bn,&code)){BN_free(bn);return -1;}if(code){BN_free(bn);return code;}} + int ok=DH_set0_key(dh,private_key?NULL:bn,private_key?bn:NULL);if(!ok)BN_clear_free(bn);return ok?0:-1; +} +API int dh_peer_check(DH *dh,const unsigned char *key,int length){ + BIGNUM *bn=BN_bin2bn(key,length,NULL);int code=0;int ok=bn && DH_check_pub_key(dh,bn,&code);BN_free(bn);return ok?code:-1; +} +API int dh_compute(DH *dh,const unsigned char *key,int length,unsigned char *out){ + BIGNUM *bn=BN_bin2bn(key,length,NULL);if(!bn)return -1;int size=DH_compute_key_padded(out,bn,dh);BN_free(bn);return size; +} +API int dh_valid(DH *dh){return BN_is_odd(DH_get0_p(dh)) && BN_ucmp(DH_get0_g(dh),DH_get0_p(dh))<0;} + +API int dh_group_check(DH *dh){return BN_mod_word(DH_get0_p(dh),24)==11?0:DH_NOT_SUITABLE_GENERATOR;} diff --git a/src/compat/crypto/native-cipher.ts b/src/compat/crypto/native-cipher.ts new file mode 100644 index 0000000..4ebe26f --- /dev/null +++ b/src/compat/crypto/native-cipher.ts @@ -0,0 +1,199 @@ +import binary from "./native-cipher.wasm"; +type API = Record any> & { + memory: WebAssembly.Memory; +}; +let instance: API | undefined; +export function library(): API { + if (instance) return instance; + let api: API; + api = new WebAssembly.Instance( + new WebAssembly.Module(binary as BufferSource), + { + env: { emscripten_notify_memory_growth() {} }, + wasi_snapshot_preview1: { + random_get(pointer: number, length: number) { + for (let offset = 0; offset < length; offset += 65536) + globalThis.crypto.getRandomValues( + new Uint8Array( + api.memory.buffer, + pointer + offset, + Math.min(65536, length - offset), + ), + ); + return 0; + }, + fd_close() { + return 8; + }, + fd_write() { + return 8; + }, + fd_seek() { + return 8; + }, + }, + }, + ).exports as API; + api._initialize(); + instance = api; + return api; +} +export function inputs( + values: (string | Uint8Array | number)[], + operation: (api: API, pointers: number[]) => T, +): T { + const api = library(), + allocated: { pointer: number; length: number }[] = []; + try { + const pointers = values.map((value) => { + const data = + typeof value === "string" + ? new TextEncoder().encode(value + "\0") + : typeof value === "number" + ? undefined + : value; + const length = typeof value === "number" ? value : data!.byteLength; + const pointer = api.malloc(Math.max(1, length)); + if (!pointer) throw new RangeError("Memory limit exceeded"); + allocated.push({ pointer, length }); + const memory = new Uint8Array(api.memory.buffer, pointer, length); + if (data) memory.set(data); + else memory.fill(0); + return pointer; + }); + return operation(api, pointers); + } finally { + for (const { pointer, length } of allocated) { + new Uint8Array(api.memory.buffer, pointer, length).fill(0); + api.free(pointer); + } + } +} +export function output(api: API, pointer: number, length: number): Uint8Array { + return new Uint8Array(api.memory.buffer, pointer, length).slice(); +} +export function size(api: API, pointer: number): number { + return new DataView(api.memory.buffer).getUint32(pointer, true); +} + +export function validateEcPrivate(name: string, value: Uint8Array): void { + inputs([name, value], (api, [curve, data]) => { + if (!api.cipher_ec_private_valid(curve, data, value.byteLength)) + throw new Error("EW JWK invalid private key"); + }); +} + +export function decodeKey( + data: Uint8Array, + pem: boolean, + privateKey: boolean, + type: string | undefined, + password?: Uint8Array, +): { data: Uint8Array; privateKey: boolean } { + return inputs([data, password ?? 0], (api, [p, pass]) => { + try { + const result = api.cipher_key_parse( + p, + data.byteLength, + +pem, + +privateKey, + type === undefined + ? -1 + : ["pkcs8", "pkcs1", "sec1", "spki"].indexOf(type), + pass, + password?.byteLength ?? 0, + +(password !== undefined), + ); + if (!result) + throw new Error( + `Failed to parse ${privateKey ? "private" : "public"} key`, + ); + return { + data: output(api, api.cipher_key_data(), api.cipher_key_der_size()), + privateKey: result === 2, + }; + } finally { + api.cipher_key_clear(); + } + }); +} + +// Match Workers' curve lookup before handing generation to the owned key backend. +export function curveName(name: string): string { + return inputs([name], (api, [p]) => { + const pointer = api.cipher_curve_name(p); + if (pointer === 0) throw new Error("Invalid or unsupported curve"); + if (pointer === -1) throw new Error("Failed to initialize key"); + const memory = new Uint8Array(api.memory.buffer); + let end = pointer; + while (memory[end]) end++; + return new TextDecoder().decode(memory.subarray(pointer, end)); + }); +} + +export function generateDh(prime: Uint8Array, generator: number): Uint8Array { + return inputs([prime, prime.length * 2], (api, [p, out]) => { + if (!api.cipher_dh_generate(p, prime.length, generator, out)) + throw new Error("Failed to generate key"); + return output(api, out, prime.length * 2); + }); +} +export function deriveDh( + prime: Uint8Array, + generator: Uint8Array, + privateKey: Uint8Array, + publicKey: Uint8Array, +): Uint8Array { + return inputs( + [prime, generator, privateKey, publicKey, prime.length], + (api, [p, g, x, y, out]) => { + const length = api.cipher_dh_derive( + p, + prime.length, + g, + generator.length, + x, + privateKey.length, + y, + publicKey.length, + out, + ); + if (length < 0) + throw new Error("Failed to derive shared diffie-hellman secret"); + return output(api, out, length); + }, + ); +} + +export function encodeKey( + data: Uint8Array, + privateKey: boolean, + pem: boolean, + type: string, + cipher?: string, + password?: Uint8Array, + ecEncoding = -1, +): Uint8Array { + return inputs([data, cipher ?? 0, password ?? 0], (api, [p, c, pass]) => { + try { + const result = api.cipher_key_export( + p, + data.byteLength, + +privateKey, + +pem, + type === "pkcs8" ? 0 : type === "pkcs1" ? 1 : type === "sec1" ? 2 : 3, + c, + +(cipher !== undefined), + pass, + password?.byteLength ?? 0, + +(password !== undefined), + ecEncoding, + ); + if (result === -1) throw new Error("Unknown cipher: " + cipher); + if (result !== 1) throw new Error("Failed to export key"); + return output(api, api.cipher_key_data(), api.cipher_key_der_size()); + } finally { + api.cipher_key_clear(); + } + }); +} diff --git a/src/compat/crypto/native-cipher.wasm b/src/compat/crypto/native-cipher.wasm new file mode 100755 index 0000000..200dfd0 Binary files /dev/null and b/src/compat/crypto/native-cipher.wasm differ diff --git a/src/compat/crypto/native-crypto.build.json b/src/compat/crypto/native-crypto.build.json index fdd5bb6..89e21a2 100644 --- a/src/compat/crypto/native-crypto.build.json +++ b/src/compat/crypto/native-crypto.build.json @@ -35,6 +35,6 @@ "-sEXPORTED_FUNCTIONS=[\"_malloc\",\"_free\"]" ], "libraries": ["libssl.a", "libcrypto.a"], - "bindingSha256": "819bf16122da0df3c5ce7b6158b23700b8d42778fcb0e14f3a9e054d5f68831f", - "sha256": "b25197493fce8f3b40615654f877b3dec064d90e3cd1a34dc86695dd3bb68c50" + "bindingSha256": "447a0899621d4d00c35ee5daf42986ad81e7e6a23e628124ea263ede9ac45748", + "sha256": "edf8ec66a9dbce9f7823233af55ed2442772905cc7941d9c6f1f9c9a996da959" } diff --git a/src/compat/crypto/native-crypto.c b/src/compat/crypto/native-crypto.c index fd193da..74bbb36 100644 --- a/src/compat/crypto/native-crypto.c +++ b/src/compat/crypto/native-crypto.c @@ -1,9 +1,11 @@ // OpenSSL bridge owned by cf-workers-native (MIT). #include #include +#include #include #include #include +#include #include #include #include @@ -53,7 +55,7 @@ API void wk_key_free(EVP_PKEY *key) { EVP_PKEY_free(key); } API const char *wk_key_type(EVP_PKEY *key) { return EVP_PKEY_get0_type_name(key); } API int wk_key_bits(EVP_PKEY *key) { return EVP_PKEY_get_bits(key); } API int wk_key_equal(EVP_PKEY *a,EVP_PKEY *b) { return EVP_PKEY_eq(a,b); } -API int wk_key_export(EVP_PKEY *key,int private_key,int pem,int type,const char *cipher_name,const char *password,int password_length) { +API int wk_key_export(EVP_PKEY *key,int private_key,int pem,int type,const char *cipher_name,const char *password,int password_length,int ec_encoding) { ERR_clear_error();BIO *bio=BIO_new(BIO_s_mem());if(!bio)return 0; const EVP_CIPHER *cipher=cipher_name && *cipher_name?EVP_get_cipherbyname(cipher_name):NULL; if(cipher_name && *cipher_name && !cipher){BIO_free(bio);return 0;} @@ -61,7 +63,7 @@ API int wk_key_export(EVP_PKEY *key,int private_key,int pem,int type,const char if(private_key) { if(type==0) ok=pem?PEM_write_bio_PKCS8PrivateKey(bio,key,cipher,(char *)password,password_length,NULL,NULL):i2d_PKCS8PrivateKey_bio(bio,key,cipher,(char *)password,password_length,NULL,NULL); else if(type==1) {RSA *rsa=EVP_PKEY_get1_RSA(key);if(rsa){ok=pem?PEM_write_bio_RSAPrivateKey(bio,rsa,cipher,(const unsigned char *)password,password_length,NULL,NULL):i2d_RSAPrivateKey_bio(bio,rsa);RSA_free(rsa);}} - else if(type==2) {EC_KEY *ec=EVP_PKEY_get1_EC_KEY(key);if(ec){ok=pem?PEM_write_bio_ECPrivateKey(bio,ec,cipher,(const unsigned char *)password,password_length,NULL,NULL):i2d_ECPrivateKey_bio(bio,ec);EC_KEY_free(ec);}} + else if(type==2) {EC_KEY *ec=EVP_PKEY_get1_EC_KEY(key);if(ec){if(ec_encoding>=0)EC_KEY_set_enc_flags(ec,ec_encoding);ok=pem?PEM_write_bio_ECPrivateKey(bio,ec,cipher,(const unsigned char *)password,password_length,NULL,NULL):i2d_ECPrivateKey_bio(bio,ec);EC_KEY_free(ec);}} } else { if(type==1) {RSA *rsa=EVP_PKEY_get1_RSA(key);if(rsa){ok=pem?PEM_write_bio_RSAPublicKey(bio,rsa):i2d_RSAPublicKey_bio(bio,rsa);RSA_free(rsa);}} else ok=pem?PEM_write_bio_PUBKEY(bio,key):i2d_PUBKEY_bio(bio,key); @@ -77,6 +79,14 @@ API EVP_PKEY *wk_key_generate(const char *type,int bits,const char *curve,unsign if(ok)ok=EVP_PKEY_generate(ctx,&key)>0; EVP_PKEY_CTX_free(ctx);if(!ok){EVP_PKEY_free(key);return NULL;}return key; } +API EVP_PKEY *wk_key_generate_dh(const unsigned char *prime,int length,unsigned long generator,const unsigned char *components) { + ERR_clear_error();BIGNUM *p=BN_bin2bn(prime,length,NULL),*g=BN_new(),*pub=BN_bin2bn(components,length,NULL),*priv=BN_bin2bn(components+length,length,NULL); + DH *dh=DH_new();EVP_PKEY *key=NULL; + if(!p || !g || !pub || !priv || !dh || !BN_set_word(g,generator) || !DH_set0_pqg(dh,p,NULL,g))goto done; + p=NULL;g=NULL;if(!DH_set0_key(dh,pub,priv))goto done;pub=NULL;priv=NULL; + key=EVP_PKEY_new();if(!key || !EVP_PKEY_assign_DH(key,dh)){EVP_PKEY_free(key);key=NULL;goto done;}dh=NULL; + done: DH_free(dh);BN_free(p);BN_free(g);BN_free(pub);BN_clear_free(priv);return key; +} static int signature_width(EVP_PKEY *key) { if(EVP_PKEY_is_a(key,"EC"))return (EVP_PKEY_get_bits(key)+7)/8; if(EVP_PKEY_is_a(key,"DSA")){BIGNUM *q=NULL;if(!EVP_PKEY_get_bn_param(key,OSSL_PKEY_PARAM_FFC_Q,&q))return 0;int size=BN_num_bytes(q);BN_free(q);return size;} @@ -112,7 +122,7 @@ API int wk_verify(EVP_PKEY *key,const char *digest,const unsigned char *data,siz int length=i2d_ECDSA_SIG(value,&converted);ECDSA_SIG_free(value);if(length<=0){EVP_MD_CTX_free(ctx);return -1;} signature=converted;signature_length=(size_t)length; } - int result=ok?EVP_DigestVerify(ctx,signature,signature_length,data,length):-1;OPENSSL_free(converted);EVP_MD_CTX_free(ctx);return result; + int result=ok?(EVP_DigestVerify(ctx,signature,signature_length,data,length)==1?1:0):-1;OPENSSL_free(converted);EVP_MD_CTX_free(ctx);return result; } API int wk_derive(EVP_PKEY *key,EVP_PKEY *peer) { @@ -145,15 +155,31 @@ API int wk_prime_generate(int bits,int safe,const unsigned char *add,int add_len BN_clear_free(a);BN_clear_free(r);BN_clear_free(prime);BN_CTX_free(ctx);return ok; } API EVP_MD_CTX *wk_digest_new(const char *algorithm) { - ERR_clear_error();const EVP_MD *md=EVP_get_digestbyname(algorithm);EVP_MD_CTX *ctx=EVP_MD_CTX_new(); + ERR_clear_error();const EVP_MD *md=EVP_get_digestbyname(algorithm); + if(md && EVP_MD_type(md)==NID_md4){static int legacy_initialized=0;if(!legacy_initialized){OSSL_PROVIDER_load(NULL,"default");OSSL_PROVIDER_load(NULL,"legacy");legacy_initialized=1;}} + EVP_MD_CTX *ctx=EVP_MD_CTX_new(); if(!md || !ctx || EVP_DigestInit_ex(ctx,md,NULL)!=1){EVP_MD_CTX_free(ctx);return NULL;}return ctx; } +API EVP_MD_CTX *wk_digest_copy(EVP_MD_CTX *source) {EVP_MD_CTX *ctx=EVP_MD_CTX_new();if(!ctx || EVP_MD_CTX_copy_ex(ctx,source)!=1){EVP_MD_CTX_free(ctx);return NULL;}return ctx;} +API int wk_digest_size(EVP_MD_CTX *ctx) {return EVP_MD_CTX_get_size(ctx);} API void wk_digest_free(EVP_MD_CTX *ctx) {EVP_MD_CTX_free(ctx);} API int wk_digest_update(EVP_MD_CTX *ctx,const unsigned char *data,size_t length) {return EVP_DigestUpdate(ctx,data,length);} API int wk_digest_final(EVP_MD_CTX *ctx) { unsigned int length=EVP_MAX_MD_SIZE;clear_output();output=OPENSSL_malloc(length);if(!output)return 0; output_length=length;if(EVP_DigestFinal_ex(ctx,output,&length)!=1){clear_output();return 0;}output_length=length;return 1; } +API HMAC_CTX *wk_hmac_new(const char *algorithm,const unsigned char *key,int length) { + EVP_MD_CTX *check=wk_digest_new(algorithm);if(!check)return NULL;EVP_MD_CTX_free(check); + HMAC_CTX *ctx=HMAC_CTX_new();if(!ctx||HMAC_Init_ex(ctx,key,length,EVP_get_digestbyname(algorithm),NULL)!=1){HMAC_CTX_free(ctx);return NULL;}return ctx; +} +API void wk_hmac_free(HMAC_CTX *ctx){HMAC_CTX_free(ctx);} +API int wk_hmac_update(HMAC_CTX *ctx,const unsigned char *data,size_t length){return HMAC_Update(ctx,data,length);} +API int wk_hmac_final(HMAC_CTX *ctx){unsigned int length=EVP_MAX_MD_SIZE;clear_output();output=OPENSSL_malloc(length);if(!output)return 0;output_length=length;if(HMAC_Final(ctx,output,&length)!=1){clear_output();return 0;}output_length=length;return 1;} +API int wk_pbkdf2(const unsigned char *password,int password_length,const unsigned char *salt,int salt_length,int iterations,int length,const char *algorithm){ + EVP_MD_CTX *check=wk_digest_new(algorithm);if(!check)return 0;EVP_MD_CTX_free(check); + clear_output();output=OPENSSL_malloc(length?length:1);if(!output)return 0;output_length=length; + if(!PKCS5_PBKDF2_HMAC((const char*)password,password_length,salt,salt_length,iterations,EVP_get_digestbyname(algorithm),length,output)){clear_output();return 0;}return 1; +} API int wk_sign_hash(EVP_PKEY *key,const char *digest,const unsigned char *data,size_t length,int padding,int saltlen) { ERR_clear_error();EVP_PKEY_CTX *ctx=EVP_PKEY_CTX_new(key,NULL);if(!ctx)return 0; int ok=EVP_PKEY_sign_init(ctx)>0 && EVP_PKEY_CTX_set_signature_md(ctx,EVP_get_digestbyname(digest))>0; @@ -167,7 +193,7 @@ API int wk_verify_hash(EVP_PKEY *key,const char *digest,const unsigned char *dat int ok=EVP_PKEY_verify_init(ctx)>0 && EVP_PKEY_CTX_set_signature_md(ctx,EVP_get_digestbyname(digest))>0; if(ok && padding)ok=EVP_PKEY_CTX_set_rsa_padding(ctx,padding)>0; if(ok && padding==RSA_PKCS1_PSS_PADDING)ok=EVP_PKEY_CTX_set_rsa_pss_saltlen(ctx,saltlen)>0; - int result=ok?EVP_PKEY_verify(ctx,signature,signature_length,data,length):-1;EVP_PKEY_CTX_free(ctx);return result; + int result=ok?(EVP_PKEY_verify(ctx,signature,signature_length,data,length)==1?1:0):-1;EVP_PKEY_CTX_free(ctx);return result; } API EC_KEY *wk_ec_new(const char *curve) {return EC_KEY_new_by_curve_name(OBJ_sn2nid(curve));} API void wk_ec_free(EC_KEY *key) {EC_KEY_free(key);} @@ -201,7 +227,14 @@ API int wk_rsa(EVP_PKEY *key,int operation,const unsigned char *data,size_t leng int status=-1;if(initialized<=0)goto done; const EVP_MD *digest=NULL; if(operation<2){digest=EVP_get_digestbyname(hash);if(!digest){status=-2;goto done;}} + /* Match BoringSSL's accepted padding set and operation restrictions. */ + if((padding!=RSA_PKCS1_PADDING&&padding!=RSA_NO_PADDING&&padding!=RSA_PKCS1_OAEP_PADDING&&padding!=RSA_PKCS1_PSS_PADDING)|| + (padding==RSA_PKCS1_PSS_PADDING&&operation!=2)|| + (padding==RSA_PKCS1_OAEP_PADDING&&operation>=2)){status=-3;goto done;} if(EVP_PKEY_CTX_set_rsa_padding(ctx,padding)<=0){status=-3;goto done;} + /* Workers rejects malformed PKCS#1 ciphertext rather than returning OpenSSL's + implicit-rejection substitute plaintext. Keep successful decoding unchanged. */ + if(operation==1&&padding==RSA_PKCS1_PADDING&&EVP_PKEY_CTX_ctrl_str(ctx,"rsa_pkcs1_implicit_rejection","0")<=0){status=-3;goto done;} if(digest && padding==RSA_PKCS1_OAEP_PADDING){ if(EVP_PKEY_CTX_set_rsa_oaep_md(ctx,digest)!=1){status=-4;goto done;} if(EVP_PKEY_CTX_set_rsa_mgf1_md(ctx,digest)!=1){status=-5;goto done;} diff --git a/src/compat/crypto/native-crypto.ts b/src/compat/crypto/native-crypto.ts index ce2beb3..1b0a09f 100644 --- a/src/compat/crypto/native-crypto.ts +++ b/src/compat/crypto/native-crypto.ts @@ -1,4 +1,5 @@ import binary from "./native-crypto.wasm"; +import { aliases } from "./digests.js"; type API = Record any> & { memory: WebAssembly.Memory; }; @@ -123,6 +124,7 @@ const released = new FinalizationRegistry((pointer) => library().wk_key_free(pointer), ); export class NativeKey { + ecEncoding = -1; checkCertificate(cert: number, operation: number) { return library().wk_x509_compare(cert, this.handle(), operation) === 1; } @@ -133,7 +135,9 @@ export class NativeKey { return inputs( [ data, - options.oaepHash, + operation < 2 + ? (aliases[options.oaepHash.split("\0")[0].toLowerCase()] ?? "") + : options.oaepHash, label instanceof ArrayBuffer ? new Uint8Array(label) : label, ], (api, [p, h, l]) => { @@ -206,6 +210,17 @@ export class NativeKey { ), ); } + static generateDh( + prime: Uint8Array, + generator: number, + components: Uint8Array, + ) { + return inputs([prime, components], (api, [p, c]) => { + const pointer = api.wk_key_generate_dh(p, prime.byteLength, generator, c); + if (!pointer) throw new Error("Failed to generate key"); + return new NativeKey(pointer); + }); + } static generate(type: string, bits = 0, curve?: string, exponent = 65537) { return inputs( [type, curve], @@ -238,6 +253,7 @@ export class NativeKey { c, p, password?.length ?? 0, + this.ecEncoding, ) ) throw error(); @@ -389,11 +405,38 @@ export class NativeDigest { constructor( algorithm: string, private context = "Signing", + pointer?: number, ) { - this.pointer = inputs([algorithm], (api, [p]) => api.wk_digest_new(p)); + this.pointer = + pointer ?? inputs([algorithm], (api, [p]) => api.wk_digest_new(p)); if (!this.pointer) throw new Error(`Unknown digest: ${algorithm}`); digestReleased.register(this, this.pointer, this); } + dispose() { + if (this.pointer) { + library().wk_digest_free(this.pointer); + this.pointer = 0; + digestReleased.unregister(this); + } + } + get size() { + return library().wk_digest_size(this.pointer); + } + copy() { + if (!this.pointer) + throw new DOMException( + "Hash context has already been finalized.", + "OperationError", + ); + const pointer = library().wk_digest_copy(this.pointer); + if (!pointer) throw error(); + return new NativeDigest("", this.context, pointer); + } + + assertActive() { + if (!this.pointer) + throw new Error(`${this.context} context has already been finalized`); + } update(data: Uint8Array) { if (!this.pointer) throw new Error(`${this.context} context has already been finalized`); @@ -422,6 +465,7 @@ const ecReleased = new FinalizationRegistry((pointer) => export class NativeECDH { private pointer: number; constructor(curve: string) { + curve = curve.split("\0")[0]!; if (!["secp224r1", "prime256v1", "secp384r1", "secp521r1"].includes(curve)) throw new Error("Invalid curve"); this.pointer = inputs([curve], (api, [p]) => api.wk_ec_new(p)); @@ -613,3 +657,58 @@ export class NativeTLS { return certs; } } + +const hmacReleased = new FinalizationRegistry((pointer) => + library().wk_hmac_free(pointer), +); +export class NativeHmac { + private pointer: number; + constructor(algorithm: string, key: Uint8Array) { + this.pointer = inputs([algorithm, key], (api, [a, k]) => + api.wk_hmac_new(a, k, key.byteLength), + ); + if (!this.pointer) throw new Error("Failed to initalize HMAC"); + hmacReleased.register(this, this.pointer, this); + } + update(data: Uint8Array) { + inputs([data], (api, [p]) => { + if (!api.wk_hmac_update(this.pointer, p, data.byteLength)) throw error(); + }); + } + final() { + const api = library(); + try { + if (!api.wk_hmac_final(this.pointer)) + throw new Error("Failed to finalize HMAC"); + return result(api); + } finally { + api.wk_hmac_free(this.pointer); + this.pointer = 0; + hmacReleased.unregister(this); + } + } +} + +export function nativePbkdf2( + password: Uint8Array, + salt: Uint8Array, + iterations: number, + length: number, + digest: string, +) { + return inputs([password, salt, digest], (api, [p, s, d]) => { + if ( + !api.wk_pbkdf2( + p, + password.byteLength, + s, + salt.byteLength, + iterations, + length, + d, + ) + ) + throw new Error("Pbkdf2 failed"); + return result(api); + }); +} diff --git a/src/compat/crypto/native-crypto.wasm b/src/compat/crypto/native-crypto.wasm index 3bb0cca..fef1d17 100755 Binary files a/src/compat/crypto/native-crypto.wasm and b/src/compat/crypto/native-crypto.wasm differ diff --git a/src/compat/crypto/pbkdf2-handle.ts b/src/compat/crypto/pbkdf2-handle.ts new file mode 100644 index 0000000..e3e3ac5 --- /dev/null +++ b/src/compat/crypto/pbkdf2-handle.ts @@ -0,0 +1,47 @@ +import { inputBytes, validateCryptoLength } from "./validation.js"; +import { nativePbkdf2 } from "./native-crypto.js"; +import { aliases } from "./hash-handle.js"; +const sizes: Record = { + md4: 16, + md5: 16, + sha1: 20, + sha224: 28, + sha256: 32, + sha384: 48, + sha512: 64, + "md5-sha1": 36, +}; +export default { + getPbkdf( + password: ArrayBuffer | ArrayBufferView, + salt: ArrayBuffer | ArrayBufferView, + iterations: number, + length: number, + digest: string, + ) { + const passwordBytes = inputBytes(password, "getPbkdf", 1), + saltBytes = inputBytes(salt, "getPbkdf", 2); + const normalized = aliases[digest.split("\0")[0]!.toLowerCase()]; + if (!normalized) + throw new TypeError("Invalid Pbkdf2 digest: " + digest + "."); + validateCryptoLength( + passwordBytes.byteLength, + "Pbkdf2 failed: password is too large", + ); + validateCryptoLength( + saltBytes.byteLength, + "Pbkdf2 failed: salt is too large", + ); + if (length > 255 * sizes[normalized]) + throw new RangeError( + "Pbkdf2 failed: derived key length exceeds maximum for this hash", + ); + return nativePbkdf2( + passwordBytes, + saltBytes, + iterations, + length, + normalized, + ); + }, +}; diff --git a/src/compat/crypto/pbkdf2.ts b/src/compat/crypto/pbkdf2.ts new file mode 100644 index 0000000..a2e0c22 --- /dev/null +++ b/src/compat/crypto/pbkdf2.ts @@ -0,0 +1,127 @@ +// @ts-nocheck +// Copyright (c) 2017-2023 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +import cryptoImpl from "./pbkdf2-handle.js"; + +import { Buffer } from "node:buffer"; + +import { + validateInt32, + validateFunction, + validateString, +} from "../net/validators.js"; + +import { arrayBufferOrView as getArrayBufferOrView } from "./validation.js"; + +export type ArrayLike = + | string + | ArrayBuffer + | SharedArrayBuffer + | ArrayBufferView; + +export function pbkdf2Sync( + password: ArrayLike, + salt: ArrayLike, + iterations: number, + keylen: number, + digest: string, +): Buffer { + ({ password, salt, iterations, keylen, digest } = check( + password, + salt, + iterations, + keylen, + digest, + )); + + const result = cryptoImpl.getPbkdf( + password, + salt, + iterations, + keylen, + digest, + ); + return Buffer.from(result); +} + +export type Pbkdf2Callback = (err?: Error | null, result?: Buffer) => void; +export function pbkdf2( + password: ArrayLike, + salt: ArrayLike, + iterations: number, + keylen: number, + digest: string, + callback: Pbkdf2Callback, +): void { + if (typeof digest === "function") { + // Appease node test cases + validateString(undefined, "digest"); + } + validateFunction(callback, "callback"); + ({ password, salt, iterations, keylen, digest } = check( + password, + salt, + iterations, + keylen, + digest, + )); + + new Promise((res, rej) => { + try { + res(cryptoImpl.getPbkdf(password, salt, iterations, keylen, digest)); + } catch (err) { + rej(err as Error); + } + }).then( + (val: ArrayBuffer): unknown => callback(null, Buffer.from(val)), + (err: unknown): unknown => callback(err as Error), + ); +} + +function check( + password: ArrayLike | ArrayBufferView, + salt: ArrayLike | ArrayBufferView, + iterations: number, + keylen: number, + digest: string, +): { + password: ArrayLike | ArrayBufferView; + salt: ArrayLike | ArrayBufferView; + iterations: number; + keylen: number; + digest: string; +} { + validateString(digest, "digest"); + + password = getArrayBufferOrView(password, "password"); + salt = getArrayBufferOrView(salt, "salt"); + // OpenSSL uses a signed int to represent these values, so we are restricted + // to the 31-bit range here (which is plenty). + validateInt32(iterations, "iterations", 1); + validateInt32(keylen, "keylen", 0); + + return { password, salt, iterations, keylen, digest }; +} diff --git a/src/compat/crypto/prime.ts b/src/compat/crypto/prime.ts index d02bfaa..c35aa6a 100644 --- a/src/compat/crypto/prime.ts +++ b/src/compat/crypto/prime.ts @@ -1,4 +1,5 @@ // Adapted from pinned workerd crypto_random.ts (Apache-2.0 / Node MIT). +import { bufferBytes, viewBytes } from "../../shared/buffer-source.js"; import { Buffer } from "node:buffer"; import { validateObject, @@ -7,12 +8,11 @@ import { validateInt32, } from "../net/validators.js"; import { ERR_INVALID_ARG_TYPE, ERR_OUT_OF_RANGE } from "../net/errors.js"; -import { isAnyArrayBuffer, isArrayBufferView } from "../util/types/index.js"; +import { isAnyBuffer as isAnyArrayBuffer } from "./validation.js"; +const isArrayBufferView = ArrayBuffer.isView; import { nativePrimeCheck, nativePrimeGenerate } from "./native-crypto.js"; function view(value: any): Uint8Array { - return ArrayBuffer.isView(value) - ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) - : new Uint8Array(value); + return ArrayBuffer.isView(value) ? viewBytes(value) : bufferBytes(value); } function arrayBufferToUnsignedBigInt(value: any): bigint { const hex = Buffer.from(view(value)).toString("hex"); diff --git a/src/compat/crypto/random.spec.ts b/src/compat/crypto/random.spec.ts new file mode 100644 index 0000000..4d12304 --- /dev/null +++ b/src/compat/crypto/random.spec.ts @@ -0,0 +1,48 @@ +import { test, expect, expectTypeOf } from "vitest"; +// @ts-expect-error Pinned upstream JavaScript fixture. +import * as fixtures from "./upstream-random.fixture.mjs"; +for (const [name, fixture] of Object.entries(fixtures)) + test("pinned Workers random: " + name, () => + (fixture as { test: () => unknown }).test(), + ); + +import { Buffer } from "node:buffer"; +import crypto, { + randomBytes, + randomFill, + randomFillSync, + pseudoRandomBytes, +} from "./index.js"; +test("random API declarations retain result and input brands", () => { + expectTypeOf(randomBytes(0)).toEqualTypeOf(); + expect(randomBytes(0).toString("hex")).toBe(""); + expectTypeOf(randomFillSync(new Int32Array(0))).toEqualTypeOf< + Int32Array + >(); + expectTypeOf( + randomFillSync(new DataView(new ArrayBuffer(0))), + ).toEqualTypeOf(); + expectTypeOf(randomFillSync(new ArrayBuffer(0))).toEqualTypeOf(); + expectTypeOf( + randomFillSync(new SharedArrayBuffer(0)), + ).toEqualTypeOf(); + randomFill(new ArrayBuffer(0), (_error, buffer) => { + expectTypeOf(buffer).toEqualTypeOf(); + }); + randomFill(new Uint16Array(0), 0, 0, (_error, buffer) => { + expectTypeOf(buffer).toEqualTypeOf | undefined>(); + }); + expect(pseudoRandomBytes).toBe(randomBytes); + expect(crypto.pseudoRandomBytes).toBe(randomBytes); +}); + +// Declaration-only invalid calls must be rejected without executing them. +function invalidRandomFillCalls() { + // @ts-expect-error A callback is required. + randomFill(new Uint8Array(0)); + // @ts-expect-error A callback is required after offset. + randomFill(new Uint8Array(0), 0); + // @ts-expect-error A callback is required after size. + randomFill(new Uint8Array(0), 0, 0); +} +void invalidRandomFillCalls; diff --git a/src/compat/crypto/random.ts b/src/compat/crypto/random.ts index 4b8cfa1..adaef61 100644 --- a/src/compat/crypto/random.ts +++ b/src/compat/crypto/random.ts @@ -26,13 +26,163 @@ // Adapted from workerd@5358e3d337336eaf696a48bc1563d808b10feba5. import { Buffer } from "node:buffer"; import type { RandomUUIDOptions } from "node:crypto"; -import { validateInteger } from "./validation.js"; -import { validateBoolean, validateObject } from "../net/validators.js"; -import { ERR_OUT_OF_RANGE } from "../net/errors.js"; -// Cache size stays below the Web Crypto per-call limit. -function randomFillSync(buffer: Uint8Array) { - return globalThis.crypto.getRandomValues(buffer as Uint8Array); +import { + validateInteger, + isAnyBuffer as isAnyArrayBuffer, +} from "./validation.js"; +import { + validateBoolean, + validateObject, + validateFunction, +} from "../net/validators.js"; +const isArrayBufferView = ArrayBuffer.isView; +const dataViewBuffer = Object.getOwnPropertyDescriptor( + DataView.prototype, + "buffer", +)!.get!; +function isDataView(value: unknown): value is DataView { + try { + dataViewBuffer.call(value); + return true; + } catch { + return false; + } +} +import { kMaxLength } from "../buffer/internal-buffer.js"; +import { ERR_INVALID_ARG_TYPE, ERR_OUT_OF_RANGE } from "../net/errors.js"; +export type RandomBytesCallback = (err: Error | null, buffer: Buffer) => void; +export function randomBytes(size: number, callback: RandomBytesCallback): void; +export function randomBytes(size: number): Buffer; +export function randomBytes( + size: number, + callback?: RandomBytesCallback, +): Buffer | undefined { + validateInteger(size, "size", 0, kMaxLength); + const buf = Buffer.alloc(size); + if (callback !== undefined) { + randomFill(buf, callback as RandomFillCallback); + return undefined; + } else { + randomFillSync(buf); + return buf; + } +} + +export type RandomFillBuffer = + | NodeJS.ArrayBufferView + | ArrayBuffer + | SharedArrayBuffer; +export type RandomFillResult = T extends + | ArrayBuffer + | SharedArrayBuffer + ? Buffer + : T extends DataView + ? Uint8Array + : T; +export function randomFillSync( + buffer: T, + offset?: number, + size?: number, +): RandomFillResult; +export function randomFillSync( + buffer: RandomFillBuffer, + offset?: number, + size?: number, +): NodeJS.TypedArray { + if (!isAnyArrayBuffer(buffer) && !isArrayBufferView(buffer)) { + throw new ERR_INVALID_ARG_TYPE( + "buffer", + ["TypedArray", "DataView", "ArrayBuffer", "SharedArrayBuffer"], + buffer, + ); + } + // Use byteLength, not length — DataView has no .length property and + // TypedArray .length is element count, not bytes. + const maxLength = (buffer as Uint8Array).byteLength; + if (offset !== undefined) { + validateInteger(offset, "offset", 0, kMaxLength); + } else offset = 0; + if (size !== undefined) { + validateInteger(size, "size", 0, maxLength - offset); + } else size = maxLength - offset; + if (isAnyArrayBuffer(buffer)) { + buffer = Buffer.from(buffer as unknown as ArrayBuffer); + } else if (isDataView(buffer)) { + buffer = new Uint8Array( + (buffer as DataView).buffer, + (buffer as DataView).byteOffset, + buffer.byteLength, + ); + } + buffer = (buffer as Buffer).subarray(offset, offset + size); + return crypto.getRandomValues(buffer as Uint8Array); } + +export type RandomFillCallback = + (err: Error | null, buf?: T) => void; +export function randomFill( + buffer: T, + callback: RandomFillCallback, +): void; +export function randomFill( + buffer: T, + offset: number, + callback: RandomFillCallback, +): void; +export function randomFill( + buffer: T, + offset: number, + size: number, + callback: RandomFillCallback, +): void; +export function randomFill( + buffer: T, + offsetOrCallback?: number | RandomFillCallback, + sizeOrCallback?: number | RandomFillCallback, + callback?: RandomFillCallback, +): void { + if (!isAnyArrayBuffer(buffer) && !isArrayBufferView(buffer)) { + throw new ERR_INVALID_ARG_TYPE( + "buffer", + ["TypedArray", "DataView", "ArrayBuffer", "SharedArrayBuffer"], + buffer, + ); + } + + let offset = 0; + let size = 0; + // Use byteLength, not length — DataView has no .length property and + // TypedArray .length is element count, not bytes. + const maxLength = (buffer as Uint8Array).byteLength; + if (typeof callback === "function") { + validateInteger(offsetOrCallback, "offset", 0, maxLength); + offset = offsetOrCallback; + + validateInteger(sizeOrCallback, "size", 0, maxLength - offset); + size = sizeOrCallback; + } else if (typeof sizeOrCallback === "function") { + validateInteger(offsetOrCallback, "offset", 0, maxLength); + offset = offsetOrCallback; + size = maxLength - offset; + callback = sizeOrCallback; + } else if (typeof offsetOrCallback === "function") { + offset = 0; + size = maxLength; + callback = offsetOrCallback; + } + validateFunction(callback, "callback"); + + // We're currently not actually implementing the fill itself asynchronously, + // so we defer to randomFillSync here, but we invoke the callback asynchronously. + new Promise((res) => { + randomFillSync(buffer, offset, size); + res(); + }).then( + (): unknown => callback(null, buffer), + (err: unknown): unknown => callback(err as Error), + ); +} + const RAND_MAX = 0xffff_ffff_ffff; // Cache random data to use in randomInt. The cache size must be evenly // divisible by 6 because each attempt to obtain a random int uses 6 bytes. diff --git a/src/compat/crypto/rsa.ts b/src/compat/crypto/rsa.ts index 4c24403..b1f2f32 100644 --- a/src/compat/crypto/rsa.ts +++ b/src/compat/crypto/rsa.ts @@ -4,10 +4,36 @@ import { Buffer } from "node:buffer"; import { validateNumber, validateString } from "../net/validators.js"; import { ERR_INVALID_ARG_TYPE, ERR_MISSING_ARGS } from "../net/errors.js"; import { ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE } from "./sign-errors.js"; -import { isAnyArrayBuffer, isArrayBufferView } from "../util/types/index.js"; -import { isKeyObject, getKeyObjectHandle } from "./keys.js"; +import { isAnyBuffer as isAnyArrayBuffer, bytes } from "./validation.js"; +const isArrayBufferView = ArrayBuffer.isView; +import { KeyObject, isKeyObject, getKeyObjectHandle } from "./keys.js"; import { createPrivateKey, createPublicKey } from "./asymmetric.js"; import { keyStates } from "./key-state.js"; +export type KeyData = + | string + | ArrayBuffer + | SharedArrayBuffer + | ArrayBufferView + | KeyObject + | CryptoKey; +interface HashOptions { + padding?: number; + oaepHash?: string; + oaepLabel?: string | ArrayBuffer | SharedArrayBuffer | ArrayBufferView; + encoding?: BufferEncoding; +} +export interface RsaKeyOptions extends HashOptions { + key: KeyData; + format?: "pem" | "der" | "jwk"; + type?: "pkcs1" | "pkcs8" | "sec1" | "spki"; + passphrase?: string | ArrayBuffer | ArrayBufferView; +} +type CreateAsymmetricKeyOptions = RsaKeyOptions; +interface PublicPrivateCipherOptions { + padding: number; + oaepHash: string; + oaepLabel?: ArrayBuffer | ArrayBufferView; +} const kRsaPkcs1Padding = 1; function operate(operation, key, data, options) { const handle = keyStates.get(key)?.asymmetric; @@ -18,8 +44,13 @@ function operate(operation, key, data, options) { ), { code: "ERR_METHOD_NOT_IMPLEMENTED" }, ); - if (data instanceof ArrayBuffer) data = new Uint8Array(data); - return handle.rsa(operation, data, options); + return handle.rsa(operation, bytes(data, "buffer"), { + ...options, + oaepLabel: + options.oaepLabel === undefined + ? undefined + : bytes(options.oaepLabel, "oaepLabel"), + }); } const cryptoImpl = { publicEncrypt: (...args) => operate(0, ...args), @@ -57,7 +88,7 @@ function getPaddingAndHash(options: HashOptions): PublicPrivateCipherOptions { } export function privateEncrypt( - privateKey: CreateAsymmetricKeyOptions | KeyObject | CryptoKey | undefined, + privateKey: CreateAsymmetricKeyOptions | KeyData | undefined, buffer: string | ArrayBufferView | ArrayBuffer | undefined, ): Buffer { if (privateKey === undefined) { diff --git a/src/compat/crypto/scrypt.ts b/src/compat/crypto/scrypt.ts index 3a73fce..f94b888 100644 --- a/src/compat/crypto/scrypt.ts +++ b/src/compat/crypto/scrypt.ts @@ -28,37 +28,51 @@ import { syncScrypt } from "scrypt-js"; import { Buffer } from "node:buffer"; import { validateInteger, - bytes as getArrayBufferOrView, + arrayBufferOrView as getArrayBufferOrView, + inputBytes, } from "./validation.js"; import { validateFunction, validateUint32 } from "../net/validators.js"; import { ERR_INVALID_ARG_VALUE } from "../net/errors.js"; -type ArrayLike = any; +export type ArrayLike = ArrayBuffer | string | Buffer | ArrayBufferView; const kMaxLength = 2147483647; const cryptoImpl = { getScrypt( - password: Uint8Array, - salt: Uint8Array, + password: ArrayBuffer | ArrayBufferView, + salt: ArrayBuffer | ArrayBufferView, N: number, r: number, p: number, maxmem: number, length: number, ) { + const passwordBytes = inputBytes(password, "getScrypt", 1), + saltBytes = inputBytes(salt, "getScrypt", 2); + // JSG validates the uint32 binding argument before entering the backend. + if (maxmem > 0xffffffff) + throw new TypeError( + "Value out of range. Must be less than or equal to 4294967295.", + ); + // Pinned workerd IsolateLimitEnforcer::checkScryptCost (2^20). + if (N > 1048576 || r > 1048576 || p > 1048576 || N * r * p > 1048576) + throw new RangeError("Scrypt failed: cost exceeds maximum (1048576)."); if ( N < 2 || (N & (N - 1)) !== 0 || + N >= 2 ** (16 * r) || + r * p >= 2 ** 30 || r < 1 || p < 1 || 128 * N * r + 128 * r * p + 256 * r > maxmem ) { - throw new Error("Invalid scrypt params"); + throw new Error("Scrypt failed"); } - return new Uint8Array(syncScrypt(password, salt, N, r, p, length)).buffer; + return new Uint8Array(syncScrypt(passwordBytes, saltBytes, N, r, p, length)) + .buffer; }, }; export interface ValidatedScryptOptions { - password: ArrayLike; - salt: ArrayLike; + password: ArrayBuffer | ArrayBufferView; + salt: ArrayBuffer | ArrayBufferView; keylen: number; N: number; r: number; @@ -90,14 +104,14 @@ function validateParameters( options?: ScryptOptions, ): ValidatedScryptOptions { // TODO(soon): Add support for KeyObject input. - password = getArrayBufferOrView(password, "password"); - salt = getArrayBufferOrView(salt, "salt"); + const passwordBytes = getArrayBufferOrView(password, "password"); + const saltBytes = getArrayBufferOrView(salt, "salt"); - if (password.byteLength >= kMaxLength) { - throw new ERR_INVALID_ARG_VALUE("password", password, "is too big"); + if (passwordBytes.byteLength >= kMaxLength) { + throw new ERR_INVALID_ARG_VALUE("password", passwordBytes, "is too big"); } - if (salt.byteLength >= kMaxLength) { - throw new ERR_INVALID_ARG_VALUE("salt", salt, "is too big"); + if (saltBytes.byteLength >= kMaxLength) { + throw new ERR_INVALID_ARG_VALUE("salt", saltBytes, "is too big"); } validateInteger(keylen, "keylen", 0, kMaxLength); @@ -150,8 +164,8 @@ function validateParameters( } return { - password, - salt, + password: passwordBytes, + salt: saltBytes, keylen, N: N as number, r: r as number, @@ -175,22 +189,31 @@ export function scrypt( options = defaults; } - let N: number; - let r: number; - let p: number; - let maxmem: number; - ({ password, salt, keylen, N, r, p, maxmem } = validateParameters( - password, - salt, - keylen, - options as ScryptOptions, - )); + const { + password: passwordBytes, + salt: saltBytes, + keylen: outputLength, + N, + r, + p, + maxmem, + } = validateParameters(password, salt, keylen, options as ScryptOptions); validateFunction(callback, "callback"); new Promise((res, rej) => { try { - res(cryptoImpl.getScrypt(password, salt, N, r, p, maxmem, keylen)); + res( + cryptoImpl.getScrypt( + passwordBytes, + saltBytes, + N, + r, + p, + maxmem, + outputLength, + ), + ); } catch (err) { rej(err as Error); } @@ -210,18 +233,25 @@ export function scryptSync( keylen: number, options?: ScryptOptions, ): Buffer { - let N: number; - let r: number; - let p: number; - let maxmem: number; - ({ password, salt, keylen, N, r, p, maxmem } = validateParameters( - password, - salt, - keylen, - options, - )); + const { + password: passwordBytes, + salt: saltBytes, + keylen: outputLength, + N, + r, + p, + maxmem, + } = validateParameters(password, salt, keylen, options); return Buffer.from( - cryptoImpl.getScrypt(password, salt, N, r, p, maxmem, keylen), + cryptoImpl.getScrypt( + passwordBytes, + saltBytes, + N, + r, + p, + maxmem, + outputLength, + ), ); } diff --git a/src/compat/crypto/sign-handle.ts b/src/compat/crypto/sign-handle.ts index 5d1bbfe..2a535c2 100644 --- a/src/compat/crypto/sign-handle.ts +++ b/src/compat/crypto/sign-handle.ts @@ -1,3 +1,6 @@ +import { bufferBytes, viewBytes } from "../../shared/buffer-source.js"; +import { isAnyBuffer } from "./validation.js"; +import { signatureDigest } from "./digests.js"; import { Buffer } from "node:buffer"; import { NativeDigest } from "./native-crypto.js"; import { keyStates } from "./key-state.js"; @@ -47,63 +50,110 @@ function rawSignature(data: Uint8Array, width: number): Buffer { } return output; } -class SignHandle { +function rsaParameters( + type: string, + padding: number | undefined, + saltLength: number | undefined, + verifying: boolean, +) { + if (type !== "rsa" && type !== "rsa-pss") return; + const mode = padding ?? (type === "rsa-pss" ? 6 : 1); + if ( + ![1, 3, 6].includes(mode) || + (mode === 6 && saltLength !== undefined && saltLength < -2) + ) + throw new Error("Failed to set RSA parameters for signature"); + if (mode === 3) + throw new Error( + verifying + ? "Failed to set digest context for signature verification" + : "Failed to set signature digest", + ); +} +export class SignHandle { private hash: NativeDigest; constructor(private algorithm: string) { - this.hash = new NativeDigest(algorithm); + this.algorithm = signatureDigest(algorithm); + this.hash = new NativeDigest(this.algorithm); } update(data: Uint8Array) { - this.hash.update( - Buffer.from(data.buffer, data.byteOffset, data.byteLength), - ); + this.hash.update(viewBytes(data)); } sign(value: any, padding?: number, saltLength?: number, encoding?: number) { + this.hash.assertActive(); const native = key(value), + hash = this.hash.final(); + if (native.type === "dsa") + throw new Error("Signing with DSA keys is not currently supported"); + if (native.type === "ed25519") + throw new Error("Failed to set signature digest"); + if (native.type === "x25519") + throw new Error("Failed to initialize signing context"); + rsaParameters(native.type, padding, saltLength, false); + let signed: Uint8Array; + try { signed = native.signHash( this.algorithm, - this.hash.final(), - padding, - saltLength, + hash, + native.type.startsWith("rsa") ? padding : undefined, + saltLength ?? -1, ); + } catch { + throw new Error("Failed to generate signature"); + } return encoding === 1 && native.type === "ec" ? rawSignature(signed, Math.ceil(native.bits / 8)) : signed; } } -class VerifyHandle { +export class VerifyHandle { private hash: NativeDigest; constructor(private algorithm: string) { - this.hash = new NativeDigest(algorithm, "Verification"); + this.algorithm = signatureDigest(algorithm); + this.hash = new NativeDigest(this.algorithm, "Verification"); } update(data: Uint8Array) { - this.hash.update( - Buffer.from(data.buffer, data.byteOffset, data.byteLength), - ); + this.hash.update(viewBytes(data)); } verify( value: any, - signature: Uint8Array, + signature: ArrayBuffer | ArrayBufferView, padding?: number, saltLength?: number, encoding?: number, ) { - const native = key(value), - hash = this.hash.final(); + if (!isAnyBuffer(signature) && !ArrayBuffer.isView(signature)) + throw new TypeError( + "Failed to execute 'verify' on 'VerifyHandle': parameter 2 is not of type 'BufferSource'.", + ); + this.hash.assertActive(); + const native = key(value); + if (native.type === "ed25519" || native.type === "ed448") + throw new Error("Unsupported operation for this key"); + let signatureBytes: Buffer = Buffer.from( + isAnyBuffer(signature) ? bufferBytes(signature) : viewBytes(signature), + ); + const hash = this.hash.final(); + if (native.type === "dsa") + throw new Error("Verifying with DSA keys is not currently supported"); + if (native.type === "x25519") + throw new Error("Failed to initialize key for verification"); + rsaParameters(native.type, padding, saltLength, true); if (encoding === 1 && native.type === "ec") { const width = Math.ceil(native.bits / 8); - if (signature.length !== width * 2) return false; - signature = der( + if (signatureBytes.length !== width * 2) return false; + signatureBytes = der( 48, - integer(signature.subarray(0, width)), - integer(signature.subarray(width)), + integer(signatureBytes.subarray(0, width)), + integer(signatureBytes.subarray(width)), ); } return native.verifyHash( this.algorithm, hash, - signature, - padding, - saltLength, + signatureBytes, + native.type.startsWith("rsa") ? padding : undefined, + saltLength ?? -1, ); } } diff --git a/src/compat/crypto/signatures.spec.ts b/src/compat/crypto/signatures.spec.ts new file mode 100644 index 0000000..5f8c13c --- /dev/null +++ b/src/compat/crypto/signatures.spec.ts @@ -0,0 +1,35 @@ +import { expect, expectTypeOf, test } from "vitest"; +import { Buffer } from "node:buffer"; +import { + createSign, + createVerify, + sign, + verify, + generateKeyPairSync, +} from "./index.js"; + +test("signature public types preserve binary and encoded results", () => { + const pair = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + const data = new Uint8Array([1, 2]); + const signature = createSign("sha256") + .update(data) + .sign(pair.privateKey, "buffer"); + expectTypeOf(signature).toEqualTypeOf(); + expectTypeOf( + createSign("sha256").update(data).sign(pair.privateKey, "hex"), + ).toEqualTypeOf(); + expect( + createVerify("sha256").update(data).verify(pair.publicKey, signature), + ).toBe(true); + expectTypeOf(sign("sha256", data, pair.privateKey)).toEqualTypeOf(); + expectTypeOf( + verify("sha256", data, pair.publicKey, signature), + ).toEqualTypeOf(); + const invalid = () => { + // @ts-expect-error Workers requires an ArrayBufferView for one-shot data. + sign("sha256", new ArrayBuffer(1), pair.privateKey); + // @ts-expect-error One-shot signatures cannot be encoded strings. + verify("sha256", data, pair.publicKey, "abc"); + }; + void invalid; +}); diff --git a/src/compat/crypto/signatures.ts b/src/compat/crypto/signatures.ts index 631268b..10b827f 100644 --- a/src/compat/crypto/signatures.ts +++ b/src/compat/crypto/signatures.ts @@ -32,7 +32,6 @@ import { default as cryptoImpl, type SignHandle, type VerifyHandle, - type CreateAsymmetricKeyOptions, } from "./sign-handle.js"; import { validateString } from "../net/validators.js"; @@ -49,19 +48,25 @@ import { } from "./sign-errors.js"; import { Writable } from "../stream/writable.js"; +import type { Writable as WritableType, WritableOptions } from "node:stream"; +import type { RsaKeyOptions as CreateAsymmetricKeyOptions } from "./rsa.js"; import { isArrayBufferView } from "../util/types/index.js"; -export interface SignOptions {} - -export interface Sign extends Writable { +export interface SignOptions extends WritableOptions {} +export interface Sign extends WritableType { [kHandle]: SignHandle; - update(data: any): void; - sign(privateKey: any): any; + update(data: string | ArrayBufferView, encoding?: BufferEncoding): this; + sign(privateKey: any, encoding?: undefined | "buffer"): Buffer; + sign(privateKey: any, encoding: BufferEncoding): string; } -export interface Verify extends Writable { +export interface Verify extends WritableType { [kHandle]: VerifyHandle; - update(data: any): void; - verify(publicKey: any, signature: any): any; + update(data: string | ArrayBufferView, encoding?: BufferEncoding): this; + verify( + publicKey: any, + signature: string | ArrayBuffer | SharedArrayBuffer | ArrayBufferView, + encoding?: BufferEncoding, + ): boolean; } const kHandle = Symbol("kHandle"); @@ -75,6 +80,7 @@ export const Sign = function (this: Sign, algorithm: string, options: any) { return this; } as any as { new (algorithm: string, options?: SignOptions): Sign; + (algorithm: string, options?: SignOptions): Sign; }; Object.setPrototypeOf(Sign.prototype, Writable.prototype); Object.setPrototypeOf(Sign, Writable); @@ -191,6 +197,7 @@ export const Verify = function (this: Verify, algorithm: string, options: any) { return this; } as any as { new (algorithm: string, options?: SignOptions): Verify; + (algorithm: string, options?: SignOptions): Verify; }; Object.setPrototypeOf(Verify.prototype, Writable.prototype); Object.setPrototypeOf(Verify, Writable); @@ -271,11 +278,11 @@ Verify.prototype.verify = function ( ); }; -export function createSign(algorithm: string, options: any) { +export function createSign(algorithm: string, options?: SignOptions) { return new Sign(algorithm, options); } -export function createVerify(algorithm: string, options: any) { +export function createVerify(algorithm: string, options?: SignOptions) { return new Verify(algorithm, options); } @@ -284,7 +291,18 @@ type VerifyCallback = (err: any, valid?: boolean) => void; export function sign( algorithm: string | null | undefined, - data: BufferSource, + data: ArrayBufferView, + options: any, +): Buffer; +export function sign( + algorithm: string | null | undefined, + data: ArrayBufferView, + options: any, + callback: SignCallback, +): void; +export function sign( + algorithm: string | null | undefined, + data: ArrayBufferView, options: any, callback?: SignCallback, ): Buffer | void { @@ -346,9 +364,22 @@ export function sign( export function verify( algorithm: string | null | undefined, - data: BufferSource, + data: ArrayBufferView, + options: any, + signature: ArrayBufferView, +): boolean; +export function verify( + algorithm: string | null | undefined, + data: ArrayBufferView, + options: any, + signature: ArrayBufferView, + callback: VerifyCallback, +): void; +export function verify( + algorithm: string | null | undefined, + data: ArrayBufferView, options: any, - signature: BufferSource, + signature: ArrayBufferView, callback?: VerifyCallback, ): boolean | undefined { // Node.js allows the algorithm to be either undefined or null, in which diff --git a/src/compat/crypto/subtle-digest.spec.ts b/src/compat/crypto/subtle-digest.spec.ts new file mode 100644 index 0000000..45c5289 --- /dev/null +++ b/src/compat/crypto/subtle-digest.spec.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { installDigest } from "./subtle-digest.js"; + +describe("Workers digest BufferSource brands", () => { + it("rejects bare shared memory while accepting its actual view extent", async () => { + const subtle = globalThis.crypto.subtle; + installDigest(subtle); + const shared = new SharedArrayBuffer(3); + new Uint8Array(shared).set([8, 1, 9]); + await expect( + subtle.digest("MD5", shared as unknown as ArrayBuffer), + ).rejects.toThrow("parameter 2 is not of type 'BufferSource'"); + const view = new Uint8Array(shared, 1, 1); + Object.defineProperty(view, "byteLength", { + get() { + throw new Error("shadow"); + }, + }); + const actual = new Uint8Array( + await subtle.digest("MD5", view as unknown as Uint8Array), + ); + expect(Array.from(actual)).toEqual([ + 85, 165, 64, 8, 173, 27, 165, 137, 170, 33, 13, 38, 41, 193, 223, 65, + ]); + }); +}); diff --git a/src/compat/crypto/subtle-digest.ts b/src/compat/crypto/subtle-digest.ts new file mode 100644 index 0000000..de172ba --- /dev/null +++ b/src/compat/crypto/subtle-digest.ts @@ -0,0 +1,73 @@ +import { NativeDigest } from "./native-crypto.js"; +import { dictionaryString } from "./key-formats.js"; +import { bufferBytes, viewBytes } from "../../shared/buffer-source.js"; + +const bufferLength = Object.getOwnPropertyDescriptor( + ArrayBuffer.prototype, + "byteLength", +)!.get!; +function isBuffer(value: unknown): value is ArrayBuffer { + try { + bufferLength.call(value); + return true; + } catch { + return false; + } +} +const names: Record = Object.assign(Object.create(null), { + "sha-1": "sha1", + "sha-256": "sha256", + "sha-384": "sha384", + "sha-512": "sha512", + md5: "md5", +}); +export function installDigest(subtle: SubtleCrypto): void { + const digest = { + digest( + this: SubtleCrypto, + algorithm: AlgorithmIdentifier, + data: BufferSource, + ): Promise { + if (this !== subtle) + throw new TypeError( + "Illegal invocation: function called with incorrect `this` reference. See https://developers.cloudflare.com/workers/observability/errors/#illegal-invocation-errors for details.", + ); + return (async () => { + if (algorithm == null) + throw new TypeError( + "Cannot initialize N7workerd3api12SubtleCrypto13HashAlgorithmE with required members from an undefined or null value.", + ); + const name = dictionaryString( + typeof algorithm === "object" || typeof algorithm === "function" + ? algorithm.name + : algorithm, + ); + if (!isBuffer(data) && !ArrayBuffer.isView(data)) + throw new TypeError( + "Failed to execute 'digest' on 'SubtleCrypto': parameter 2 is not of type 'BufferSource'.", + ); + const bytes = isBuffer(data) ? bufferBytes(data) : viewBytes(data); + const normalized = names[name.split("\0")[0]!.toLowerCase()]; + if (!normalized) + throw new DOMException( + "Unrecognized or unimplemented digest algorithm requested.", + "NotSupportedError", + ); + const handle = new NativeDigest(normalized); + try { + handle.update(bytes); + return handle.final().buffer as ArrayBuffer; + } finally { + handle.dispose(); + } + })(); + }, + }.digest; + Object.defineProperty(digest, "length", { value: 0 }); + Object.defineProperty(Object.getPrototypeOf(subtle), "digest", { + value: digest, + writable: true, + enumerable: true, + configurable: true, + }); +} diff --git a/src/compat/crypto/symmetric.ts b/src/compat/crypto/symmetric.ts index 8cb36a2..1171042 100644 --- a/src/compat/crypto/symmetric.ts +++ b/src/compat/crypto/symmetric.ts @@ -1,131 +1,459 @@ -import { Buffer } from "node:buffer"; +// @ts-nocheck +// Copyright (c) 2017-2023 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +import cryptoImpl, { type CipherHandle } from "./cipher-handle.js"; import { Transform } from "../stream/transform.js"; -// @ts-expect-error dependency has no declarations -import original from "crypto-browserify/index.js"; -import { secretMaterial } from "./keys.js"; -const BaseTransform = - Transform as unknown as typeof import("node:stream").Transform; -const handles = new WeakMap(); -export class Hmac extends BaseTransform { - constructor(algorithm: string, key: unknown, options?: any) { - super(options); - handles.set( - this, - original.createHmac( - algorithm, - typeof key === "string" - ? Buffer.from(key, options?.encoding) - : secretMaterial(key), - ), - ); - } - update(data: unknown, encoding?: string): this { - handles.get(this).update(data, encoding); - return this; - } - digest(encoding?: string): any { - return handles.get(this).digest(encoding); - } - override _transform(chunk: any, encoding: any, callback: any) { - try { - this.update(chunk, encoding); - callback(); - } catch (error) { - callback(error); - } +import type { + Transform as NodeTransform, + TransformOptions, + TransformCallback, +} from "node:stream"; +import { KeyObject, createSecretKey } from "./keys.js"; +import { + validateObject, + validateString, + validateUint32, +} from "../net/validators.js"; +import { ERR_INVALID_ARG_TYPE, ERR_INVALID_ARG_VALUE } from "../net/errors.js"; +import { ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE } from "./sign-errors.js"; +import { isAnyBuffer as isAnyArrayBuffer } from "./validation.js"; +import { Buffer } from "node:buffer"; +const isArrayBufferView = ArrayBuffer.isView; + +export interface AADOptions { + plaintextLength?: number; + encoding?: string; +} + +const kHandle = Symbol("kHandle"); + +const CipherMode = { + CIPHER: 0, + DECIPHER: 1, +}; + +export interface Cipheriv extends NodeTransform { + [kHandle]: CipherHandle; + update( + data: string | ArrayBuffer | ArrayBufferView, + inputEncoding?: string, + ): Buffer; + update( + data: string | ArrayBuffer | ArrayBufferView, + inputEncoding: string | undefined, + outputEncoding: string, + ): string; + update( + data: string | ArrayBuffer | ArrayBufferView, + inputEncoding?: string, + outputEncoding?: string, + ): string | Buffer; + final(): Buffer; + final(outputEncoding: string): string; + final(outputEncoding?: string): string | Buffer; + setAAD( + buffer: string | ArrayBuffer | ArrayBufferView, + options?: AADOptions, + ): this; + setAutoPadding(autoPadding?: boolean): this; + getAuthTag(): Buffer; +} + +export interface Decipheriv extends NodeTransform { + [kHandle]: CipherHandle; + update( + data: string | ArrayBuffer | ArrayBufferView, + inputEncoding?: string, + ): Buffer; + update( + data: string | ArrayBuffer | ArrayBufferView, + inputEncoding: string | undefined, + outputEncoding: string, + ): string; + update( + data: string | ArrayBuffer | ArrayBufferView, + inputEncoding?: string, + outputEncoding?: string, + ): string | Buffer; + final(): Buffer; + final(outputEncoding: string): string; + final(outputEncoding?: string): string | Buffer; + setAAD( + buffer: string | ArrayBuffer | ArrayBufferView, + options?: AADOptions, + ): this; + setAutoPadding(autoPadding?: boolean): this; + setAuthTag(buffer: ArrayBuffer | ArrayBufferView): this; +} + +export interface CipherOptions extends TransformOptions { + authLengthTag?: number; +} + +function getSecretKey(key: unknown): unknown { + if (typeof CryptoKey !== "undefined" && key instanceof CryptoKey) + key = KeyObject.from(key); + if (key instanceof KeyObject) { + if (key.type !== "secret") + throw new ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE(key.type, "secret"); + return key; } - override _flush(callback: any) { - try { - this.push(this.digest()); - callback(); - } catch (error) { - callback(error); - } + if (isAnyArrayBuffer(key) || isArrayBufferView(key)) + return createSecretKey(key); + if (typeof key === "string") return createSecretKey(key, "utf8"); + throw new ERR_INVALID_ARG_TYPE( + "key", + ["string", "Buffer", "TypedArray", "DataView", "KeyObject", "CryptoKey"], + key, + ); +} + +function getIv( + iv: string | ArrayBuffer | ArrayBufferView | null, +): ArrayBuffer | ArrayBufferView { + if (isAnyArrayBuffer(iv) || isArrayBufferView(iv)) { + return iv; + } else if (typeof iv === "string") { + return Buffer.from(iv, "utf8"); } + + throw new ERR_INVALID_ARG_TYPE( + "iv", + ["string", "Buffer", "TypedArray", "DataView", "null"], + iv, + ); } -class CipherTransform extends BaseTransform { - constructor( - decrypt: boolean, + +export const Cipheriv = function ( + this: Cipheriv, + algorithm: string, + key: string | ArrayBuffer | ArrayBufferView | KeyObject | CryptoKey, + iv: string | ArrayBuffer | ArrayBufferView | null, + options: CipherOptions = {}, +) { + validateString(algorithm, "algorithm"); + const secretKey = getSecretKey(key); + const ivBuf = getIv(iv); + + if (options.authLengthTag !== undefined) { + validateUint32(options.authLengthTag, "options.authLengthTag"); + } + this[kHandle] = cryptoImpl.newHandle( + CipherMode.CIPHER, + algorithm, + secretKey, + ivBuf, + options.authLengthTag, + ); + + Transform.call(this, options as TransformOptions); +} as unknown as { + new ( algorithm: string, - key: unknown, - iv: unknown, - options?: any, - ) { - super(options); - handles.set( - this, - (decrypt ? original.createDecipheriv : original.createCipheriv)( - algorithm, - secretMaterial(key), - iv, - options, - ), + key: string | ArrayBuffer | ArrayBufferView | KeyObject | CryptoKey, + iv: string | ArrayBuffer | ArrayBufferView | null, + options?: CipherOptions, + ): Cipheriv; +}; +Object.setPrototypeOf(Cipheriv.prototype, Transform.prototype); +Object.setPrototypeOf(Cipheriv, Transform); + +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access +Cipheriv.prototype.update = function ( + this: Cipheriv, + data: string | ArrayBuffer | ArrayBufferView, + inputEncoding?: string, + outputEncoding?: string, +): string | Buffer { + let ret: ArrayBuffer; + if (typeof data === "string") { + if (inputEncoding === undefined) { + throw new ERR_INVALID_ARG_VALUE( + "inputEncoding", + inputEncoding, + "If inputEncoding is not provided then the data must be a Buffer", + ); + } + ret = this[kHandle].update(Buffer.from(data, inputEncoding)); + } else if (isAnyArrayBuffer(data)) { + ret = this[kHandle].update(data); + } else if (isArrayBufferView(data)) { + ret = this[kHandle].update(data); + } else { + throw new ERR_INVALID_ARG_TYPE( + "data", + ["string", "Buffer", "TypedArray", "DataView"], + data, ); } - update(data: unknown, inputEncoding?: string, outputEncoding?: string): any { - return handles.get(this).update(data, inputEncoding, outputEncoding); + + if (outputEncoding === undefined) { + return Buffer.from(ret); } - final(encoding?: string): any { - return handles.get(this).final(encoding); + + return Buffer.from(ret).toString(outputEncoding); +}; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access +Cipheriv.prototype.final = function ( + this: Cipheriv, + outputEncoding?: string, +): string | Buffer { + const ret = this[kHandle].final(); + if (outputEncoding === undefined) { + return Buffer.from(ret); } - setAutoPadding(value?: boolean): this { - handles.get(this).setAutoPadding(value); - return this; + return Buffer.from(ret).toString(outputEncoding); +}; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access +Cipheriv.prototype.setAAD = function ( + this: Cipheriv, + buffer: string | ArrayBuffer | ArrayBufferView, + options: AADOptions = {}, +): Cipheriv { + validateObject(options, "options"); + const { plaintextLength, encoding } = options; + if (plaintextLength !== undefined) { + validateUint32(plaintextLength, "options.plaintextLength"); } - setAAD(data: unknown, options?: any): this { - handles.get(this).setAAD(data, options); - return this; + if (encoding !== undefined) { + validateString(encoding, "options.encoding"); } - getAuthTag(): any { - return handles.get(this).getAuthTag(); + + if (typeof buffer === "string") { + this[kHandle].setAAD(Buffer.from(buffer, encoding), plaintextLength); + } else if (isAnyArrayBuffer(buffer) || isArrayBufferView(buffer)) { + this[kHandle].setAAD(buffer, plaintextLength); } - setAuthTag(data: unknown): this { - handles.get(this).setAuthTag(data); - return this; + return this; +}; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access +Cipheriv.prototype.setAutoPadding = function ( + this: Cipheriv, + autoPadding?: boolean, +): Cipheriv { + this[kHandle].setAutoPadding(autoPadding ? true : false); + return this; +}; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access +Cipheriv.prototype.getAuthTag = function (this: Cipheriv): Buffer | undefined { + const ret = this[kHandle].getAuthTag(); + if (ret === undefined) return undefined; + return Buffer.from(ret); +}; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access +Cipheriv.prototype._transform = function ( + this: Cipheriv, + chunk: string | Buffer | ArrayBufferView, + encoding: string, + callback: TransformCallback, +): void { + if (typeof chunk === "string") { + chunk = Buffer.from(chunk, encoding); } - override _transform(chunk: any, encoding: any, callback: any) { - try { - this.push(this.update(chunk, encoding)); - callback(); - } catch (error) { - callback(error); - } + this.push(Buffer.from(this[kHandle].update(chunk))); + callback(); +}; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access +Cipheriv.prototype._flush = function ( + this: Cipheriv, + callback: TransformCallback, +): void { + this.push(Buffer.from(this[kHandle].final())); + callback(); +}; + +export const Decipheriv = function ( + this: Decipheriv, + algorithm: string, + key: string | ArrayBuffer | ArrayBufferView | KeyObject | CryptoKey, + iv: string | ArrayBuffer | ArrayBufferView | null, + options: CipherOptions = {}, +) { + validateString(algorithm, "algorithm"); + const secretKey = getSecretKey(key); + const ivBuf = getIv(iv); + + if (options.authLengthTag !== undefined) { + validateUint32(options.authLengthTag, "options.authLengthTag"); } - override _flush(callback: any) { - try { - this.push(this.final()); - callback(); - } catch (error) { - callback(error); + + this[kHandle] = cryptoImpl.newHandle( + CipherMode.DECIPHER, + algorithm, + secretKey, + ivBuf, + options.authLengthTag, + ); + + Transform.call(this, options as TransformOptions); + return this; +} as unknown as { + new ( + algorithm: string, + key: string | ArrayBuffer | ArrayBufferView | KeyObject | CryptoKey, + iv: string | ArrayBuffer | ArrayBufferView | null, + options?: CipherOptions, + ): Decipheriv; +}; + +Object.setPrototypeOf(Decipheriv.prototype, Transform.prototype); +Object.setPrototypeOf(Decipheriv, Transform); + +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access +Decipheriv.prototype.update = function ( + this: Decipheriv, + data: string | ArrayBuffer | ArrayBufferView, + inputEncoding?: string, + outputEncoding?: string, +): string | Buffer { + let ret: ArrayBuffer; + if (typeof data === "string") { + if (inputEncoding === undefined) { + throw new ERR_INVALID_ARG_VALUE( + "inputEncoding", + inputEncoding, + "If inputEncoding is not provided then the data must be a Buffer", + ); } + ret = this[kHandle].update(Buffer.from(data, inputEncoding)); + } else if (isAnyArrayBuffer(data)) { + ret = this[kHandle].update(data); + } else if (isArrayBufferView(data)) { + ret = this[kHandle].update(data); + } else { + throw new ERR_INVALID_ARG_TYPE( + "data", + ["string", "Buffer", "TypedArray", "DataView"], + data, + ); } -} -export class Cipheriv extends CipherTransform { - constructor(algorithm: string, key: unknown, iv: unknown, options?: any) { - super(false, algorithm, key, iv, options); + + if (outputEncoding === undefined) { + return Buffer.from(ret); } -} -export class Decipheriv extends CipherTransform { - constructor(algorithm: string, key: unknown, iv: unknown, options?: any) { - super(true, algorithm, key, iv, options); + + return Buffer.from(ret).toString(outputEncoding); +}; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access +Decipheriv.prototype.final = function ( + this: Decipheriv, + outputEncoding?: string, +): string | Buffer { + const ret = this[kHandle].final(); + if (outputEncoding === undefined) { + return Buffer.from(ret); } -} -export function createHmac(algorithm: string, key: unknown, options?: any) { - return new Hmac(algorithm, key, options); -} + return Buffer.from(ret).toString(outputEncoding); +}; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access +Decipheriv.prototype.setAAD = function ( + this: Decipheriv, + buffer: string | ArrayBuffer | ArrayBufferView, + options: AADOptions = {}, +): Decipheriv { + validateObject(options, "options"); + const { plaintextLength, encoding } = options; + if (plaintextLength !== undefined) { + validateUint32(plaintextLength, "options.plaintextLength"); + } + if (encoding !== undefined) { + validateString(encoding, "options.encoding"); + } + + if (typeof buffer === "string") { + this[kHandle].setAAD(Buffer.from(buffer, encoding), plaintextLength); + } else if (isAnyArrayBuffer(buffer) || isArrayBufferView(buffer)) { + this[kHandle].setAAD(buffer, plaintextLength); + } + return this; +}; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access +Decipheriv.prototype.setAutoPadding = function ( + this: Decipheriv, + autoPadding?: boolean, +): Decipheriv { + this[kHandle].setAutoPadding(autoPadding ? true : false); + return this; +}; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access +Decipheriv.prototype.setAuthTag = function ( + this: Decipheriv, + buffer: ArrayBuffer | ArrayBufferView, +): Decipheriv { + this[kHandle].setAuthTag(buffer); + return this; +}; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access +Decipheriv.prototype._transform = function ( + this: Decipheriv, + chunk: string | Buffer | ArrayBufferView, + encoding: string, + callback: TransformCallback, +): void { + if (typeof chunk === "string") { + chunk = Buffer.from(chunk, encoding); + } + this.push(Buffer.from(this[kHandle].update(chunk))); + callback(); +}; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access +Decipheriv.prototype._flush = function ( + this: Decipheriv, + callback: TransformCallback, +): void { + this.push(Buffer.from(this[kHandle].final())); + callback(); +}; + export function createCipheriv( algorithm: string, - key: unknown, - iv: unknown, - options?: any, -) { + key: string | ArrayBuffer | ArrayBufferView | KeyObject | CryptoKey, + iv: string | ArrayBuffer | ArrayBufferView | null, + options: CipherOptions = {}, +): Cipheriv { return new Cipheriv(algorithm, key, iv, options); } + export function createDecipheriv( algorithm: string, - key: unknown, - iv: unknown, - options?: any, -) { + key: string | ArrayBuffer | ArrayBufferView | KeyObject | CryptoKey, + iv: string | ArrayBuffer | ArrayBufferView | null, + options: CipherOptions = {}, +): Decipheriv { return new Decipheriv(algorithm, key, iv, options); } diff --git a/src/compat/crypto/upstream-cipher.fixture.mjs b/src/compat/crypto/upstream-cipher.fixture.mjs new file mode 100644 index 0000000..72291c9 --- /dev/null +++ b/src/compat/crypto/upstream-cipher.fixture.mjs @@ -0,0 +1,678 @@ +// Copyright (c) 2017-2023 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import { + createCipheriv, + createDecipheriv, + randomBytes, + createSecretKey, + publicDecrypt, + publicEncrypt, + privateDecrypt, + privateEncrypt, + createPublicKey, + createPrivateKey, + getCiphers, + getCipherInfo, + createCipher, + createDecipher, + Cipher, + Decipher, +} from "./index.js"; + +import { strictEqual, deepStrictEqual, throws, ok } from "node:assert"; + +const tests = [ + { name: "aes-128-cbc", size: 16, iv: 16 }, + { name: "aes-192-cbc", size: 24, iv: 16 }, + { name: "aes-256-cbc", size: 32, iv: 16 }, + { name: "aes-128-ctr", size: 16, iv: 16 }, + { name: "aes-192-ctr", size: 24, iv: 16 }, + { name: "aes-256-ctr", size: 32, iv: 16 }, + { name: "aes-128-ecb", size: 16, iv: 0 }, + { name: "aes-192-ecb", size: 24, iv: 0 }, + { name: "aes-256-ecb", size: 32, iv: 0 }, + { name: "aes-128-ofb", size: 16, iv: 16 }, + { name: "aes-192-ofb", size: 24, iv: 16 }, + { name: "aes-256-ofb", size: 32, iv: 16 }, +]; + +const authTagTests = [ + { name: "aes-128-gcm", size: 16, iv: 16 }, + { name: "aes-192-gcm", size: 24, iv: 16 }, + { name: "aes-256-gcm", size: 32, iv: 16 }, + { name: "chacha20-poly1305", size: 32, iv: 12 }, +]; + +export const cipheriv = { + async test() { + tests.forEach((test) => { + const key = createSecretKey(Buffer.alloc(test.size)); + const iv = Buffer.alloc(test.iv); + + const cipher = createCipheriv(test.name, key, iv); + const decipher = createDecipheriv(test.name, key, iv); + + let data = ""; + data += decipher.update(cipher.update("Hello World", "utf8")); + data += decipher.update(cipher.final()); + data += decipher.final(); + strictEqual(data, "Hello World"); + }); + + // Test that the streams API works also + await Promise.all( + tests.map(async (test) => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const key = createSecretKey(Buffer.alloc(test.size)); + const iv = Buffer.alloc(test.iv); + + const cipher = createCipheriv(test.name, key, iv); + const decipher = createDecipheriv(test.name, key, iv); + decipher.setEncoding("utf8"); + + cipher.end("Hello World"); + cipher.on("data", (chunk) => { + decipher.write(chunk); + }); + cipher.on("end", () => { + decipher.end(); + }); + + cipher.on("error", reject); + decipher.on("error", reject); + + let res = ""; + decipher.on("data", (chunk) => { + res += chunk; + }); + decipher.on("end", resolve); + + await promise; + strictEqual(res, "Hello World", test.name); + }), + ); + + authTagTests.forEach((test) => { + const key = createSecretKey(Buffer.alloc(test.size)); + const iv = Buffer.alloc(test.iv); + + const cipher = createCipheriv(test.name, key, iv); + + let data = ""; + cipher.setAAD(Buffer.from("hello")); + data += cipher.update("Hello World", "utf8", "hex"); + data += cipher.final("hex"); + + const tag = cipher.getAuthTag(); + + //tag[1] = 0xbb; + + const decipher = createDecipheriv(test.name, key, iv); + decipher.setAuthTag(tag); + decipher.setAAD(Buffer.from("hello")); + let res = ""; + res += decipher.update(data, "hex"); + res += decipher.final(); + strictEqual(res, "Hello World"); + }); + }, +}; + +export const largeData = { + async test() { + const { promise, resolve, reject } = Promise.withResolvers(); + + const key = createSecretKey(Buffer.alloc(16)); + const iv = Buffer.alloc(16); + const cipher = createCipheriv("aes-128-cbc", key, iv); + const chunks = [ + randomBytes(1024), + randomBytes(2048), + randomBytes(4096), + randomBytes(8192), + randomBytes(16304), + ]; + chunks.forEach((chunk) => cipher.write(chunk)); + cipher.end(); + + const decipher = createDecipheriv("aes-128-cbc", key, iv); + cipher.pipe(decipher); + + const output = []; + decipher.on("data", (chunk) => output.push(chunk)); + + decipher.on("end", () => { + const inputCombined = Buffer.concat(chunks); + const outputCombined = Buffer.concat(output); + deepStrictEqual(inputCombined, outputCombined); + resolve(); + }); + + decipher.on("error", reject); + + await promise; + }, +}; + +export const publicEncryptPrivateDecrypt = { + test(_, env) { + const pub = createPublicKey(env["rsa_public.pem"]); + const pvt = createPrivateKey(env["rsa_private.pem"]); + + pub.oaepLabel = "test"; + pub.oaepHash = "sha256"; + pub.padding = 4; + pub.encoding = "utf8"; + + pvt.oaepLabel = "test"; + pvt.oaepHash = "sha256"; + pvt.padding = 4; + pvt.encoding = "utf8"; + + strictEqual( + privateDecrypt(pvt, publicEncrypt(pub, "hello")).toString(), + "hello", + ); + }, +}; + +export const publicEncryptPrivateDecryptDer = { + test(_, env) { + const pub = createPublicKey(env["rsa_public.pem"]); + const pvt = createPrivateKey(env["rsa_private.pem"]); + + const pubDer = { + key: pub.export({ type: "pkcs1", format: "der" }), + format: "der", + type: "pkcs1", + }; + + const pvtDer = { + key: pvt.export({ type: "pkcs8", format: "der" }), + format: "der", + type: "pkcs8", + }; + + strictEqual( + privateDecrypt(pvtDer, publicEncrypt(pubDer, "hello")).toString(), + "hello", + ); + }, +}; + +export const publicEncryptPrivateDecryptPem = { + test(_, env) { + const pub = createPublicKey(env["rsa_public.pem"]); + const pvt = createPrivateKey(env["rsa_private.pem"]); + + const pubPem = { + key: pub.export({ type: "pkcs1", format: "pem" }), + format: "pem", + type: "pkcs1", + }; + + const pvtPem = { + key: pvt.export({ type: "pkcs8", format: "pem" }), + format: "pem", + type: "pkcs8", + }; + + strictEqual( + privateDecrypt(pvtPem, publicEncrypt(pubPem, "hello")).toString(), + "hello", + ); + }, +}; + +export const publicEncryptPrivateDecryptPem2 = { + test(_, env) { + const pub = createPublicKey(env["rsa_public.pem"]); + const pvt = createPrivateKey(env["rsa_private.pem"]); + + const pubPem = pub.export({ type: "pkcs1", format: "pem" }); + const pvtPem = pvt.export({ type: "pkcs8", format: "pem" }); + + strictEqual( + privateDecrypt(pvtPem, publicEncrypt(pubPem, "hello")).toString(), + "hello", + ); + }, +}; + +export const publicEncryptPrivateDecryptPem3 = { + test(_, env) { + const pub = createPublicKey(env["rsa_public.pem"]); + const pvt = createPrivateKey(env["rsa_private.pem"]); + + const pubPem = pub.export({ type: "spki", format: "pem" }); + const pvtPem = pvt.export({ type: "pkcs8", format: "pem" }); + + strictEqual( + privateDecrypt(pvtPem, publicEncrypt(pubPem, "hello")).toString(), + "hello", + ); + }, +}; + +export const privateEncryptPublicDecrypt = { + test(_, env) { + const pub = createPublicKey(env["rsa_public.pem"]); + const pvt = createPrivateKey(env["rsa_private.pem"]); + + pub.oaepLabel = "test"; + pub.oaepHash = "sha256"; + pub.padding = 3; + pub.encoding = "utf8"; + + pvt.oaepLabel = "test"; + pvt.oaepHash = "sha256"; + pvt.padding = 3; + pvt.encoding = "utf8"; + + const input = "a".repeat(256); + strictEqual( + publicDecrypt(pub, privateEncrypt(pvt, input)).toString(), + input, + ); + }, +}; + +export const missingArgChecks = { + test() { + throws(() => publicEncrypt(), { + code: "ERR_MISSING_ARGS", + }); + throws(() => publicDecrypt(), { + code: "ERR_MISSING_ARGS", + }); + throws(() => privateEncrypt(), { + code: "ERR_MISSING_ARGS", + }); + throws(() => privateDecrypt(), { + code: "ERR_MISSING_ARGS", + }); + throws(() => publicEncrypt("key"), { + code: "ERR_MISSING_ARGS", + }); + throws(() => publicDecrypt("key"), { + code: "ERR_MISSING_ARGS", + }); + throws(() => privateEncrypt("key"), { + code: "ERR_MISSING_ARGS", + }); + throws(() => privateDecrypt("key"), { + code: "ERR_MISSING_ARGS", + }); + }, +}; + +export const known_ciphers_info = { + test() { + const ciphers = getCiphers(); + ciphers.forEach((i) => { + const info = getCipherInfo(i); + ok(info); + strictEqual(typeof info, "object"); + strictEqual(typeof info.name, "string"); + strictEqual(typeof info.nid, "number"); + strictEqual(typeof info.blockSize, "number"); + strictEqual(typeof info.ivLength, "number"); + strictEqual(typeof info.keyLength, "number"); + strictEqual(typeof info.mode, "string"); + }); + }, +}; + +export const test_cipher_info = { + test() { + const info = getCipherInfo("aes-128-cbc", { + ivLength: 16, + keyLength: 16, + }); + ok(info); + strictEqual(typeof info, "object"); + strictEqual(typeof info.name, "string"); + strictEqual(typeof info.nid, "number"); + strictEqual(typeof info.blockSize, "number"); + strictEqual(typeof info.ivLength, "number"); + strictEqual(typeof info.keyLength, "number"); + strictEqual(typeof info.mode, "string"); + }, +}; + +export const test_cipher_info2 = { + test() { + const info = getCipherInfo("aes-128-cbc", { + ivLength: 17, + keyLength: 15, + }); + strictEqual(info, undefined); + }, +}; + +export const test_cipher_info3 = { + test() { + const info = getCipherInfo("aes-128-cbc", { + ivLength: -1, + keyLength: 16, + }); + strictEqual(info, undefined); + }, +}; + +export const test_cipher_info4 = { + test() { + const info = getCipherInfo("aes-128-cbc", { + ivLength: 16, + keyLength: -1, + }); + strictEqual(info, undefined); + }, +}; + +// Verify that modifying a buffer after passing it to a cipher API does not +// affect the cipher output. Buffer data (IV, AAD, auth tag) is copied into +// C++-owned memory at the API boundary, matching Node.js behavior. These tests +// compare output against a reference encryption performed with unmodified buffers. + +// Helper: encrypt with chacha20-poly1305 (AEAD path) and return {ciphertext, tag}. +function chachaEncrypt(key, iv, plaintext, aad) { + const cipher = createCipheriv("chacha20-poly1305", key, iv); + if (aad) cipher.setAAD(aad); + const ct = cipher.update(plaintext); + cipher.final(); + return { ciphertext: ct, tag: cipher.getAuthTag() }; +} + +// Helper: encrypt with aes-256-gcm (CipherHandle path) and return {ciphertext, tag}. +function gcmEncrypt(key, iv, plaintext) { + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ct = cipher.update(plaintext); + cipher.final(); + return { ciphertext: ct, tag: cipher.getAuthTag() }; +} + +export const modifiedIvChaCha20 = { + test() { + // ChaCha20-Poly1305 uses the AEAD (EVP_AEAD) path. + const plainKey = Buffer.alloc(32, 0x01); + const plainIv = Buffer.alloc(12, 0x42); + const key = createSecretKey(plainKey); + const plaintext = Buffer.from("hello world"); + + // Reference encryption with plain buffers. + const ref = chachaEncrypt(key, plainIv, plaintext); + + // Modify IV buffer after cipher creation -- must not affect output. + const ivBuffer = new ArrayBuffer(12, { maxByteLength: 16 }); + new Uint8Array(ivBuffer).fill(0x42); + const cipher = createCipheriv( + "chacha20-poly1305", + key, + new Uint8Array(ivBuffer), + ); + ivBuffer.resize(0); + const ct = cipher.update(plaintext); + cipher.final(); + + deepStrictEqual(ct, ref.ciphertext); + deepStrictEqual(cipher.getAuthTag(), ref.tag); + }, +}; + +export const modifiedIvAesGcm = { + test() { + // AES-256-GCM uses the CipherHandle (EVP_CIPHER) path. + const plainKey = Buffer.alloc(32, 0x01); + const plainIv = Buffer.alloc(16, 0x42); + const key = createSecretKey(plainKey); + const plaintext = Buffer.from("hello world"); + + const ref = gcmEncrypt(key, plainIv, plaintext); + + const ivBuffer = new ArrayBuffer(16, { maxByteLength: 32 }); + new Uint8Array(ivBuffer).fill(0x42); + const cipher = createCipheriv("aes-256-gcm", key, new Uint8Array(ivBuffer)); + ivBuffer.resize(0); + const ct = cipher.update(plaintext); + cipher.final(); + + deepStrictEqual(ct, ref.ciphertext); + deepStrictEqual(cipher.getAuthTag(), ref.tag); + }, +}; + +export const modifiedIvGrowChaCha20 = { + test() { + // Growing the IV buffer after cipher creation must not change the output. + const plainKey = Buffer.alloc(32, 0x01); + const plainIv = Buffer.alloc(12, 0x42); + const key = createSecretKey(plainKey); + const plaintext = Buffer.from("hello world"); + + const ref = chachaEncrypt(key, plainIv, plaintext); + + const ivBuffer = new ArrayBuffer(12, { maxByteLength: 32 }); + new Uint8Array(ivBuffer).fill(0x42); + const cipher = createCipheriv( + "chacha20-poly1305", + key, + new Uint8Array(ivBuffer), + ); + ivBuffer.resize(32); // grow past original IV length + const ct = cipher.update(plaintext); + cipher.final(); + + deepStrictEqual(ct, ref.ciphertext); + deepStrictEqual(cipher.getAuthTag(), ref.tag); + }, +}; + +export const modifiedAadChaCha20 = { + test() { + // Modifying AAD buffer after setAAD must not change the cipher output. + const plainKey = Buffer.alloc(32, 0x01); + const plainIv = Buffer.alloc(12, 0x42); + const plainAad = Buffer.alloc(16, 0xaa); + const key = createSecretKey(plainKey); + const plaintext = Buffer.from("hello world"); + + const ref = chachaEncrypt(key, plainIv, plaintext, plainAad); + + const aadBuffer = new ArrayBuffer(16, { maxByteLength: 32 }); + new Uint8Array(aadBuffer).fill(0xaa); + const cipher = createCipheriv("chacha20-poly1305", key, plainIv); + cipher.setAAD(new Uint8Array(aadBuffer)); + aadBuffer.resize(0); + const ct = cipher.update(plaintext); + cipher.final(); + + deepStrictEqual(ct, ref.ciphertext); + deepStrictEqual(cipher.getAuthTag(), ref.tag); + }, +}; + +export const modifiedAuthTagDecrypt = { + test() { + // Modifying auth tag buffer after setAuthTag must still allow correct decryption. + const plainKey = Buffer.alloc(32, 0x01); + const plainIv = Buffer.alloc(12, 0x42); + const key = createSecretKey(plainKey); + const plaintext = Buffer.from("hello world"); + + const { ciphertext, tag } = chachaEncrypt(key, plainIv, plaintext); + + // Copy tag into a buffer, then modify it after setAuthTag. + const tagBuffer = new ArrayBuffer(tag.length, { maxByteLength: 32 }); + new Uint8Array(tagBuffer).set(tag); + + const decipher = createDecipheriv("chacha20-poly1305", key, plainIv); + decipher.setAuthTag(new Uint8Array(tagBuffer)); + tagBuffer.resize(0); + + const pt = decipher.update(ciphertext); + decipher.final(); + deepStrictEqual(pt, plaintext); + }, +}; + +export const invalidAuthTagDecrypt = { + test() { + // Decryption with an invalid auth tag results in a meaningful error message + const plainKey = Buffer.alloc(32, 0x01); + const plainIv = Buffer.alloc(12, 0x42); + const key = createSecretKey(plainKey); + const plaintext = Buffer.from("hello world"); + + const { ciphertext, tag } = chachaEncrypt(key, plainIv, plaintext); + // Modify first byte of tag + tag[0] ^= 1; + + const decipher = createDecipheriv("chacha20-poly1305", key, plainIv); + decipher.setAuthTag(new Uint8Array(tag)); + + throws( + () => { + decipher.update(ciphertext); + }, + { + name: "OperationError", + message: + "Decryption failed: Incorrect key/IV or corrupted ciphertext/authentication tag provided.", + }, + ); + }, +}; + +export const transferredIvChaCha20 = { + test() { + // Transferring IV buffer after cipher creation must not affect output. + const plainKey = Buffer.alloc(32, 0x01); + const plainIv = Buffer.alloc(12, 0x42); + const key = createSecretKey(plainKey); + const plaintext = Buffer.from("hello world"); + + const ref = chachaEncrypt(key, plainIv, plaintext); + + const ivBuffer = new ArrayBuffer(12); + new Uint8Array(ivBuffer).fill(0x42); + const cipher = createCipheriv( + "chacha20-poly1305", + key, + new Uint8Array(ivBuffer), + ); + structuredClone(ivBuffer, { transfer: [ivBuffer] }); + const ct = cipher.update(plaintext); + cipher.final(); + + deepStrictEqual(ct, ref.ciphertext); + deepStrictEqual(cipher.getAuthTag(), ref.tag); + }, +}; + +export const transferredIvAesGcm = { + test() { + const plainKey = Buffer.alloc(32, 0x01); + const plainIv = Buffer.alloc(16, 0x42); + const key = createSecretKey(plainKey); + const plaintext = Buffer.from("hello world"); + + const ref = gcmEncrypt(key, plainIv, plaintext); + + const ivBuffer = new ArrayBuffer(16); + new Uint8Array(ivBuffer).fill(0x42); + const cipher = createCipheriv("aes-256-gcm", key, new Uint8Array(ivBuffer)); + structuredClone(ivBuffer, { transfer: [ivBuffer] }); + const ct = cipher.update(plaintext); + cipher.final(); + + deepStrictEqual(ct, ref.ciphertext); + deepStrictEqual(cipher.getAuthTag(), ref.tag); + }, +}; + +export const transferredAadChaCha20 = { + test() { + const plainKey = Buffer.alloc(32, 0x01); + const plainIv = Buffer.alloc(12, 0x42); + const plainAad = Buffer.alloc(16, 0xaa); + const key = createSecretKey(plainKey); + const plaintext = Buffer.from("hello world"); + + const ref = chachaEncrypt(key, plainIv, plaintext, plainAad); + + const aadBuffer = new ArrayBuffer(16); + new Uint8Array(aadBuffer).fill(0xaa); + const cipher = createCipheriv("chacha20-poly1305", key, plainIv); + cipher.setAAD(new Uint8Array(aadBuffer)); + structuredClone(aadBuffer, { transfer: [aadBuffer] }); + const ct = cipher.update(plaintext); + cipher.final(); + + deepStrictEqual(ct, ref.ciphertext); + deepStrictEqual(cipher.getAuthTag(), ref.tag); + }, +}; + +export const transferredAuthTagDecrypt = { + test() { + const plainKey = Buffer.alloc(32, 0x01); + const plainIv = Buffer.alloc(12, 0x42); + const key = createSecretKey(plainKey); + const plaintext = Buffer.from("hello world"); + + const { ciphertext, tag } = chachaEncrypt(key, plainIv, plaintext); + + const tagBuffer = new ArrayBuffer(tag.length); + new Uint8Array(tagBuffer).set(tag); + + const decipher = createDecipheriv("chacha20-poly1305", key, plainIv); + decipher.setAuthTag(new Uint8Array(tagBuffer)); + structuredClone(tagBuffer, { transfer: [tagBuffer] }); + + const pt = decipher.update(ciphertext); + decipher.final(); + deepStrictEqual(pt, plaintext); + }, +}; + +// Regression: AUTOVULN-CLOUDFLARE-WORKERD-76 +// publicEncrypt/privateDecrypt with non-OAEP padding + oaepLabel must +// throw without leaking the label buffer. +export const oaepLabelWithNonOaepPaddingThrows = { + test(_, env) { + const pub = createPublicKey(env["rsa_public.pem"]); + pub.padding = 1; // RSA_PKCS1_PADDING (not OAEP=4) + pub.oaepLabel = Buffer.alloc(1024); + pub.encoding = "utf8"; + + for (let i = 0; i < 5; i++) { + throws(() => publicEncrypt(pub, Buffer.from("test")), { + message: /Failed to set the OAEP label/, + }); + } + + const pvt = createPrivateKey(env["rsa_private.pem"]); + pvt.padding = 1; // RSA_PKCS1_PADDING (not OAEP=4) + pvt.oaepLabel = Buffer.alloc(1024); + pvt.encoding = "utf8"; + + for (let i = 0; i < 5; i++) { + throws(() => privateDecrypt(pvt, Buffer.from("test")), { + message: /Failed to set the OAEP label/, + }); + } + }, +}; + +export const testUnimplemented = { + async test() { + strictEqual(typeof Cipher, "function"); + strictEqual(typeof Decipher, "function"); + strictEqual(typeof createCipher, "function"); + strictEqual(typeof createDecipher, "function"); + }, +}; diff --git a/src/compat/crypto/upstream-dh.fixture.mjs b/src/compat/crypto/upstream-dh.fixture.mjs new file mode 100644 index 0000000..5ad60c7 --- /dev/null +++ b/src/compat/crypto/upstream-dh.fixture.mjs @@ -0,0 +1,547 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// Adapted from Node.js. Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +"use strict"; + +import { Buffer } from "node:buffer"; + +import * as assert from "node:assert"; +import * as cryptoModule from "./index.js"; +// Keep the local identifier in host TypeError text after Vitest import rewriting. +const crypto = cryptoModule; + +export const dh_test = { + test(ctrl, env, ctx) { + // https://github.com/nodejs/node/issues/32738 + // XXX(bnoordhuis) validateInt32() throwing ERR_OUT_OF_RANGE and RangeError + // instead of ERR_INVALID_ARG_TYPE and TypeError is questionable, IMO. + assert.throws(() => crypto.createDiffieHellman(13.37), { + code: "ERR_OUT_OF_RANGE", + name: "RangeError", + message: + 'The value of "sizeOrKey" is out of range. ' + + "It must be an integer. Received 13.37", + }); + + assert.throws(() => crypto.createDiffieHellman("abcdef", 13.37), { + code: "ERR_OUT_OF_RANGE", + name: "RangeError", + message: + 'The value of "generator" is out of range. ' + + "It must be an integer. Received 13.37", + }); + // BoringSSL throws when key sizes > 10000 bits are requested, we proactively return a + // RangeError instead which is more descriptive. + assert.throws(() => crypto.createDiffieHellman(10001), { + name: "RangeError", + }); + + for (const bits of [-1, 0, 1]) { + assert.throws(() => crypto.createDiffieHellman(bits), { + name: "Error", + }); + } + + // Through a fluke of history, g=0 defaults to DH_GENERATOR (2). + { + const g = 0; + crypto.createDiffieHellman("abcdef", "hex", g); + } + + for (const g of [-1, 1]) { + const ex = { + name: "RangeError", + }; + assert.throws(() => crypto.createDiffieHellman("abcdef", g), ex); + assert.throws(() => crypto.createDiffieHellman("abcdef", "hex", g), ex); + } + + // Calls with even p, or p values that are interpreted as <= g will be rejected. + crypto.createDiffieHellman("abcdef", "hex", Buffer.from([2])); // OK + for (const bits of ["abcdef", Buffer.from([1]), Buffer.from([4])]) { + assert.throws(() => crypto.createDiffieHellman(bits), { + name: "Error", + }); + } + + for (const g of [Buffer.from([]), Buffer.from([0]), Buffer.from([1])]) { + const ex = { + name: "Error", + }; + assert.throws(() => crypto.createDiffieHellman("abcdef", g), ex); + assert.throws(() => crypto.createDiffieHellman("abcdef", "hex", g), ex); + } + + [[0x1, 0x2], () => {}, /abc/, {}].forEach((input) => { + assert.throws(() => crypto.createDiffieHellman(input), { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + }); + }); + + assert.throws( + function () { + crypto.getDiffieHellman("unknown-group"); + }, + { + name: "Error", + }, + "crypto.getDiffieHellman('unknown-group') " + + "failed to throw the expected error.", + ); + + assert.throws(() => crypto.createDiffieHellman("", true), { + code: "ERR_INVALID_ARG_TYPE", + }); + + [true, Symbol(), {}, () => {}, []].forEach((generator) => + assert.throws(() => crypto.createDiffieHellman("", "base64", generator), { + name: "TypeError", + }), + ); + }, +}; + +/////////////// + +export const dh_verify_error_test = { + test(ctrl, env, ctx) { + // Second OAKLEY group, see + // https://github.com/nodejs/node-v0.x-archive/issues/2338 and + // https://xml2rfc.tools.ietf.org/public/rfc/html/rfc2412.html#anchor49 + const p = + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74" + + "020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F1437" + + "4FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" + + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFF"; + crypto.createDiffieHellman(p, "hex"); + + // Confirm DH_check() results are exposed for optional examination. Use an odd value for prime + // since even values are rejected immediately. + const bad_dh = crypto.createDiffieHellman("03", "hex"); + assert.notStrictEqual(bad_dh.verifyError, 0); + }, +}; + +///////////////////// + +export const dh_constructor_test = { + test(ctrl, env, ctx) { + const DiffieHellmanGroup = crypto.DiffieHellmanGroup; + const dhg = DiffieHellmanGroup("modp14"); + assert.ok( + dhg instanceof DiffieHellmanGroup, + "DiffieHellmanGroup is expected " + + "to return a new instance when " + + "called without `new`", + ); + + const p1 = dhg.getPrime("buffer"); + const DiffieHellman = crypto.DiffieHellman; + const dh = DiffieHellman(p1, "buffer"); + assert.ok( + dh instanceof DiffieHellman, + "DiffieHellman is expected to return a " + + "new instance when called without `new`", + ); + }, +}; +//////////////////// + +// This test will fail if BoringSSL runs in FIPS mode and succeed otherwise; disable it for now. + +/* +function test() { + const odd = Buffer.alloc(39, 'A'); + + const c = crypto.createDiffieHellman(size); + c.setPrivateKey(odd); + c.generateKeys(); +} + +// FIPS requires a length of at least 1024 +if (!common.hasFipsCrypto) { + test(); +} else { + assert.throws(function() { test(); }, /key size too small/); +} +*/ + +////////////////// + +export const dh_group_test = { + test(ctrl, env, ctx) { + assert.throws( + function () { + crypto.getDiffieHellman("modp14").setPrivateKey(""); + }, + new RegExp( + "^TypeError: crypto\\.getDiffieHellman\\(\\.\\.\\.\\)\\." + + "setPrivateKey is not a function$", + ), + "crypto.getDiffieHellman('modp14').setPrivateKey('') " + + "failed to throw the expected error.", + ); + assert.throws( + function () { + crypto.getDiffieHellman("modp14").setPublicKey(""); + }, + new RegExp( + "^TypeError: crypto\\.getDiffieHellman\\(\\.\\.\\.\\)\\." + + "setPublicKey is not a function$", + ), + "crypto.getDiffieHellman('modp14').setPublicKey('') " + + "failed to throw the expected error.", + ); + }, +}; + +//////////////// + +export const dh_exchange_test = { + test(ctrl, env, ctx) { + const alice = crypto.createDiffieHellmanGroup("modp14"); + const bob = crypto.createDiffieHellmanGroup("modp14"); + alice.generateKeys(); + bob.generateKeys(); + const aSecret = alice.computeSecret(bob.getPublicKey()).toString("hex"); + const bSecret = bob.computeSecret(alice.getPublicKey()).toString("hex"); + assert.strictEqual(aSecret, bSecret); + }, +}; + +//////////////// + +// This test verifies padding with leading zeroes for shared +// secrets that are strictly smaller than the modulus (prime). +// See: +// RFC 4346: https://www.ietf.org/rfc/rfc4346.txt +// https://github.com/nodejs/node-v0.x-archive/issues/7906 +// https://github.com/nodejs/node-v0.x-archive/issues/5239 +// +// In FIPS mode OPENSSL_DH_FIPS_MIN_MODULUS_BITS = 1024, meaning we need +// a FIPS-friendly >= 1024 bit prime, we can use MODP 14 from RFC 3526: +// https://www.ietf.org/rfc/rfc3526.txt +// +// We can generate appropriate values with this code: +// +// crypto = require('crypto'); +// +// for (;;) { +// var a = crypto.getDiffieHellman('modp14'), +// var b = crypto.getDiffieHellman('modp14'); +// +// a.generateKeys(); +// b.generateKeys(); +// +// var aSecret = a.computeSecret(b.getPublicKey()).toString('hex'); +// console.log("A public: " + a.getPublicKey().toString('hex')); +// console.log("A private: " + a.getPrivateKey().toString('hex')); +// console.log("B public: " + b.getPublicKey().toString('hex')); +// console.log("B private: " + b.getPrivateKey().toString('hex')); +// console.log("A secret: " + aSecret); +// console.log('-------------------------------------------------'); +// if(aSecret.substring(0,2) === "00") { +// console.log("found short key!"); +// return; +// } +// } + +const apub = + "5484455905d3eff34c70980e871f27f05448e66f5a6efbb97cbcba4e927196c2bd9ea272cded91\ +10a4977afa8d9b16c9139a444ed2d954a794650e5d7cb525204f385e1af81530518563822ecd0f9\ +524a958d02b3c269e79d6d69850f0968ad567a4404fbb0b19efc8bc73e267b6136b88cafb33299f\ +f7c7cace3ffab1a88c2c9ee841f88b4c3679b4efc465f5c93cca11d487be57373e4c5926f634c4e\ +efee6721d01db91cd66321615b2522f96368dbc818875d422140d0edf30bdb97d9721feddcb9ff6\ +453741a4f687ee46fc54bf1198801f1210ac789879a5ee123f79e2d2ce1209df2445d32166bc9e4\ +8f89e944ec9c3b2e16c8066cd8eebd4e33eb941"; +const bpub = + "3fca64510e36bc7da8a3a901c7b74c2eabfa25deaf7cbe1d0c50235866136ad677317279e1fb0\ +06e9c0a07f63e14a3363c8e016fbbde2b2c7e79fed1cc3e08e95f7459f547a8cd0523ee9dc744d\ +e5a956d92b937db4448917e1f6829437f05e408ee7aea70c0362b37370c7c75d14449d8b2d2133\ +04ac972302d349975e2265ca7103cfebd019d9e91234d638611abd049014f7abf706c1c5da6c88\ +788a1fdc6cdf17f5fffaf024ce8711a2ebde0b52e9f1cb56224483826d6e5ac6ecfaae07b75d20\ +6e8ac97f5be1a5b68f20382f2a7dac189cf169325c4cf845b26a0cd616c31fec905c5d9035e5f7\ +8e9880c812374ac0f3ca3d365f06e4be526b5affd4b79"; +const apriv = + "62411e34704637d99c6c958a7db32ac22fcafafbe1c33d2cfdb76e12ded41f38fc16b792b9041\ +2e4c82755a3815ba52f780f0ee296ad46e348fc4d1dcd6b64f4eea1b231b2b7d95c5b1c2e26d34\ +83520558b9860a6eb668f01422a54e6604aa7702b4e67511397ef3ecb912bff1a83899c5a5bfb2\ +0ee29249a91b8a698e62486f7009a0e9eaebda69d77ecfa2ca6ba2db6c8aa81759c8c90c675979\ +08c3b3e6fc60668f7be81cce6784482af228dd7f489005253a165e292802cfd0399924f6c56827\ +7012f68255207722355634290acc7fddeefbba75650a85ece95b6a12de67eac016ba78960108dd\ +5dbadfaa43cc9fed515a1f307b7d90ae0623bc7b8cefb"; +const secret = + "00c37b1e06a436d6717816a40e6d72907a6f255638b93032267dcb9a5f0b4a9aa0236f3dce63b\ +1c418c60978a00acd1617dfeecf1661d8a3fafb4d0d8824386750f4853313400e7e4afd22847e4\ +fa56bc9713872021265111906673b38db83d10cbfa1dea3b6b4c97c8655f4ae82125281af7f234\ +8916a15c6f95649367d169d587697480df4d10b381479e86d5518b520d9d8fb764084eab518224\ +dc8fe984ddaf532fc1531ce43155fa0ab32532bf1ece5356b8a3447b5267798a904f16f3f4e635\ +597adc0179d011132dcffc0bbcb0dd2c8700872f8663ec7ddd897c659cc2efebccc73f38f0ec96\ +8612314311231f905f91c63a1aea52e0b60cead8b57df"; + +export const dh_padding_test = { + test(ctrl, env, ctx) { + /* FIPS-friendly 2048 bit prime */ + const p = crypto.createDiffieHellman( + crypto.getDiffieHellman("modp14").getPrime(), + ); + + p.setPublicKey(apub, "hex"); + p.setPrivateKey(apriv, "hex"); + + assert.strictEqual( + p.computeSecret(bpub, "hex", "hex").toString("hex"), + secret, + ); + }, +}; + +export const dhKeygenTest = { + test() { + // FIPS-mode BoringSSL mandates keys of at least 1024 bits. RFC 8270 recommends that sizes of + // at least 2048 bits should be used, 1024-bit primes are sufficient for these tests though. + const size = 1024; + const dh1 = crypto.createDiffieHellman(size); + const p1 = dh1.getPrime("buffer"); + const dh2 = crypto.createDiffieHellman(p1, "buffer"); + const key1 = dh1.generateKeys(); + const key2 = dh2.generateKeys("hex"); + const secret1 = dh1.computeSecret(key2, "hex", "base64"); + const secret2 = dh2.computeSecret(key1, "latin1", "buffer"); + + // Test Diffie-Hellman with two parties sharing a secret, + // using various encodings as we go along + assert.strictEqual(secret2.toString("base64"), secret1); + + assert.strictEqual(dh1.verifyError, 0); + assert.strictEqual(dh2.verifyError, 0); + + // Create "another dh1" using generated keys from dh1, + // and compute secret again + const dh3 = crypto.createDiffieHellman(p1, "buffer"); + const privkey1 = dh1.getPrivateKey(); + dh3.setPublicKey(key1); + dh3.setPrivateKey(privkey1); + + assert.deepStrictEqual(dh1.getPrime(), dh3.getPrime()); + assert.deepStrictEqual(dh1.getGenerator(), dh3.getGenerator()); + assert.deepStrictEqual(dh1.getPublicKey(), dh3.getPublicKey()); + assert.deepStrictEqual(dh1.getPrivateKey(), dh3.getPrivateKey()); + assert.strictEqual(dh3.verifyError, 0); + + const secret3 = dh3.computeSecret(key2, "hex", "base64"); + + assert.strictEqual(secret1, secret3); + + // computeSecret works without a public key set at all. + const dh4 = crypto.createDiffieHellman(p1, "buffer"); + dh4.setPrivateKey(privkey1); + + assert.deepStrictEqual(dh1.getPrime(), dh4.getPrime()); + assert.deepStrictEqual(dh1.getGenerator(), dh4.getGenerator()); + assert.deepStrictEqual(dh1.getPrivateKey(), dh4.getPrivateKey()); + assert.strictEqual(dh4.verifyError, 0); + + const secret4 = dh4.computeSecret(key2, "hex", "base64"); + + assert.strictEqual(secret1, secret4); + + assert.throws( + () => { + dh3.computeSecret(""); + }, + { name: "Error" }, + ); + }, +}; + +export const ecdh = { + test() { + const curves = crypto.getCurves(); + curves.forEach((i) => { + const alice = crypto.createECDH(i); + const bob = crypto.createECDH(i); + + alice.generateKeys(); + bob.generateKeys(); + + const aliceSecret = alice.computeSecret(bob.getPublicKey(), null, "hex"); + const bobSecret = bob.computeSecret(alice.getPublicKey(), null, "hex"); + + assert.strictEqual(aliceSecret, bobSecret); + }); + }, +}; + +export const ecdhConvertKey = { + test() { + const ecdh = crypto.createECDH("prime256v1"); + ecdh.generateKeys(); + + const compressedKey = ecdh.getPublicKey("hex", "compressed"); + + const uncompressedKey = crypto.ECDH.convertKey( + compressedKey, + "prime256v1", + "hex", + "hex", + "uncompressed", + ); + + // The converted key and the uncompressed public key should be the same + assert.strictEqual(uncompressedKey, ecdh.getPublicKey("hex")); + }, +}; + +export const statelessDh = { + test() { + // DH keygen is currently unsupported by the boringssl+fips + // we use internally. This test works for workerd but fails + // on the internal run. + // const pair1 = crypto.generateKeyPairSync('dh', { + // prime: Buffer.from([31, 0, 0, 0, 0, 1]), + // }); + // const pair2 = crypto.generateKeyPairSync('dh', { + // prime: Buffer.from([31, 0, 0, 0, 0, 1]), + // }); + // const sec1 = crypto.diffieHellman({ + // publicKey: pair1.publicKey, + // privateKey: pair2.privateKey, + // }); + // const sec2 = crypto.diffieHellman({ + // publicKey: pair2.publicKey, + // privateKey: pair1.privateKey, + // }); + // assert.deepStrictEqual(sec1, sec2); + }, +}; + +export const statelessDhX25591 = { + test() { + const pair1 = crypto.generateKeyPairSync("x25519"); + const pair2 = crypto.generateKeyPairSync("x25519"); + const sec1 = crypto.diffieHellman({ + publicKey: pair1.publicKey, + privateKey: pair2.privateKey, + }); + const sec2 = crypto.diffieHellman({ + publicKey: pair2.publicKey, + privateKey: pair1.privateKey, + }); + assert.deepStrictEqual(sec1, sec2); + }, +}; + +export const statelessDhEc = { + test() { + // The Boringssl-based implementation currently does not support + // the mechanisms for stateless dh using named EC curve. + const pair1 = crypto.generateKeyPairSync("ec", { namedCurve: "secp224r1" }); + const pair2 = crypto.generateKeyPairSync("ec", { namedCurve: "secp224r1" }); + assert.throws( + () => { + crypto.diffieHellman({ + publicKey: pair1.publicKey, + privateKey: pair2.privateKey, + }); + }, + { + message: /Failed to derive/, + }, + ); + }, +}; + +export const helloTest = { + test() { + const v2 = crypto.createDiffieHellman("hello"); + assert.throws(() => v2.getPrivateKey(v2, "hello", v2, v2, "hello"), { + message: /No private key/, + }); + }, +}; + +export const DhLargeGeneratorParam = { + async test() { + const prime = Buffer.from([ + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc9, 0x0f, 0xda, + 0xa2, 0x21, 0x68, 0xc2, 0x34, 0xc4, 0xc6, 0x62, 0x8b, 0x80, 0xdc, 0x1c, + 0xd1, 0x29, 0x02, 0x4e, 0x08, 0x8a, 0x67, 0xcc, 0x74, + ]); + + const generator = new Uint8Array(100 * 1024 * 1024); + generator.fill(0x02); + + assert.throws(() => crypto.createDiffieHellman(prime, generator), { + name: "RangeError", + message: /generator is too large/, + }); + }, +}; + +// Test that invalid public keys are rejected by setPublicKey and computeSecret. +export const dhPubKeyValidation = { + test() { + const dh = crypto.createDiffieHellman(1024); + dh.generateKeys(); + const prime = dh.getPrime(); + + // Public key of 0 should be rejected (too small) + const dh2 = crypto.createDiffieHellman(prime); + dh2.generateKeys(); + assert.throws(() => dh2.setPublicKey(Buffer.from([0x00])), { + message: /too small|invalid public key/, + }); + + // Public key of 1 should be rejected (too small) + assert.throws(() => dh2.setPublicKey(Buffer.from([0x01])), { + message: /too small|invalid public key/, + }); + + // Public key equal to the prime should be rejected (too large) + assert.throws(() => dh2.setPublicKey(prime), { + message: /too large|invalid public key/, + }); + + // computeSecret should also reject invalid peer keys proactively + assert.throws(() => dh2.computeSecret(Buffer.from([0x00])), { + message: /too small|invalid peer public key/, + }); + assert.throws(() => dh2.computeSecret(Buffer.from([0x01])), { + message: /too small|invalid peer public key/, + }); + assert.throws(() => dh2.computeSecret(prime), { + message: /too large|invalid peer public key/, + }); + + // Valid exchange should still work + const dh3 = crypto.createDiffieHellman(prime); + dh3.generateKeys(); + const secret1 = dh.computeSecret(dh3.getPublicKey()); + const secret2 = dh3.computeSecret(dh.getPublicKey()); + assert.deepStrictEqual(secret1, secret2); + }, +}; diff --git a/src/compat/crypto/upstream-hash.fixture.mjs b/src/compat/crypto/upstream-hash.fixture.mjs new file mode 100644 index 0000000..6b33971 --- /dev/null +++ b/src/compat/crypto/upstream-hash.fixture.mjs @@ -0,0 +1,342 @@ +// Copyright (c) 2017-2023 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// Adapted from Node.js. Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +"use strict"; + +import { Buffer } from "node:buffer"; +import * as assert from "node:assert"; +import * as crypto from "./hash.js"; +import * as stream from "../stream/index.js"; + +let cryptoType; +let digest; + +export const hash_correctness_tests = { + async test(ctrl, env, ctx) { + const a1 = crypto.createHash("sha1").update("Test123").digest("hex"); + const a2 = crypto.createHash("sha256").update("Test123").digest("base64"); + const a3 = crypto.createHash("sha512").update("Test123").digest(); // buffer + const a4 = crypto.createHash("sha1").update("Test123").digest("buffer"); + + // stream interface + let a5 = crypto.createHash("sha512"); + a5.end("Test123"); + a5 = a5.read(); + + let a6 = crypto.createHash("sha512"); + a6.write("Te"); + a6.write("st"); + a6.write("123"); + a6.end(); + a6 = a6.read(); + + let a7 = crypto.createHash("sha512"); + a7.end(); + a7 = a7.read(); + + let a8 = crypto.createHash("sha512"); + a8.write(""); + a8.end(); + a8 = a8.read(); + + cryptoType = "md5"; + digest = "latin1"; + const a0 = crypto.createHash(cryptoType).update("Test123").digest(digest); + assert.strictEqual( + a0, + // The pinned fixture spells some bytes as caret notation; use the + // equivalent hex vector confirmed against the pinned workerd runtime. + Buffer.from("68eacb97d86f0c4621fa2b0e17cabd8c", "hex").toString("latin1"), + `${cryptoType} with ${digest} digest failed to evaluate to expected hash`, + ); + + cryptoType = "md5"; + digest = "hex"; + assert.strictEqual( + a1, + "8308651804facb7b9af8ffc53a33a22d6a1c8ac2", + `${cryptoType} with ${digest} digest failed to evaluate to expected hash`, + ); + cryptoType = "sha256"; + digest = "base64"; + assert.strictEqual( + a2, + "2bX1jws4GYKTlxhloUB09Z66PoJZW+y+hq5R8dnx9l4=", + `${cryptoType} with ${digest} digest failed to evaluate to expected hash`, + ); + + cryptoType = "sha512"; + digest = "latin1"; + assert.deepStrictEqual( + a3, + Buffer.from( + "c12834f1031f6497214f27d4432f26517ad494156cb88d512bdb1dc4b57db2d692a3dfa269a19b0a0a2a0fd7d6a2a885e33c839c93c206da30a187392847ed27", + "hex", + ), + `${cryptoType} with ${digest} digest failed to evaluate to expected hash`, + ); + + cryptoType = "sha1"; + digest = "hex"; + assert.deepStrictEqual( + a4, + Buffer.from("8308651804facb7b9af8ffc53a33a22d6a1c8ac2", "hex"), + `${cryptoType} with ${digest} digest failed to evaluate to expected hash`, + ); + + cryptoType = "sha1"; + digest = "hex"; + assert.deepStrictEqual( + a4, + Buffer.from("8308651804FACB7B9AF8FFC53A33A22D6A1C8AC2", "hex"), + `${cryptoType} with ${digest} digest failed to evaluate to expected hash`, + ); + + // Stream interface should produce the same result. + assert.deepStrictEqual(a5, a3); + assert.deepStrictEqual(a6, a3); + assert.notStrictEqual(a7, undefined); + assert.notStrictEqual(a8, undefined); + + // Test multiple updates to same hash + const h1 = crypto.createHash("sha1").update("Test123").digest("hex"); + const h2 = crypto + .createHash("sha1") + .update("Test") + .update("123") + .digest("hex"); + assert.strictEqual(h1, h2); + }, +}; + +export const hash_error_test = { + async test(ctrl, env, ctx) { + // Issue https://github.com/nodejs/node-v0.x-archive/issues/2227: unknown digest + // method should throw an error. + assert.throws(function () { + crypto.createHash("xyzzy"); + }, /Digest method not supported/); + + // Issue https://github.com/nodejs/node/issues/9819: throwing encoding used to + // segfault. + assert.throws( + () => + crypto.createHash("sha256").digest({ + toString: () => { + throw new Error("boom"); + }, + }), + { + name: "Error", + message: "boom", + }, + ); + + // Issue https://github.com/nodejs/node/issues/25487: error message for invalid + // arg type to update method should include all possible types + assert.throws(() => crypto.createHash("sha256").update(), { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + }); + + // Default UTF-8 encoding + const hutf8 = crypto + .createHash("sha512") + .update("УТФ-8 text") + .digest("hex"); + assert.strictEqual( + hutf8, + "4b21bbd1a68e690a730ddcb5a8bc94ead9879ffe82580767ad7ec6fa8ba2dea6" + + "43a821af66afa9a45b6a78c712fecf0e56dc7f43aef4bcfc8eb5b4d8dca6ea5b", + ); + + assert.notStrictEqual( + hutf8, + crypto.createHash("sha512").update("УТФ-8 text", "latin1").digest("hex"), + ); + + const h3 = crypto.createHash("sha256"); + h3.digest(); + + assert.throws(() => h3.digest(), { + code: "ERR_CRYPTO_HASH_FINALIZED", + name: "Error", + }); + + assert.throws(() => h3.update("foo"), { + code: "ERR_CRYPTO_HASH_FINALIZED", + name: "Error", + }); + + assert.strictEqual( + crypto.createHash("sha256").update("test").digest("ucs2"), + crypto.createHash("sha256").update("test").digest().toString("ucs2"), + ); + + assert.throws(() => crypto.createHash(), { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + message: + 'The "algorithm" argument must be of type string. ' + + "Received undefined", + }); + + { + const Hash = crypto.Hash; + const instance = crypto.Hash("sha256"); + assert.ok( + instance instanceof Hash, + "Hash is expected to return a new instance" + + " when called without `new`", + ); + } + + // shake*-based tests for XOF hash function are not supported in FIPS and have been removed. + { + // Non-XOF hash functions should accept valid outputLength options as well. + assert.strictEqual( + crypto.createHash("sha224", { outputLength: 28 }).digest("hex"), + "d14a028c2a3a2bc9476102bb288234c4" + "15a2b01f828ea62ac5b3e42f", + ); + + // Passing invalid sizes should throw during creation. + assert.throws( + () => { + crypto.createHash("sha256", { outputLength: 28 }); + }, + { + name: "Error", + }, + ); + + for (const outputLength of [null, {}, "foo", false]) { + assert.throws(() => crypto.createHash("sha256", { outputLength }), { + code: "ERR_INVALID_ARG_TYPE", + }); + } + + for (const outputLength of [-1, 0.5, Infinity, 2 ** 90]) { + assert.throws(() => crypto.createHash("sha256", { outputLength }), { + code: "ERR_OUT_OF_RANGE", + }); + } + } + }, +}; + +export const hash_copy_test = { + async test(ctrl, env, ctx) { + const h = crypto.createHash("sha512"); + h.digest(); + assert.throws(() => h.copy(), { code: "ERR_CRYPTO_HASH_FINALIZED" }); + assert.throws(() => h.digest(), { code: "ERR_CRYPTO_HASH_FINALIZED" }); + + const a = crypto.createHash("sha512").update("abc"); + const b = a.copy(); + const c = b.copy().update("def"); + const d = crypto.createHash("sha512").update("abcdef"); + assert.strictEqual(a.digest("hex"), b.digest("hex")); + assert.strictEqual(c.digest("hex"), d.digest("hex")); + }, +}; + +export const hash_pipe_test = { + async test(ctrl, env, ctx) { + const p = Promise.withResolvers(); + + const s = new stream.PassThrough(); + const h = crypto.createHash("sha512"); + const expect = + "fba055c6fd0c5b6645407749ed7a8b41" + + "b8f629f2163c3ca3701d864adabda1f8" + + "93c37bf82b22fdd151ba8e357f611da4" + + "88a74b6a5525dd9b69554c6ce5138ad7"; + + s.pipe(h) + .on("data", function (c) { + // Calling digest() after piping into a stream with SHA3 should not cause + // a segmentation fault, see https://github.com/nodejs/node/issues/28245. + if (c !== expect || h.digest("hex") !== expect) { + p.reject("Unexpected value for stream-based hash"); + } + p.resolve(); + }) + .setEncoding("hex"); + + s.end("aoeu"); + await p.promise; + }, +}; + +export const hash_one_shot = { + test() { + assert.strictEqual( + crypto.hash("sha1", "Node.js"), + "10b3493287f831e81a438811a1ffba01f8cec4b7", + ); + + const check = Buffer.from( + "10b3493287f831e81a438811a1ffba01f8cec4b7", + "hex", + ); + const base64 = "Tm9kZS5qcw=="; + assert.deepStrictEqual( + crypto.hash("sha1", Buffer.from(base64, "base64"), "buffer"), + check, + ); + + assert.throws(() => crypto.hash(1), { + code: "ERR_INVALID_ARG_TYPE", + }); + assert.throws(() => crypto.hash("sha1", 12), { + code: "ERR_INVALID_ARG_TYPE", + }); + assert.throws(() => crypto.hash("sha1", "test", 123), { + code: "ERR_INVALID_ARG_TYPE", + }); + assert.throws(() => crypto.hash("unknown", "what"), { + message: /Digest method not supported/, + }); + }, +}; + +export const HashCopyAfterDigest = { + async test() { + for (let i = 0; i < 50; i++) { + const hash = crypto.createHash("sha256"); + hash.update("x" + i); + + const kHandle = Object.getOwnPropertySymbols(hash).find( + (s) => s.toString() === "Symbol(kHandle)", + ); + const handle = hash[kHandle]; + handle.digest(); + + assert.throws(() => hash.copy(), { + message: /already been finalized/, + }); + } + }, +}; diff --git a/src/compat/crypto/upstream-hkdf.fixture.mjs b/src/compat/crypto/upstream-hkdf.fixture.mjs new file mode 100644 index 0000000..6e65d75 --- /dev/null +++ b/src/compat/crypto/upstream-hkdf.fixture.mjs @@ -0,0 +1,283 @@ +// Copyright (c) 2017-2023 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// Adapted from Node.js. Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +"use strict"; + +import { Buffer, kMaxLength } from "node:buffer"; +import * as assert from "node:assert"; + +import { + // createSecretKey, + hkdf, + hkdfSync, + getHashes, + createSecretKey, +} from "./index.js"; + +export const hkdf_error_tests = { + async test(ctrl, env, ctx) { + assert.throws(() => hkdf(), { + code: "ERR_INVALID_ARG_TYPE", + message: /The "digest" argument must be of type string/, + }); + + [1, {}, [], false, Infinity].forEach((i) => { + assert.throws(() => hkdf(i, "a"), { + code: "ERR_INVALID_ARG_TYPE", + message: /^The "digest" argument must be of type string/, + }); + assert.throws(() => hkdfSync(i, "a"), { + code: "ERR_INVALID_ARG_TYPE", + message: /^The "digest" argument must be of type string/, + }); + }); + + [1, {}, [], false, Infinity].forEach((i) => { + assert.throws(() => hkdf("sha256", i), { + code: "ERR_INVALID_ARG_TYPE", + message: /^The "ikm" argument must be /, + }); + assert.throws(() => hkdfSync("sha256", i), { + code: "ERR_INVALID_ARG_TYPE", + message: /^The "ikm" argument must be /, + }); + }); + + [1, {}, [], false, Infinity].forEach((i) => { + assert.throws(() => hkdf("sha256", "secret", i), { + code: "ERR_INVALID_ARG_TYPE", + message: /^The "salt" argument must be /, + }); + assert.throws(() => hkdfSync("sha256", "secret", i), { + code: "ERR_INVALID_ARG_TYPE", + message: /^The "salt" argument must be /, + }); + }); + + [1, {}, [], false, Infinity].forEach((i) => { + assert.throws(() => hkdf("sha256", "secret", "salt", i), { + code: "ERR_INVALID_ARG_TYPE", + message: /^The "info" argument must be /, + }); + assert.throws(() => hkdfSync("sha256", "secret", "salt", i), { + code: "ERR_INVALID_ARG_TYPE", + message: /^The "info" argument must be /, + }); + }); + + ["test", {}, [], false].forEach((i) => { + assert.throws(() => hkdf("sha256", "secret", "salt", "info", i), { + code: "ERR_INVALID_ARG_TYPE", + message: /^The "length" argument must be of type number/, + }); + assert.throws(() => hkdfSync("sha256", "secret", "salt", "info", i), { + code: "ERR_INVALID_ARG_TYPE", + message: /^The "length" argument must be of type number/, + }); + }); + + assert.throws(() => hkdf("sha256", "secret", "salt", "info", -1), { + code: "ERR_OUT_OF_RANGE", + }); + assert.throws(() => hkdfSync("sha256", "secret", "salt", "info", -1), { + code: "ERR_OUT_OF_RANGE", + }); + assert.throws( + () => hkdf("sha256", "secret", "salt", "info", kMaxLength + 1), + { + code: "ERR_OUT_OF_RANGE", + }, + ); + assert.throws( + () => hkdfSync("sha256", "secret", "salt", "info", kMaxLength + 1), + { + code: "ERR_OUT_OF_RANGE", + }, + ); + + { + const p = Promise.withResolvers(); + hkdf("unknown", "a", "", "", 10, (err, asyncResult) => { + if (err) { + return p.reject(err); + } + p.resolve(); + }); + await assert.rejects(p.promise); + } + assert.throws(() => hkdfSync("unknown", "a", "", "", 10), { + name: "TypeError", + }); + + assert.throws(() => hkdf("unknown", "a", "", Buffer.alloc(1025), 10), { + code: "ERR_OUT_OF_RANGE", + }); + assert.throws(() => hkdfSync("unknown", "a", "", Buffer.alloc(1025), 10), { + code: "ERR_OUT_OF_RANGE", + }); + + { + const p = Promise.withResolvers(); + hkdf("sha512", "a", "", "", 64 * 255 + 1, (err, asyncResult) => { + if (err) { + return p.reject(err); + } + p.resolve(); + }); + await assert.rejects(p.promise); + } + assert.throws(() => hkdfSync("sha512", "a", "", "", 64 * 255 + 1), { + name: "RangeError", + }); + }, +}; + +async function hkdfTestAlg([hash, secret, salt, info, length]) { + { + const syncResult = hkdfSync(hash, secret, salt, info, length); + assert.ok(syncResult instanceof ArrayBuffer); + let is_async = false; + const p = Promise.withResolvers(); + + hkdf(hash, secret, salt, info, length, (err, asyncResult) => { + if (err) return p.reject(err); + assert.ok(is_async); + assert.ok(asyncResult instanceof ArrayBuffer); + assert.deepStrictEqual(syncResult, asyncResult); + p.resolve(); + }); + // Keep this after the hkdf call above. This verifies + // that the callback is invoked asynchronously. + is_async = true; + await p.promise; + } + + { + const buf_secret = Buffer.from(secret); + const buf_salt = Buffer.from(salt); + const buf_info = Buffer.from(info); + const p = Promise.withResolvers(); + + const syncResult = hkdfSync(hash, buf_secret, buf_salt, buf_info, length); + hkdf(hash, buf_secret, buf_salt, buf_info, length, (err, asyncResult) => { + if (err) return p.reject(err); + assert.deepStrictEqual(syncResult, asyncResult); + p.resolve(); + }); + await p.promise; + } + + // Disabled for now, requires KeyObject support + // { + // const key_secret = createSecretKey(Buffer.from(secret)); + // const buf_salt = Buffer.from(salt); + // const buf_info = Buffer.from(info); + // const p = Promise.withResolvers(); + // + // const syncResult = hkdfSync(hash, key_secret, buf_salt, buf_info, length); + // hkdf(hash, key_secret, buf_salt, buf_info, length, (err, asyncResult) => { + // if (err) return p.reject(err); + // assert.deepStrictEqual(syncResult, asyncResult); + // p.resolve(); + // }); + // await p.promise; + // } + + { + const p = Promise.withResolvers(); + const ta_secret = new Uint8Array(Buffer.from(secret)); + const ta_salt = new Uint16Array(Buffer.from(salt)); + const ta_info = new Uint32Array(Buffer.from(info)); + + const syncResult = hkdfSync(hash, ta_secret, ta_salt, ta_info, length); + hkdf(hash, ta_secret, ta_salt, ta_info, length, (err, asyncResult) => { + if (err) return p.reject(err); + assert.deepStrictEqual(syncResult, asyncResult); + p.resolve(); + }); + await p.promise; + + const syncResultBuf = hkdfSync( + hash, + ta_secret.buffer, + ta_salt.buffer, + ta_info.buffer, + length, + ); + assert.deepStrictEqual(syncResult, syncResultBuf); + } + + { + const p = Promise.withResolvers(); + const ta_secret = new Uint8Array(Buffer.from(secret)); + const a_salt = new ArrayBuffer(0); + const a_info = new ArrayBuffer(1); + + const syncResult = hkdfSync(hash, ta_secret.buffer, a_salt, a_info, length); + hkdf(hash, ta_secret, a_salt, a_info, length, (err, asyncResult) => { + if (err) return p.reject(err); + assert.deepStrictEqual(syncResult, asyncResult); + p.resolve(); + }); + await p.promise; + } +} + +export const hkdf_correctness_tests = { + async test(ctrl, env, ctx) { + const algorithms = [ + ["sha256", "secret", "salt", "info", 10], + ["sha256", "", "", "", 10], + ["sha256", "", "salt", "", 10], + ["sha512", "secret", "salt", "", 15], + ]; + for (const element of algorithms) { + await hkdfTestAlg(element); + } + + getHashes().forEach((hash) => { + assert.ok(hkdfSync(hash, "key", "salt", "info", 5)); + }); + }, +}; + +export const hkdf_secret_key = { + async test() { + const check = "ae1db23b1051ea6bc1f182e5114d869c1b5e0b8d"; + + const key = createSecretKey("abcdef01020304", "hex"); + const result = hkdfSync("sha256", key, "hello", "there", 20); + assert.strictEqual(Buffer.from(result).toString("hex"), check); + + const { promise, resolve, reject } = Promise.withResolvers(); + + hkdf("sha256", key, "hello", "there", 20, (err, result) => { + if (err) return reject(err); + resolve(Buffer.from(result).toString("hex")); + }); + + assert.strictEqual(await promise, check); + }, +}; diff --git a/src/compat/crypto/upstream-hmac.fixture.mjs b/src/compat/crypto/upstream-hmac.fixture.mjs new file mode 100644 index 0000000..a16ba12 --- /dev/null +++ b/src/compat/crypto/upstream-hmac.fixture.mjs @@ -0,0 +1,558 @@ +// Copyright (c) 2017-2023 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// Adapted from Node.js. Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +"use strict"; + +import { Buffer } from "node:buffer"; +import * as assert from "node:assert"; +import { Hmac, createHmac } from "./hash.js"; +import { createSecretKey } from "./keys.js"; +const crypto = { Hmac, createHmac, createSecretKey }; + +export const hmac_error_tests = { + async test(ctrl, env, ctx) { + { + const Hmac = crypto.Hmac; + const instance = crypto.Hmac("sha256", "Node"); + assert.ok( + instance instanceof Hmac, + "Hmac is expected to return a new instance" + + " when called without `new`", + ); + } + + assert.throws(() => crypto.createHmac(null), { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + message: 'The "hmac" argument must be of type string. Received null', + }); + + assert.throws( + () => + crypto.createHmac("sha256", "key").digest({ + toString: () => { + throw new Error("boom"); + }, + }), + { + name: "Error", + message: "boom", + }, + ); + + assert.throws(() => crypto.createHmac("sha1", null), { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + }); + }, +}; + +function testHmac(algo, key, data, expected) { + if (!Array.isArray(data)) data = [data]; + + // If the key is a Buffer, test Hmac with a key object as well. + const keyWrappers = [ + (key) => key, + ...(typeof key === "string" ? [] : [crypto.createSecretKey]), + ]; + + for (const keyWrapper of keyWrappers) { + const hmac = crypto.createHmac(algo, keyWrapper(key)); + for (const chunk of data) hmac.update(chunk); + const actual = hmac.digest("hex"); + assert.strictEqual(actual, expected); + } +} + +export const hmac_correctness_tests = { + async test(ctrl, env, ctx) { + { + // Test HMAC with multiple updates. + testHmac( + "sha1", + "Node", + ["some data", "to hmac"], + "19fd6e1ba73d9ed2224dd5094a71babe85d9a892", + ); + } + + // Test HMAC (Wikipedia Test Cases) + const wikipedia = [ + { + key: "key", + data: "The quick brown fox jumps over the lazy dog", + hmac: { + // HMACs lifted from Wikipedia. + md5: "80070713463e7749b90c2dc24911e275", + sha1: "de7c9b85b8b78aa6bc8a7a36f70a90701c9db4d9", + sha256: + "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc" + + "2d1a3cd8", + }, + }, + { + key: "key", + data: "", + hmac: { + // Intermediate test to help debugging. + md5: "63530468a04e386459855da0063b6596", + sha1: "f42bb0eeb018ebbd4597ae7213711ec60760843f", + sha256: + "5d5d139563c95b5967b9bd9a8c9b233a9dedb45072794cd232dc1b74" + + "832607d0", + }, + }, + { + key: "", + data: "The quick brown fox jumps over the lazy dog", + hmac: { + // Intermediate test to help debugging. + md5: "ad262969c53bc16032f160081c4a07a0", + sha1: "2ba7f707ad5f187c412de3106583c3111d668de8", + sha256: + "fb011e6154a19b9a4c767373c305275a5a69e8b68b0b4c9200c383dc" + + "ed19a416", + }, + }, + { + key: "", + data: "", + hmac: { + // HMACs lifted from Wikipedia. + md5: "74e6f7298a9c2d168935f58c001bad88", + sha1: "fbdb1d1b18aa6c08324b7d64b71fb76370690e1d", + sha256: + "b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c71214" + + "4292c5ad", + }, + }, + ]; + + for (const { key, data, hmac } of wikipedia) { + for (const hash in hmac) testHmac(hash, key, data, hmac[hash]); + } + + // Test HMAC-SHA-* (rfc 4231 Test Cases) + const rfc4231 = [ + { + key: Buffer.from("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b", "hex"), + data: Buffer.from("4869205468657265", "hex"), // 'Hi There' + hmac: { + sha224: "896fb1128abbdf196832107cd49df33f47b4b1169912ba4f53684b22", + sha256: + "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c" + + "2e32cff7", + sha384: + "afd03944d84895626b0825f4ab46907f15f9dadbe4101ec682aa034c" + + "7cebc59cfaea9ea9076ede7f4af152e8b2fa9cb6", + sha512: + "87aa7cdea5ef619d4ff0b4241a1d6cb02379f4e2ce4ec2787ad0b305" + + "45e17cdedaa833b7d6b8a702038b274eaea3f4e4be9d914eeb61f170" + + "2e696c203a126854", + }, + }, + { + key: Buffer.from("4a656665", "hex"), // 'Jefe' + data: Buffer.from( + "7768617420646f2079612077616e7420666f72206e6f74686" + "96e673f", + "hex", + ), // 'what do ya want for nothing?' + hmac: { + sha224: "a30e01098bc6dbbf45690f3a7e9e6d0f8bbea2a39e6148008fd05e44", + sha256: + "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b9" + + "64ec3843", + sha384: + "af45d2e376484031617f78d2b58a6b1b9c7ef464f5a01b47e42ec373" + + "6322445e8e2240ca5e69e2c78b3239ecfab21649", + sha512: + "164b7a7bfcf819e2e395fbe73b56e0a387bd64222e831fd610270cd7" + + "ea2505549758bf75c05a994a6d034f65f8f0e6fdcaeab1a34d4a6b4b" + + "636e070a38bce737", + }, + }, + { + key: Buffer.from("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "hex"), + data: Buffer.from( + "ddddddddddddddddddddddddddddddddddddddddddddddddd" + + "ddddddddddddddddddddddddddddddddddddddddddddddddddd", + "hex", + ), + hmac: { + sha224: "7fb3cb3588c6c1f6ffa9694d7d6ad2649365b0c1f65d69d1ec8333ea", + sha256: + "773ea91e36800e46854db8ebd09181a72959098b3ef8c122d9635514" + + "ced565fe", + sha384: + "88062608d3e6ad8a0aa2ace014c8a86f0aa635d947ac9febe83ef4e5" + + "5966144b2a5ab39dc13814b94e3ab6e101a34f27", + sha512: + "fa73b0089d56a284efb0f0756c890be9b1b5dbdd8ee81a3655f83e33" + + "b2279d39bf3e848279a722c806b485a47e67c807b946a337bee89426" + + "74278859e13292fb", + }, + }, + { + key: Buffer.from( + "0102030405060708090a0b0c0d0e0f10111213141516171819", + "hex", + ), + data: Buffer.from( + "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdc" + + "dcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "hex", + ), + hmac: { + sha224: "6c11506874013cac6a2abc1bb382627cec6a90d86efc012de7afec5a", + sha256: + "82558a389a443c0ea4cc819899f2083a85f0faa3e578f8077a2e3ff4" + + "6729665b", + sha384: + "3e8a69b7783c25851933ab6290af6ca77a9981480850009cc5577c6e" + + "1f573b4e6801dd23c4a7d679ccf8a386c674cffb", + sha512: + "b0ba465637458c6990e5a8c5f61d4af7e576d97ff94b872de76f8050" + + "361ee3dba91ca5c11aa25eb4d679275cc5788063a5f19741120c4f2d" + + "e2adebeb10a298dd", + }, + }, + + { + key: Buffer.from("0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c", "hex"), + // 'Test With Truncation' + data: Buffer.from("546573742057697468205472756e636174696f6e", "hex"), + hmac: { + sha224: "0e2aea68a90c8d37c988bcdb9fca6fa8", + sha256: "a3b6167473100ee06e0c796c2955552b", + sha384: "3abf34c3503b2a23a46efc619baef897", + sha512: "415fad6271580a531d4179bc891d87a6", + }, + truncate: true, + }, + { + key: Buffer.from( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaa", + "hex", + ), + // 'Test Using Larger Than Block-Size Key - Hash Key First' + data: Buffer.from( + "54657374205573696e67204c6172676572205468616e20426" + + "c6f636b2d53697a65204b6579202d2048617368204b657920" + + "4669727374", + "hex", + ), + hmac: { + sha224: "95e9a0db962095adaebe9b2d6f0dbce2d499f112f2d2b7273fa6870e", + sha256: + "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f" + + "0ee37f54", + sha384: + "4ece084485813e9088d2c63a041bc5b44f9ef1012a2b588f3cd11f05" + + "033ac4c60c2ef6ab4030fe8296248df163f44952", + sha512: + "80b24263c7c1a3ebb71493c1dd7be8b49b46d1f41b4aeec1121b0137" + + "83f8f3526b56d037e05f2598bd0fd2215d6a1e5295e64f73f63f0aec" + + "8b915a985d786598", + }, + }, + { + key: Buffer.from( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaa", + "hex", + ), + // 'This is a test using a larger than block-size key and a larger ' + + // 'than block-size data. The key needs to be hashed before being ' + + // 'used by the HMAC algorithm.' + data: Buffer.from( + "5468697320697320612074657374207573696e672061206c6" + + "172676572207468616e20626c6f636b2d73697a65206b6579" + + "20616e642061206c6172676572207468616e20626c6f636b2" + + "d73697a6520646174612e20546865206b6579206e65656473" + + "20746f20626520686173686564206265666f7265206265696" + + "e6720757365642062792074686520484d414320616c676f72" + + "6974686d2e", + "hex", + ), + hmac: { + sha224: "3a854166ac5d9f023f54d517d0b39dbd946770db9c2b95c9f6f565d1", + sha256: + "9b09ffa71b942fcb27635fbcd5b0e944bfdc63644f0713938a7f5153" + + "5c3a35e2", + sha384: + "6617178e941f020d351e2f254e8fd32c602420feb0b8fb9adccebb82" + + "461e99c5a678cc31e799176d3860e6110c46523e", + sha512: + "e37b6a775dc87dbaa4dfa9f96e5e3ffddebd71f8867289865df5a32d" + + "20cdc944b6022cac3c4982b10d5eeb55c3e4de15134676fb6de04460" + + "65c97440fa8c6a58", + }, + }, + ]; + + for (let i = 0, l = rfc4231.length; i < l; i++) { + for (const hash in rfc4231[i].hmac) { + const str = crypto.createHmac(hash, rfc4231[i].key); + str.end(rfc4231[i].data); + let strRes = str.read().toString("hex"); + let actual = crypto + .createHmac(hash, rfc4231[i].key) + .update(rfc4231[i].data) + .digest("hex"); + if (rfc4231[i].truncate) { + actual = actual.substr(0, 32); // first 128 bits == 32 hex chars + strRes = strRes.substr(0, 32); + } + const expected = rfc4231[i].hmac[hash]; + assert.strictEqual( + actual, + expected, + `Test HMAC-${hash} rfc 4231 case ${i + 1}: ${actual} must be ${expected}`, + ); + assert.strictEqual( + actual, + strRes, + `Should get same result from stream (hash: ${hash} and case: ${i + 1})` + + ` => ${actual} must be ${strRes}`, + ); + } + } + + // Test HMAC-MD5/SHA1 (rfc 2202 Test Cases) + const rfc2202_md5 = [ + { + key: Buffer.from("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b", "hex"), + data: "Hi There", + hmac: "9294727a3638bb1c13f48ef8158bfc9d", + }, + { + key: "Jefe", + data: "what do ya want for nothing?", + hmac: "750c783e6ab0b503eaa86e310a5db738", + }, + { + key: Buffer.from("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "hex"), + data: Buffer.from( + "ddddddddddddddddddddddddddddddddddddddddddddddddd" + + "ddddddddddddddddddddddddddddddddddddddddddddddddddd", + "hex", + ), + hmac: "56be34521d144c88dbb8c733f0e8b3f6", + }, + { + key: Buffer.from( + "0102030405060708090a0b0c0d0e0f10111213141516171819", + "hex", + ), + data: Buffer.from( + "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdc" + + "dcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" + + "cdcdcdcdcd", + "hex", + ), + hmac: "697eaf0aca3a3aea3a75164746ffaa79", + }, + { + key: Buffer.from("0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c", "hex"), + data: "Test With Truncation", + hmac: "56461ef2342edc00f9bab995690efd4c", + }, + { + key: Buffer.from( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaa", + "hex", + ), + data: "Test Using Larger Than Block-Size Key - Hash Key First", + hmac: "6b1ab7fe4bd7bf8f0b62e6ce61b9d0cd", + }, + { + key: Buffer.from( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaa", + "hex", + ), + data: + "Test Using Larger Than Block-Size Key and Larger Than One " + + "Block-Size Data", + hmac: "6f630fad67cda0ee1fb1f562db3aa53e", + }, + ]; + + for (const { key, data, hmac } of rfc2202_md5) + testHmac("md5", key, data, hmac); + + const rfc2202_sha1 = [ + { + key: Buffer.from("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b", "hex"), + data: "Hi There", + hmac: "b617318655057264e28bc0b6fb378c8ef146be00", + }, + { + key: "Jefe", + data: "what do ya want for nothing?", + hmac: "effcdf6ae5eb2fa2d27416d5f184df9c259a7c79", + }, + { + key: Buffer.from("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "hex"), + data: Buffer.from( + "ddddddddddddddddddddddddddddddddddddddddddddd" + + "ddddddddddddddddddddddddddddddddddddddddddddd" + + "dddddddddd", + "hex", + ), + hmac: "125d7342b9ac11cd91a39af48aa17b4f63f175d3", + }, + { + key: Buffer.from( + "0102030405060708090a0b0c0d0e0f10111213141516171819", + "hex", + ), + data: Buffer.from( + "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdc" + + "dcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" + + "cdcdcdcdcd", + "hex", + ), + hmac: "4c9007f4026250c6bc8414f9bf50c86c2d7235da", + }, + { + key: Buffer.from("0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c", "hex"), + data: "Test With Truncation", + hmac: "4c1a03424b55e07fe7f27be1d58bb9324a9a5a04", + }, + { + key: Buffer.from( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaa", + "hex", + ), + data: "Test Using Larger Than Block-Size Key - Hash Key First", + hmac: "aa4ae5e15272d00e95705637ce8a3b55ed402112", + }, + { + key: Buffer.from( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaa", + "hex", + ), + data: + "Test Using Larger Than Block-Size Key and Larger Than One " + + "Block-Size Data", + hmac: "e8e99d0f45237d786d6bbaa7965c7808bbff1a91", + }, + ]; + + for (const { key, data, hmac } of rfc2202_sha1) + testHmac("sha1", key, data, hmac); + }, +}; + +export const hmac_misc_tests = { + async test(ctrl, env, ctx) { + assert.strictEqual( + crypto.createHmac("sha256", "w00t").digest("ucs2"), + crypto.createHmac("sha256", "w00t").digest().toString("ucs2"), + ); + + // Check initialized -> uninitialized state transition after calling digest(). + { + // Normalize caret-spelled bytes in the pinned fixture using workerd's hex vector. + const expected = Buffer.from( + "104152c5bfdca07bc633eebd46199f0255c9f49d", + "hex", + ).toString("latin1"); + { + const h = crypto.createHmac("sha1", "key").update("data"); + assert.deepStrictEqual( + h.digest("buffer"), + Buffer.from(expected, "latin1"), + ); + assert.deepStrictEqual(h.digest("buffer"), Buffer.from("")); + } + { + const h = crypto.createHmac("sha1", "key").update("data"); + assert.strictEqual(h.digest("latin1"), expected); + assert.strictEqual(h.digest("latin1"), ""); + } + } + + // Check initialized -> uninitialized state transition after calling digest(). + // Calls to update() omitted intentionally. + { + // Normalize caret-spelled bytes in the pinned fixture using workerd's hex vector. + const expected = Buffer.from( + "f42bb0eeb018ebbd4597ae7213711ec60760843f", + "hex", + ).toString("latin1"); + { + const h = crypto.createHmac("sha1", "key"); + assert.deepStrictEqual( + h.digest("buffer"), + Buffer.from(expected, "latin1"), + ); + assert.deepStrictEqual(h.digest("buffer"), Buffer.from("")); + } + { + const h = crypto.createHmac("sha1", "key"); + assert.strictEqual(h.digest("latin1"), expected); + assert.strictEqual(h.digest("latin1"), ""); + } + } + + { + assert.throws( + () => crypto.createHmac("sha7", "key"), + /Digest method not supported/, + ); + } + + { + const buf = Buffer.alloc(0); + const keyObject = crypto.createSecretKey(Buffer.alloc(0)); + assert.deepStrictEqual( + crypto.createHmac("sha256", buf).update("foo").digest(), + crypto.createHmac("sha256", keyObject).update("foo").digest(), + ); + } + }, +}; diff --git a/src/compat/crypto/upstream-keygen.fixture.mjs b/src/compat/crypto/upstream-keygen.fixture.mjs new file mode 100644 index 0000000..00c0237 --- /dev/null +++ b/src/compat/crypto/upstream-keygen.fixture.mjs @@ -0,0 +1,690 @@ +// Copyright (c) 2023 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +import { deepStrictEqual, strictEqual, ok, rejects, throws } from "node:assert"; +import { + KeyObject, + SecretKeyObject, + PrivateKeyObject, + createSecretKey, + createPrivateKey, + createPublicKey, + createHmac, + generateKey, + generateKeySync, + generateKeyPair, + generateKeyPairSync, + sign, + verify, +} from "./index.js"; +import { Buffer } from "node:buffer"; +import { promisify } from "../util/index.js"; + +// Generation/export subset of the pinned upstream file; import/key-object coverage remains separate. +export const generate_hmac_secret_key = { + async test() { + // Length is intentionally not a multiple of 8 + const key = generateKeySync("hmac", { length: 33 }); + strictEqual(key.type, "secret"); + strictEqual(key.symmetricKeySize, 4); + + const { promise, resolve, reject } = Promise.withResolvers(); + generateKey("hmac", { length: 33 }, (err, key) => { + if (err) { + reject(err); + return; + } + strictEqual(key.type, "secret"); + strictEqual(key.symmetricKeySize, 4); + resolve(); + }); + await promise; + + throws(() => generateKeySync("hmac", { length: 0 }), { + message: + 'The value of "options.length" is out of range. It must ' + + "be >= 8 && <= 65536. Received 0", + }); + throws(() => generateKeySync("hmac", { length: 65537 }), { + message: + 'The value of "options.length" is out of range. It must ' + + "be >= 8 && <= 65536. Received 65537", + }); + + const h = createHmac("sha256", key); + ok(h.update("test").digest()); + }, +}; + +export const generate_aes_secret_key = { + async test() { + const key = generateKeySync("aes", { length: 128 }); + strictEqual(key.type, "secret"); + strictEqual(key.symmetricKeySize, 16); + + const { promise, resolve, reject } = Promise.withResolvers(); + generateKey("aes", { length: 128 }, (err, key) => { + if (err) { + reject(err); + return; + } + strictEqual(key.type, "secret"); + strictEqual(key.symmetricKeySize, 16); + resolve(); + }); + await promise; + + throws(() => generateKeySync("aes", { length: 0 }), { + message: + "The property 'options.length' must be one of: 128, 192, " + + "256. Received 0", + }); + }, +}; + +export const generate_key_pair_arg_validation = { + async test() { + throws(() => generateKeyPairSync("invalid type"), { + message: + "The argument 'type' must be one of: 'rsa', " + + "'ec', 'ed25519', 'x25519', 'dh'. Received " + + "'invalid type'", + }); + + throws(() => generateKeyPairSync("rsa", { modulusLength: -1 }), { + message: + 'The value of "options.modulusLength" is out of range. ' + + "It must be >= 0 && < 4294967296. Received -1", + }); + + throws(() => generateKeyPairSync("rsa", { modulusLength: "foo" }), { + message: + 'The "options.modulusLength" property must be of type number. ' + + "Received type string ('foo')", + }); + + throws( + () => + generateKeyPairSync("rsa", { + modulusLength: 512, + publicExponent: "foo", + }), + { + message: + 'The "options.publicExponent" property must be of type number. ' + + "Received type string ('foo')", + }, + ); + + // TODO(later): BoringSSL does not currently support rsa-pss key generation + // in the way Node.js does. Uncomment these later when it does. + // throws( + // () => + // generateKeyPairSync('rsa-pss', { + // modulusLength: 512, + // hashAlgorithm: false, + // }), + // { + // message: + // 'The "options.hashAlgorithm" property must be of type string. ' + + // 'Received type boolean (false)', + // } + // ); + + // throws( + // () => + // generateKeyPairSync('rsa-pss', { + // modulusLength: 512, + // mgf1HashAlgorithm: false, + // }), + // { + // message: + // 'The "options.mgf1HashAlgorithm" property must be of type ' + + // 'string. Received type boolean (false)', + // } + // ); + + // throws( + // () => + // generateKeyPairSync('rsa-pss', { + // modulusLength: 512, + // saltLength: 'foo', + // }), + // { + // message: + // 'The "options.saltLength" property must be of type number. ' + + // "Received type string ('foo')", + // } + // ); + + // TODO(later): BoringSSL currently does not support dsa key generation + // in the same way Node.js does. Uncomment this when it does. + // throws( + // () => + // generateKeyPairSync('dsa', { + // modulusLength: 512, + // divisorLength: 'foo', + // }), + // { + // message: + // 'The "options.divisorLength" property must be of type number. ' + + // "Received type string ('foo')", + // } + // ); + + throws(() => generateKeyPairSync("dh", { prime: "foo" }), { + message: + 'The "options.prime" property must be an instance of Buffer, ' + + "TypedArray, or ArrayBuffer. Received type string ('foo')", + }); + + throws(() => generateKeyPairSync("dh", { primeLength: "foo" }), { + message: + 'The "options.primeLength" property must be of type number. ' + + "Received type string ('foo')", + }); + + throws(() => generateKeyPairSync("dh", { primeLength: -1 }), { + message: + 'The value of "options.primeLength" is out of range. It ' + + "must be >= 0 && <= 2147483647. Received -1", + }); + + throws( + () => generateKeyPairSync("dh", { primeLength: 10, generator: -1 }), + { + message: + 'The value of "options.generator" is out of range. It must ' + + "be >= 0 && <= 2147483647. Received -1", + }, + ); + + throws(() => generateKeyPairSync("dh", { groupName: 123 }), { + message: + 'The "options.group" property must be of type string. ' + + "Received type number (123)", + }); + + throws( + () => + generateKeyPairSync("ec", { namedCurve: "foo", paramEncoding: "foo" }), + { + message: + "The property 'options.paramEncoding' must be one of: " + + "'named', 'explicit'. Received 'foo'", + }, + ); + + throws(() => generateKeyPairSync("ed25519", { publicKeyEncoding: "foo" }), { + message: + 'The "options.publicKeyEncoding" property must be of type ' + + "object. Received type string ('foo')", + }); + + throws(() => generateKeyPairSync("x25519", { privateKeyEncoding: "foo" }), { + message: + 'The "options.privateKeyEncoding" property must be of type ' + + "object. Received type string ('foo')", + }); + + // ==== + async function wrapped(...args) { + const { promise, resolve, reject } = Promise.withResolvers(); + generateKeyPair(...args, (err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + await promise; + } + + await rejects(wrapped("invalid type", {}), { + message: + "The argument 'type' must be one of: 'rsa', " + + "'ec', 'ed25519', 'x25519', 'dh'. Received " + + "'invalid type'", + }); + + await rejects(wrapped("rsa", { modulusLength: -1 }), { + message: + 'The value of "options.modulusLength" is out of range. ' + + "It must be >= 0 && < 4294967296. Received -1", + }); + + await rejects(wrapped("rsa", { modulusLength: "foo" }), { + message: + 'The "options.modulusLength" property must be of type number. ' + + "Received type string ('foo')", + }); + + await rejects( + wrapped("rsa", { modulusLength: 512, publicExponent: "foo" }), + { + message: + 'The "options.publicExponent" property must be of type number. ' + + "Received type string ('foo')", + }, + ); + + // TODO(later): BoringSSL currently does not support rsa-pss key generation + // in the same way Node.js does. Uncomment these later when it does. + // await rejects( + // wrapped('rsa-pss', { modulusLength: 512, hashAlgorithm: false }), + // { + // message: + // 'The "options.hashAlgorithm" property must be of type string. ' + + // 'Received type boolean (false)', + // } + // ); + + // await rejects( + // wrapped('rsa-pss', { modulusLength: 512, mgf1HashAlgorithm: false }), + // { + // message: + // 'The "options.mgf1HashAlgorithm" property must be of type ' + + // 'string. Received type boolean (false)', + // } + // ); + + // await rejects( + // wrapped('rsa-pss', { modulusLength: 512, saltLength: 'foo' }), + // { + // message: + // 'The "options.saltLength" property must be of type number. ' + + // "Received type string ('foo')", + // } + // ); + + // TODO(later): BoringSSL currently does not support dsa key generation + // in the same way Node.js does. Uncomment this later when it does. + // await rejects( + // wrapped('dsa', { modulusLength: 512, divisorLength: 'foo' }), + // { + // message: + // 'The "options.divisorLength" property must be of type number. ' + + // "Received type string ('foo')", + // } + // ); + + await rejects(wrapped("dh", { prime: "foo" }), { + message: + 'The "options.prime" property must be an instance of Buffer, ' + + "TypedArray, or ArrayBuffer. Received type string ('foo')", + }); + + await rejects(wrapped("dh", { primeLength: "foo" }), { + message: + 'The "options.primeLength" property must be of type number. ' + + "Received type string ('foo')", + }); + + await rejects(wrapped("dh", { primeLength: -1 }), { + message: + 'The value of "options.primeLength" is out of range. It ' + + "must be >= 0 && <= 2147483647. Received -1", + }); + + await rejects(wrapped("dh", { primeLength: 10, generator: -1 }), { + message: + 'The value of "options.generator" is out of range. It must ' + + "be >= 0 && <= 2147483647. Received -1", + }); + + await rejects(wrapped("dh", { groupName: 123 }), { + message: + 'The "options.group" property must be of type string. ' + + "Received type number (123)", + }); + + await rejects(wrapped("ec", { namedCurve: "foo", paramEncoding: "foo" }), { + message: + "The property 'options.paramEncoding' must be one of: " + + "'named', 'explicit'. Received 'foo'", + }); + + await rejects(wrapped("ed25519", { publicKeyEncoding: "foo" }), { + message: + 'The "options.publicKeyEncoding" property must be of type ' + + "object. Received type string ('foo')", + }); + + await rejects(wrapped("x25519", { privateKeyEncoding: "foo" }), { + message: + 'The "options.privateKeyEncoding" property must be of type ' + + "object. Received type string ('foo')", + }); + }, +}; + +export const generate_rsa_key_pair_modulus_cap = { + test() { + throws(() => generateKeyPairSync("rsa", { modulusLength: 16385 }), { + name: "RangeError", + }); + const { publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + strictEqual(publicKey.asymmetricKeyDetails.modulusLength, 2048); + }, +}; + +export const generate_dh_key_pair_prime_size_cap = { + test() { + // 1025 bytes > kMaxPrimeBits / 8 (1024 bytes) + throws(() => generateKeyPairSync("dh", { prime: Buffer.alloc(1025) }), { + name: "RangeError", + }); + }, +}; + +export const generate_rsa_key_pair = { + test() { + const { publicKey, privateKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + }); + strictEqual(publicKey.type, "public"); + strictEqual(publicKey.asymmetricKeyDetails.modulusLength, 2048); + strictEqual(publicKey.asymmetricKeyDetails.publicExponent, 65537n); + + strictEqual(privateKey.type, "private"); + strictEqual(privateKey.asymmetricKeyDetails.modulusLength, 2048); + strictEqual(privateKey.asymmetricKeyDetails.publicExponent, 65537n); + }, +}; + +export const generate_rsa_key_pair_enc = { + test() { + const { publicKey, privateKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { + format: "der", + type: "pkcs1", + }, + privateKeyEncoding: { + format: "pem", + type: "pkcs8", + }, + }); + + const pubImported = createPublicKey({ + key: publicKey, + format: "der", + type: "pkcs1", + }); + strictEqual(pubImported.type, "public"); + strictEqual(pubImported.asymmetricKeyDetails.modulusLength, 2048); + strictEqual(pubImported.asymmetricKeyDetails.publicExponent, 65537n); + + const imported = createPrivateKey(privateKey); + strictEqual(imported.type, "private"); + strictEqual(imported.asymmetricKeyDetails.modulusLength, 2048); + strictEqual(imported.asymmetricKeyDetails.publicExponent, 65537n); + }, +}; + +export const generate_ec_key_pair = { + test() { + const { privateKey, publicKey } = generateKeyPairSync("ec", { + namedCurve: "P-256", + }); + strictEqual(publicKey.type, "public"); + strictEqual(publicKey.asymmetricKeyDetails.namedCurve, "prime256v1"); + strictEqual(privateKey.type, "private"); + strictEqual(privateKey.asymmetricKeyDetails.namedCurve, "prime256v1"); + }, +}; + +export const generate_ed25519_key_pair = { + test() { + const { privateKey, publicKey } = generateKeyPairSync("ed25519", {}); + strictEqual(publicKey.type, "public"); + strictEqual(privateKey.type, "private"); + strictEqual(publicKey.asymmetricKeyType, "ed25519"); + strictEqual(privateKey.asymmetricKeyType, "ed25519"); + }, +}; + +export const generate_x25519_key_pair = { + test() { + const { privateKey, publicKey } = generateKeyPairSync("x25519", {}); + strictEqual(publicKey.type, "public"); + strictEqual(privateKey.type, "private"); + strictEqual(publicKey.asymmetricKeyType, "x25519"); + strictEqual(privateKey.asymmetricKeyType, "x25519"); + }, +}; + +// TODO(later): BoringSSL with fips does not support generating DH keys +// this way. Uncomment this later when it does. +// export const generate_dh_key_pair = { +// test() { +// const { privateKey, publicKey } = generateKeyPairSync('dh', { +// group: 'modp14', +// }); +// strictEqual(publicKey.type, 'public'); +// strictEqual(privateKey.type, 'private'); +// strictEqual(publicKey.asymmetricKeyType, 'dh'); +// strictEqual(privateKey.asymmetricKeyType, 'dh'); + +// const res = diffieHellman({ privateKey, publicKey }); +// ok(res instanceof Buffer); +// strictEqual(res.byteLength, 256); +// }, +// }; + +// TODO(later): BoringSSL with fips does not support generating DH keys +// this way. Uncomment this later when it does. +// export const generate_dh_from_fixed_prime = { +// test() { +// const prime = generatePrimeSync(1024); + +// const { privateKey: privateKey1, publicKey: publicKey1 } = +// generateKeyPairSync('dh', { +// prime, +// }); +// strictEqual(publicKey1.type, 'public'); +// strictEqual(privateKey1.type, 'private'); +// strictEqual(publicKey1.asymmetricKeyType, 'dh'); +// strictEqual(privateKey1.asymmetricKeyType, 'dh'); + +// const { privateKey: privateKey2, publicKey: publicKey2 } = +// generateKeyPairSync('dh', { +// prime, +// }); +// strictEqual(publicKey2.type, 'public'); +// strictEqual(privateKey2.type, 'private'); +// strictEqual(publicKey2.asymmetricKeyType, 'dh'); +// strictEqual(privateKey2.asymmetricKeyType, 'dh'); + +// ok(!publicKey1.equals(publicKey2)); +// ok(!privateKey1.equals(privateKey2)); + +// // Once we generate the keys, let's make sure they are usable. + +// const res1 = diffieHellman({ +// privateKey: privateKey2, +// publicKey: publicKey1, +// }); +// ok(res1 instanceof Buffer); +// strictEqual(res1.byteLength, 128); + +// const res2 = diffieHellman({ +// privateKey: privateKey2, +// publicKey: publicKey1, +// }); +// ok(res2 instanceof Buffer); +// strictEqual(res2.byteLength, 128); + +// deepStrictEqual(res1, res2); +// // It's actual data and not just zeroes right? +// notDeepStrictEqual(res1, Buffer.alloc(128, 0)); + +// // Keys generated from different prime groups aren't compatible and should throw. +// const prime2 = generatePrimeSync(1024); +// const { privateKey: privateKey3, publicKey: publicKey3 } = +// generateKeyPairSync('dh', { +// prime: prime2, +// }); +// strictEqual(publicKey3.type, 'public'); +// strictEqual(privateKey3.type, 'private'); +// strictEqual(publicKey3.asymmetricKeyType, 'dh'); +// strictEqual(privateKey3.asymmetricKeyType, 'dh'); + +// throws( +// () => diffieHellman({ publicKey: publicKey1, privateKey: privateKey3 }), +// { +// message: 'Failed to derive shared diffie-hellman secret', +// } +// ); +// }, +// }; + +export const generate_dh_key_pair_by_length = { + test() { + throws( + () => + generateKeyPairSync("dh", { + primeLength: 1048, + }), + { + message: + "Generating DH keys from a prime length is not yet implemented", + }, + ); + }, +}; + +export const generate_ed_keypair_promisified = { + async test() { + const promisifiedGenKeyPair = promisify(generateKeyPair); + const { publicKey, privateKey } = await promisifiedGenKeyPair( + "ed25519", + {}, + ); + strictEqual(publicKey.asymmetricKeyType, "ed25519"); + strictEqual(privateKey.asymmetricKeyType, "ed25519"); + }, +}; + +// Regression test: privateKey.export() with cipher and passphrase must +// produce an encrypted PEM (BEGIN ENCRYPTED PRIVATE KEY), not plaintext. +export const export_encrypted_private_key = { + test() { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + + const plainPem = privateKey.export({ format: "pem", type: "pkcs8" }); + const encryptedPem = privateKey.export({ + format: "pem", + type: "pkcs8", + cipher: "aes-256-cbc", + passphrase: "test-passphrase", + }); + + // Encrypted PEM must differ from unencrypted PEM. + ok(plainPem !== encryptedPem); + + // Encrypted PEM must have the ENCRYPTED header. + ok( + encryptedPem.startsWith("-----BEGIN ENCRYPTED PRIVATE KEY-----"), + "Expected encrypted PEM header", + ); + + // Re-import the encrypted PEM using the passphrase. + const reimported = createPrivateKey({ + key: encryptedPem, + format: "pem", + passphrase: "test-passphrase", + }); + + // The re-imported key must produce the same unencrypted export. + strictEqual(reimported.export({ format: "pem", type: "pkcs8" }), plainPem); + }, +}; + +// Verify encrypted export also works for EC keys. +export const export_encrypted_ec_private_key = { + test() { + const { privateKey } = generateKeyPairSync("ec", { + namedCurve: "P-256", + }); + + const encryptedPem = privateKey.export({ + format: "pem", + type: "pkcs8", + cipher: "aes-128-cbc", + passphrase: "ec-passphrase", + }); + + ok( + encryptedPem.startsWith("-----BEGIN ENCRYPTED PRIVATE KEY-----"), + "Expected encrypted PEM header for EC key", + ); + + const reimported = createPrivateKey({ + key: encryptedPem, + format: "pem", + passphrase: "ec-passphrase", + }); + + strictEqual( + reimported.export({ format: "pem", type: "pkcs8" }), + privateKey.export({ format: "pem", type: "pkcs8" }), + ); + }, +}; + +// Regression test for AUTOVULN-CLOUDFLARE-WORKERD-30: +// createSecretKey() must correctly copy DataView key material. +// Previously, Buffer.from(dataView) produced an empty Buffer because +// DataView has no .length property, causing the key material to be lost. +export const regression_create_secret_key_dataview = { + test() { + // Create a 16-byte ArrayBuffer filled with 0x41 ('A') + const ab = new ArrayBuffer(16); + new Uint8Array(ab).fill(0x41); + + // Create a secret key from a DataView over the full buffer + const dvFull = new DataView(ab); + const keyFull = createSecretKey(dvFull); + const exported = keyFull.export(); + + // The exported key must contain exactly the 16 bytes, not be empty + strictEqual(exported.length, 16, "DataView key material must not be empty"); + strictEqual( + exported.toString(), + "A".repeat(16), + "DataView key material must match the original bytes", + ); + strictEqual(keyFull.symmetricKeySize, 16); + + // Also verify a DataView over a sub-range of the buffer works correctly + const abLarge = new ArrayBuffer(32); + const u8 = new Uint8Array(abLarge); + u8.fill(0x42); // fill with 'B' + u8.fill(0x43, 8, 16); // bytes 8..15 = 'C' + + const dvSlice = new DataView(abLarge, 8, 8); + const keySlice = createSecretKey(dvSlice); + const exportedSlice = keySlice.export(); + + strictEqual( + exportedSlice.length, + 8, + "DataView sub-range key must have correct length", + ); + strictEqual( + exportedSlice.toString(), + "C".repeat(8), + "DataView sub-range key must contain the correct bytes", + ); + strictEqual(keySlice.symmetricKeySize, 8); + + // Verify the DataView-created key matches a Uint8Array-created key + // over the same bytes + const keyFromUint8 = createSecretKey(Buffer.from("A".repeat(16))); + ok( + keyFull.equals(keyFromUint8), + "DataView key must equal Buffer key with same bytes", + ); + }, +}; diff --git a/src/compat/crypto/upstream-pbkdf2.fixture.mjs b/src/compat/crypto/upstream-pbkdf2.fixture.mjs new file mode 100644 index 0000000..03aae3b --- /dev/null +++ b/src/compat/crypto/upstream-pbkdf2.fixture.mjs @@ -0,0 +1,349 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// Adapted from Node.js. Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +"use strict"; + +import * as assert from "node:assert"; +import * as crypto from "./pbkdf2.js"; + +function mustNotCall() { + return () => {}; +} + +async function runPBKDF2(password, salt, iterations, keylen, hash) { + const syncResult = crypto.pbkdf2Sync( + password, + salt, + iterations, + keylen, + hash, + ); + + const p = Promise.withResolvers(); + crypto.pbkdf2( + password, + salt, + iterations, + keylen, + hash, + (err, asyncResult) => { + assert.deepStrictEqual(asyncResult, syncResult); + p.resolve(); + }, + ); + await p.promise; + + return syncResult; +} + +async function testPBKDF2( + password, + salt, + iterations, + keylen, + expected, + encoding, +) { + const actual = await runPBKDF2(password, salt, iterations, keylen, "sha256"); + assert.strictEqual(actual.toString(encoding || "latin1"), expected); +} + +// +// Test PBKDF2 with RFC 6070 test vectors (except #4) +// + +export const pbkdf2_correctness_tests = { + async test(ctrl, env, ctx) { + await testPBKDF2( + "password", + "salt", + 1, + 20, + "\x12\x0f\xb6\xcf\xfc\xf8\xb3\x2c\x43\xe7\x22\x52" + + "\x56\xc4\xf8\x37\xa8\x65\x48\xc9", + ); + + await testPBKDF2( + "password", + "salt", + 2, + 20, + "\xae\x4d\x0c\x95\xaf\x6b\x46\xd3\x2d\x0a\xdf\xf9" + + "\x28\xf0\x6d\xd0\x2a\x30\x3f\x8e", + ); + + await testPBKDF2( + "password", + "salt", + 4096, + 20, + "\xc5\xe4\x78\xd5\x92\x88\xc8\x41\xaa\x53\x0d\xb6" + + "\x84\x5c\x4c\x8d\x96\x28\x93\xa0", + ); + + await testPBKDF2( + "passwordPASSWORDpassword", + "saltSALTsaltSALTsaltSALTsaltSALTsalt", + 4096, + 25, + "\x34\x8c\x89\xdb\xcb\xd3\x2b\x2f\x32\xd8\x14\xb8\x11" + + "\x6e\x84\xcf\x2b\x17\x34\x7e\xbc\x18\x00\x18\x1c", + ); + + await testPBKDF2( + "pass\0word", + "sa\0lt", + 4096, + 16, + "\x89\xb6\x9d\x05\x16\xf8\x29\x89\x3c\x69\x62\x26\x65" + "\x0a\x86\x87", + ); + + await testPBKDF2( + "password", + "salt", + 32, + 32, + "64c486c55d30d4c5a079b8823b7d7cb37ff0556f537da8410233bcec330ed956", + "hex", + ); + }, +}; + +export const pbkdf2_no_callback_test = { + test(ctrl, env, ctx) { + // Error path should not leak memory (check with Valgrind). + assert.throws(() => crypto.pbkdf2("password", "salt", 1, 20, "sha1"), { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + }); + }, +}; + +export const pbkdf2_out_of_range_tests = { + test(ctrl, env, ctx) { + for (const iterations of [-1, 0, 2147483648]) { + assert.throws( + () => crypto.pbkdf2Sync("password", "salt", iterations, 20, "sha1"), + { + code: "ERR_OUT_OF_RANGE", + name: "RangeError", + }, + ); + } + + ["str", null, undefined, [], {}].forEach((notNumber) => { + assert.throws( + () => { + crypto.pbkdf2Sync("password", "salt", 1, notNumber, "sha256"); + }, + { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + }, + ); + }); + + [Infinity, -Infinity, NaN].forEach((input) => { + assert.throws( + () => { + crypto.pbkdf2("password", "salt", 1, input, "sha256", mustNotCall()); + }, + { + code: "ERR_OUT_OF_RANGE", + name: "RangeError", + message: + 'The value of "keylen" is out of range. It ' + + `must be an integer. Received ${input}`, + }, + ); + }); + + [-1, 2147483648, 4294967296].forEach((input) => { + assert.throws( + () => { + crypto.pbkdf2("password", "salt", 1, input, "sha256", mustNotCall()); + }, + { + code: "ERR_OUT_OF_RANGE", + name: "RangeError", + }, + ); + }); + }, +}; + +export const empty_pwd_test = { + async test(ctrl, env, ctx) { + // Should not get FATAL ERROR with empty password and salt + // https://github.com/nodejs/node/issues/8571 + const p = Promise.withResolvers(); + crypto.pbkdf2("", "", 1, 32, "sha256", (err, prime) => { + p.resolve(); + }); + await p.promise; + }, +}; + +export const invalid_arg_tests = { + test(ctrl, env, ctx) { + assert.throws( + () => crypto.pbkdf2("password", "salt", 8, 8, mustNotCall()), + { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + message: + 'The "digest" argument must be of type string. ' + + "Received undefined", + }, + ); + + assert.throws(() => crypto.pbkdf2Sync("password", "salt", 8, 8), { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + message: + 'The "digest" argument must be of type string. ' + "Received undefined", + }); + + assert.throws(() => crypto.pbkdf2Sync("password", "salt", 8, 8, null), { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + message: + 'The "digest" argument must be of type string. ' + "Received null", + }); + [1, {}, [], true, undefined, null].forEach((input) => { + assert.throws( + () => crypto.pbkdf2(input, "salt", 8, 8, "sha256", mustNotCall()), + { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + }, + ); + + assert.throws( + () => crypto.pbkdf2("pass", input, 8, 8, "sha256", mustNotCall()), + { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + }, + ); + + assert.throws(() => crypto.pbkdf2Sync(input, "salt", 8, 8, "sha256"), { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + }); + + assert.throws(() => crypto.pbkdf2Sync("pass", input, 8, 8, "sha256"), { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + }); + }); + + ["test", {}, [], true, undefined, null].forEach((i) => { + assert.throws( + () => crypto.pbkdf2("pass", "salt", i, 8, "sha256", mustNotCall()), + { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + }, + ); + + assert.throws(() => crypto.pbkdf2Sync("pass", "salt", i, 8, "sha256"), { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + }); + }); + }, +}; + +export const TypedArray_tests = { + async test(ctrl, env, ctx) { + // Any TypedArray should work for password and salt. + for (const SomeArray of [ + Uint8Array, + Uint16Array, + Uint32Array, + // Some hosts lack Float16Array. The shared contract checks its input + // brand where available; native measurements record this host boundary. + ...(globalThis.Float16Array ? [globalThis.Float16Array] : []), + Float32Array, + Float64Array, + ArrayBuffer, + ]) { + await runPBKDF2(new SomeArray(10), "salt", 8, 8, "sha256"); + await runPBKDF2("pass", new SomeArray(10), 8, 8, "sha256"); + } + }, +}; + +export const invalid_digest_tests = { + async test(ctrl, env, ctx) { + { + const p = Promise.withResolvers(); + crypto.pbkdf2("pass", "salt", 8, 8, "md55", (err, prime) => { + if (err) return p.reject(err); + }); + await assert.rejects(p.promise); + } + + assert.throws(() => crypto.pbkdf2Sync("pass", "salt", 8, 8, "md55"), { + name: "TypeError", + }); + + // TODO(soon): Enable this once crypto.getHashes() is available. Note that shake* is not + // supported by BoringSSL so there's no need to filter it out, but we may want to filter other + // functions. + // const kNotPBKDF2Supported = ['shake128', 'shake256']; + // crypto.getHashes() + // .filter((hash) => !kNotPBKDF2Supported.includes(hash)) + // .forEach((hash) => { + // runPBKDF2(new Uint8Array(10), 'salt', 8, 8, hash); + // }); + + { + // This should not crash. + assert.throws(() => crypto.pbkdf2Sync("1", "2", 1, 1, "%"), { + name: "TypeError", + }); + } + }, +}; + +export const pbkdf2_keylen_cap = { + test() { + // SHA-1 digest = 20 bytes, max keylen = 255 * 20 = 5100 + assert.throws(() => crypto.pbkdf2Sync("p", "s", 1000, 5101, "sha1"), { + name: "RangeError", + }); + const r1 = crypto.pbkdf2Sync("p", "s", 1000, 5100, "sha1"); + assert.strictEqual(r1.length, 5100); + + // SHA-256 digest = 32 bytes, max keylen = 255 * 32 = 8160 + assert.throws(() => crypto.pbkdf2Sync("p", "s", 1000, 8161, "sha256"), { + name: "RangeError", + }); + const r2 = crypto.pbkdf2Sync("p", "s", 1000, 8160, "sha256"); + assert.strictEqual(r2.length, 8160); + }, +}; diff --git a/src/compat/crypto/upstream-random.fixture.mjs b/src/compat/crypto/upstream-random.fixture.mjs new file mode 100644 index 0000000..2ac92a4 --- /dev/null +++ b/src/compat/crypto/upstream-random.fixture.mjs @@ -0,0 +1,540 @@ +// Copyright (c) 2023 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +import { ok, rejects, strictEqual, throws } from "node:assert"; + +import { + generatePrime, + generatePrimeSync, + checkPrime, + checkPrimeSync, + timingSafeEqual, +} from "./index.js"; + +import { Buffer } from "node:buffer"; + +export const test = { + async test(ctrl, env, ctx) { + [1, "hello", {}, []].forEach((i) => { + throws(() => checkPrimeSync(i), { + code: "ERR_INVALID_ARG_TYPE", + }); + }); + + // prettier-ignore + for (const checks of [-(2 ** 31), -1, 2 ** 31, 2 ** 32 - 1, 2 ** 32, 2 ** 50]) { + throws(() => checkPrimeSync(2n, { checks }), { + code: 'ERR_OUT_OF_RANGE', + message: /<= 2147483647/ + }); + } + + ok( + !checkPrimeSync(Buffer.from([0x1]), { + fast: true, + trialDivision: true, + checks: 10, + }), + ); + + ok(!checkPrimeSync(Buffer.from([0x1]))); + ok(checkPrimeSync(Buffer.from([0x2]))); + ok(checkPrimeSync(Buffer.from([0x3]))); + ok(!checkPrimeSync(Buffer.from([0x4]))); + + //////////////// + + ["hello", false, 123].forEach((i) => { + throws(() => generatePrimeSync(80, i), { + code: "ERR_INVALID_ARG_TYPE", + }); + }); + + for (const checks of ["hello", {}, []]) { + throws(() => checkPrimeSync(2n, { checks }), { + code: "ERR_INVALID_ARG_TYPE", + message: /checks/, + }); + } + + // ////////////////// + + ["hello", false, {}, []].forEach((i) => { + throws(() => generatePrime(i), { + code: "ERR_INVALID_ARG_TYPE", + }); + throws(() => generatePrimeSync(i), { + code: "ERR_INVALID_ARG_TYPE", + }); + }); + + ["hello", false, 123].forEach((i) => { + throws(() => generatePrime(80, i, {}), { + code: "ERR_INVALID_ARG_TYPE", + }); + throws(() => generatePrimeSync(80, i), { + code: "ERR_INVALID_ARG_TYPE", + }); + }); + + ["hello", false, 123].forEach((i) => { + throws(() => generatePrime(80, {}), { + code: "ERR_INVALID_ARG_TYPE", + }); + }); + + [-1, 0, 2 ** 31, 2 ** 31 + 1, 2 ** 32 - 1, 2 ** 32].forEach((size) => { + throws(() => generatePrime(-1), { + code: "ERR_OUT_OF_RANGE", + message: />= 1 && <= 2147483647/, + }); + throws(() => generatePrimeSync(size), { + code: "ERR_OUT_OF_RANGE", + message: />= 1 && <= 2147483647/, + }); + }); + + // TODO: Fix and enable asynchronous tests + ["test", -1, {}, []].forEach((i) => { + throws(() => generatePrime(8, { safe: i }, () => {}), { + code: "ERR_INVALID_ARG_TYPE", + }); + throws(() => generatePrime(8, { rem: i }, () => {}), { + code: "ERR_INVALID_ARG_TYPE", + }); + throws(() => generatePrime(8, { add: i }, () => {}), { + code: "ERR_INVALID_ARG_TYPE", + }); + throws(() => generatePrimeSync(8, { safe: i }), { + code: "ERR_INVALID_ARG_TYPE", + }); + throws(() => generatePrimeSync(8, { rem: i }), { + code: "ERR_INVALID_ARG_TYPE", + }); + throws(() => generatePrimeSync(8, { add: i }), { + code: "ERR_INVALID_ARG_TYPE", + }); + }); + + { + // Negative BigInts should not be converted to 0 silently. + throws(() => generatePrime(20, { add: -1n }, () => {}), { + code: "ERR_OUT_OF_RANGE", + }); + + throws(() => generatePrime(20, { rem: -1n }, () => {}), { + code: "ERR_OUT_OF_RANGE", + }); + throws(() => checkPrime(-1n, () => {}), { + code: "ERR_OUT_OF_RANGE", + }); + } + + { + const p = Promise.withResolvers(); + generatePrime(80, (err, prime) => { + ok(checkPrimeSync(prime)); + checkPrime(prime, (err, result) => { + ok(result); + p.resolve(); + }); + }); + await p.promise; + } + + ok(checkPrimeSync(generatePrimeSync(80))); + + { + const p = Promise.withResolvers(); + generatePrime(80, {}, (err, prime) => { + if (err) return p.reject(err); + ok(checkPrimeSync(prime)); + p.resolve(); + }); + await p.promise; + } + + ok(checkPrimeSync(generatePrimeSync(80, {}))); + + { + const p = Promise.withResolvers(); + generatePrime(32, { safe: true }, (err, prime) => { + if (err) return p.reject(err); + ok(checkPrimeSync(prime)); + const buf = Buffer.from(prime); + const val = buf.readUInt32BE(); + const check = (val - 1) / 2; + buf.writeUInt32BE(check); + ok(checkPrimeSync(buf)); + p.resolve(); + }); + await p.promise; + } + + { + const prime = generatePrimeSync(32, { safe: true }); + ok(checkPrimeSync(prime)); + const buf = Buffer.from(prime); + const val = buf.readUInt32BE(); + const check = (val - 1) / 2; + buf.writeUInt32BE(check); + ok(checkPrimeSync(buf)); + } + + const add = 12; + const rem = 11; + const add_buf = Buffer.from([add]); + const rem_buf = Buffer.from([rem]); + + { + const p = Promise.withResolvers(); + generatePrime(32, { add: add_buf, rem: rem_buf }, (err, prime) => { + if (err) return p.reject(err); + ok(checkPrimeSync(prime)); + const buf = Buffer.from(prime); + const val = buf.readUInt32BE(); + strictEqual(val % add, rem); + p.resolve(); + }); + await p.promise; + } + + { + const prime = generatePrimeSync(32, { add: add_buf, rem: rem_buf }); + ok(checkPrimeSync(prime)); + const buf = Buffer.from(prime); + const val = buf.readUInt32BE(); + strictEqual(val % add, rem); + } + + { + const prime = generatePrimeSync(32, { + add: BigInt(add), + rem: BigInt(rem), + }); + ok(checkPrimeSync(prime)); + const buf = Buffer.from(prime); + const val = buf.readUInt32BE(); + strictEqual(val % add, rem); + } + + { + const p = Promise.withResolvers(); + generatePrime( + 128, + { + bigint: true, + add: 5n, + }, + (err, prime) => { + // Fails because the add option is not a supported value + if (err) return p.reject(err); + }, + ); + await rejects(p.promise); + } + { + const p = Promise.withResolvers(); + generatePrime( + 128, + { + bigint: true, + safe: true, + add: 5n, + }, + (err, prime) => { + // Fails because the add option is not a supported value + if (err) return p.reject(err); + }, + ); + await rejects(p.promise); + } + + // This is impossible because it implies (prime % 2**64) == 1 and + // prime < 2**64, meaning prime = 1, but 1 is not prime. + for (const add of [2n ** 64n, 2n ** 65n]) { + throws( + () => { + generatePrimeSync(64, { add }); + }, + { + name: "RangeError", + }, + ); + } + + // Any parameters with rem >= add lead to an impossible condition. + for (const rem of [7n, 8n, 3000n]) { + throws( + () => { + generatePrimeSync(64, { add: 7n, rem }); + }, + { + name: "RangeError", + }, + ); + } + + // This is possible, but not allowed. It implies prime == 7, which means that + // we did not actually generate a random prime. + throws( + () => { + generatePrimeSync(3, { add: 8n, rem: 7n }); + }, + { + name: "RangeError", + }, + ); + + // We only allow specific values of add and rem + throws( + () => + generatePrimeSync(8, { + add: 7n, + rem: 1n, + }), + { + name: "RangeError", + }, + ); + throws( + () => + generatePrimeSync(8, { + add: 12n, + rem: 10n, + }), + { + name: "RangeError", + }, + ); + throws( + () => + generatePrimeSync(8, { + add: 12n, + }), + { + name: "RangeError", + }, + ); + + [1, "hello", {}, []].forEach((i) => { + throws(() => checkPrime(i), { + code: "ERR_INVALID_ARG_TYPE", + }); + }); + + for (const checks of ["hello", {}, []]) { + throws(() => checkPrime(2n, { checks }, () => {}), { + code: "ERR_INVALID_ARG_TYPE", + message: /checks/, + }); + throws(() => checkPrimeSync(2n, { checks }), { + code: "ERR_INVALID_ARG_TYPE", + message: /checks/, + }); + } + + // prettier-ignore + for (const checks of [-(2 ** 31), -1, 2 ** 31, 2 ** 32 - 1, 2 ** 32, 2 ** 50]) { + throws(() => checkPrime(2n, { checks }, () => {}), { + code: 'ERR_OUT_OF_RANGE', + message: /<= 2147483647/ + }); + throws(() => checkPrimeSync(2n, { checks }), { + code: 'ERR_OUT_OF_RANGE', + message: /<= 2147483647/ + }); + } + + ok( + !checkPrimeSync(Buffer.from([0x1]), { + fast: true, + trialDivision: true, + checks: 10, + }), + ); + + throws( + () => { + generatePrimeSync(32, { bigint: "" }); + }, + { code: "ERR_INVALID_ARG_TYPE" }, + ); + + throws( + () => { + generatePrime(32, { bigint: "" }, () => {}); + }, + { code: "ERR_INVALID_ARG_TYPE" }, + ); + + { + const prime = generatePrimeSync(3, { bigint: true }); + strictEqual(typeof prime, "bigint"); + strictEqual(prime, 7n); + ok(checkPrimeSync(prime)); + const p = Promise.withResolvers(); + checkPrime(prime, (err, result) => { + if (err) return p.reject(err); + p.resolve(result); + }); + await p.promise; + } + + { + const p = Promise.withResolvers(); + generatePrime(3, { bigint: true }, (err, prime) => { + if (err) return p.reject(err); + strictEqual(typeof prime, "bigint"); + strictEqual(prime, 7n); + ok(checkPrimeSync(prime)); + checkPrime(prime, (err, result) => { + if (err) return p.reject(err); + p.resolve(result); + }); + }); + await p.promise; + } + }, +}; + +export const timingSafeEqualTest = { + test() { + timingSafeEqual(new Uint8Array(1), new Uint8Array(1)); + }, +}; + +export const randomIntTest = { + async test() { + const { randomInt } = await import("./index.js"); + + // min === max should throw, not infinite-loop + throws(() => randomInt(5, 5), { code: "ERR_OUT_OF_RANGE" }); + + // min > max should throw + throws(() => randomInt(10, 5), { code: "ERR_OUT_OF_RANGE" }); + + // Valid range returns value in [min, max) + const val = randomInt(0, 10); + ok(val >= 0 && val < 10); + + // Single-arg form: randomInt(max) means [0, max) + strictEqual(randomInt(1), 0); + }, +}; + +export const randomFillSyncTest = { + async test() { + const { randomFillSync } = await import("./index.js"); + + // With offset but no size, should fill only buf.length - offset bytes + const buf = Buffer.alloc(10, 0); + randomFillSync(buf, 4); + + // First 4 bytes must be untouched (all zero) + for (let i = 0; i < 4; i++) { + strictEqual(buf[i], 0, `byte ${i} should be untouched`); + } + }, +}; + +// Regression: randomFillSync with DataView was silently filling zero bytes +// because DataView has no .length property (only .byteLength). +export const randomFillSyncDataView = { + async test() { + const { randomFillSync } = await import("./index.js"); + + // DataView without explicit offset/size — should fill all 8 bytes. + const ab = new ArrayBuffer(16); + const dv = new DataView(ab, 4, 8); + randomFillSync(dv); + + // Verify the DataView region was actually filled (not left as zeros). + // With 8 random bytes, P(all zero) = 2^-64 — negligible false-positive. + const filled = new Uint8Array(ab, 4, 8); + let allZero = true; + for (let i = 0; i < filled.length; i++) { + if (filled[i] !== 0) { + allZero = false; + break; + } + } + if (allZero) + throw new Error("DataView region was not filled with random data"); + + // Verify bytes outside the DataView window were NOT touched. + const before = new Uint8Array(ab, 0, 4); + const after = new Uint8Array(ab, 12, 4); + for (let i = 0; i < 4; i++) { + strictEqual( + before[i], + 0, + `byte before DataView at ${i} should be untouched`, + ); + strictEqual( + after[i], + 0, + `byte after DataView at ${i} should be untouched`, + ); + } + + // DataView with explicit offset and size args. + const ab2 = new ArrayBuffer(16); + const dv2 = new DataView(ab2); + randomFillSync(dv2, 2, 4); + const slice = new Uint8Array(ab2, 2, 4); + allZero = true; + for (let i = 0; i < slice.length; i++) { + if (slice[i] !== 0) { + allZero = false; + break; + } + } + if (allZero) throw new Error("DataView with offset/size was not filled"); + + // Bytes outside the fill region should be untouched. + strictEqual( + new Uint8Array(ab2, 0, 2).every((b) => b === 0), + true, + "bytes before fill region should be zero", + ); + strictEqual( + new Uint8Array(ab2, 6, 10).every((b) => b === 0), + true, + "bytes after fill region should be zero", + ); + }, +}; + +// Ref: https://github.com/cloudflare/workerd/issues/2716 +export const getRandomValuesIllegalInvocation = { + async test() { + const crypto = await import("./index.js"); + { + // The following assertion doesn't fail as of Node.js v20.17.0 + const getRandomValues = crypto.getRandomValues; + strictEqual(getRandomValues(new Uint8Array(6)).length, 6); + } + throws( + () => { + // This ensures that we are replicating Node.js behavior as of v20.17.0 + const getRandomValues = crypto.webcrypto.getRandomValues; + strictEqual(getRandomValues(new Uint8Array(6)).length, 6); + }, + { + name: "TypeError", + // The adapter exposes the host Crypto object; Node's host uses a + // different brand-error message. Both real WebViews are also tested. + message: /Illegal invocation|Value of "this" must be of type Crypto/, + }, + ); + strictEqual(crypto.getRandomValues(new Uint8Array(6)).length, 6); + }, +}; + +export const generate_prime_size_cap = { + test() { + throws(() => generatePrimeSync(8193), { name: "RangeError" }); + ok(generatePrimeSync(512).byteLength > 0); + }, +}; diff --git a/src/compat/crypto/upstream-scrypt-limits.fixture.mjs b/src/compat/crypto/upstream-scrypt-limits.fixture.mjs new file mode 100644 index 0000000..ec81656 --- /dev/null +++ b/src/compat/crypto/upstream-scrypt-limits.fixture.mjs @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import { strictEqual, throws } from "node:assert"; +import { scrypt, scryptSync } from "./index.js"; + +export const scrypt_cost_limit_sync = { + test() { + // N=1024, r=1, p=32768 → cost=33,554,432, exceeds 2^20 + throws(() => scryptSync("p", "s", 64, { N: 1024, r: 1, p: 32768 }), { + name: "RangeError", + }); + + // N=2, r=1, p=1048577 → cost just over 2^20 + throws(() => scryptSync("p", "s", 64, { N: 2, r: 1, p: 1048577 }), { + name: "RangeError", + }); + + // N=1024, r=1, p=1 → cost=1024, within limit + const result = scryptSync("p", "s", 64, { N: 1024, r: 1, p: 1 }); + strictEqual(result.length, 64); + }, +}; + +export const scrypt_cost_limit_async = { + async test() { + const { promise, resolve, reject } = Promise.withResolvers(); + scrypt("p", "s", 64, { N: 1024, r: 1, p: 32768 }, (err) => { + if (err) { + resolve(err); + } else { + reject(new Error("Expected error")); + } + }); + const err = await promise; + strictEqual(err.constructor.name, "RangeError"); + }, +}; diff --git a/src/compat/crypto/upstream-scrypt.fixture.mjs b/src/compat/crypto/upstream-scrypt.fixture.mjs new file mode 100644 index 0000000..a3db32a --- /dev/null +++ b/src/compat/crypto/upstream-scrypt.fixture.mjs @@ -0,0 +1,257 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// Adapted from Node.js. Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +"use strict"; + +import { strictEqual, throws, rejects } from "node:assert"; + +import { scrypt, scryptSync } from "./index.js"; + +import { mock } from "node:test"; + +const good = [ + // Zero-length key is legal, functions as a parameter validation check. + { + pass: "", + salt: "", + keylen: 0, + N: 16, + p: 1, + r: 1, + expected: "", + }, + // Test vectors from https://tools.ietf.org/html/rfc7914#page-13 that + // should pass. Note that the test vector with N=1048576 is omitted + // because it takes too long to complete and uses over 1 GiB of memory. + { + pass: "", + salt: "", + keylen: 64, + N: 16, + p: 1, + r: 1, + expected: + "77d6576238657b203b19ca42c18a0497f16b4844e3074ae8dfdffa3fede21442" + + "fcd0069ded0948f8326a753a0fc81f17e8d3e0fb2e0d3628cf35e20c38d18906", + }, + { + pass: "password", + salt: "NaCl", + keylen: 64, + N: 1024, + p: 16, + r: 8, + expected: + "fdbabe1c9d3472007856e7190d01e9fe7c6ad7cbc8237830e77376634b373162" + + "2eaf30d92e22a3886ff109279d9830dac727afb94a83ee6d8360cbdfa2cc0640", + }, + { + pass: "pleaseletmein", + salt: "SodiumChloride", + keylen: 64, + N: 16384, + p: 1, + r: 8, + expected: + "7023bdcb3afd7348461c06cd81fd38ebfda8fbba904f8e3ea9b543f6545da1f2" + + "d5432955613f0fcf62d49705242a9af9e61e85dc0d651e40dfcf017b45575887", + }, + { + pass: "", + salt: "", + keylen: 64, + cost: 16, + parallelization: 1, + blockSize: 1, + expected: + "77d6576238657b203b19ca42c18a0497f16b4844e3074ae8dfdffa3fede21442" + + "fcd0069ded0948f8326a753a0fc81f17e8d3e0fb2e0d3628cf35e20c38d18906", + }, + { + pass: "password", + salt: "NaCl", + keylen: 64, + cost: 1024, + parallelization: 16, + blockSize: 8, + expected: + "fdbabe1c9d3472007856e7190d01e9fe7c6ad7cbc8237830e77376634b373162" + + "2eaf30d92e22a3886ff109279d9830dac727afb94a83ee6d8360cbdfa2cc0640", + }, + { + pass: "pleaseletmein", + salt: "SodiumChloride", + keylen: 64, + cost: 16384, + parallelization: 1, + blockSize: 8, + expected: + "7023bdcb3afd7348461c06cd81fd38ebfda8fbba904f8e3ea9b543f6545da1f2" + + "d5432955613f0fcf62d49705242a9af9e61e85dc0d651e40dfcf017b45575887", + }, +]; + +// Test vectors that should fail. +const bad = [ + { N: 1, p: 1, r: 1 }, // N < 2 + { N: 3, p: 1, r: 1 }, // Not power of 2. +]; + +// Test vectors where 128*N*r exceeds maxmem. +const toobig = [ + { N: 2 ** 16, p: 1, r: 1 }, // N >= 2**(r*16) + { N: 2, p: 2 ** 30, r: 1 }, // p > (2**30-1)/r + { N: 2 ** 20, p: 1, r: 8 }, + { N: 2 ** 10, p: 1, r: 8, maxmem: 2 ** 20 }, +]; + +const badargs = [ + { + args: [], + expected: { code: "ERR_INVALID_ARG_TYPE", message: /"password"/ }, + }, + { + args: [null], + expected: { code: "ERR_INVALID_ARG_TYPE", message: /"password"/ }, + }, + { + args: [""], + expected: { code: "ERR_INVALID_ARG_TYPE", message: /"salt"/ }, + }, + { + args: ["", null], + expected: { code: "ERR_INVALID_ARG_TYPE", message: /"salt"/ }, + }, + { + args: ["", ""], + expected: { code: "ERR_INVALID_ARG_TYPE", message: /"keylen"/ }, + }, + { + args: ["", "", null], + expected: { code: "ERR_INVALID_ARG_TYPE", message: /"keylen"/ }, + }, + { + args: ["", "", 0.42], + expected: { code: "ERR_OUT_OF_RANGE", message: /"keylen"/ }, + }, + { + args: ["", "", -42], + expected: { code: "ERR_OUT_OF_RANGE", message: /"keylen"/ }, + }, + { + args: ["", "", 2 ** 31], + expected: { code: "ERR_OUT_OF_RANGE", message: /"keylen"/ }, + }, + { + args: ["", "", 2147485780], + expected: { code: "ERR_OUT_OF_RANGE", message: /"keylen"/ }, + }, + { + args: ["", "", 2 ** 32], + expected: { code: "ERR_OUT_OF_RANGE", message: /"keylen"/ }, + }, +]; + +export const goodTests = { + async test() { + for (const options of good) { + const { pass, salt, keylen, expected } = options; + const actual = scryptSync(pass, salt, keylen, options); + strictEqual(actual.toString("hex"), expected); + const { promise, resolve } = Promise.withResolvers(); + const fn = mock.fn((err, actual) => { + strictEqual(actual.toString("hex"), expected); + resolve(); + }); + scrypt(pass, salt, keylen, options, fn); + await promise; + strictEqual(fn.mock.calls.length, 1); + } + }, +}; + +export const badTests = { + async test() { + for (const options of bad) { + const { promise, reject } = Promise.withResolvers(); + const fn = mock.fn((err, actual) => { + if (err) reject(err); + }); + scrypt("pass", "salt", 1, options, fn); + await rejects(promise); + throws(() => scryptSync("pass", "salt", 1, options)); + } + throws(() => scryptSync("pass", "salt", 1, { N: 1, cost: 1 })); + throws(() => scryptSync("pass", "salt", 1, { p: 1, parallelization: 1 })); + throws(() => scryptSync("pass", "salt", 1, { r: 1, blockSize: 1 })); + }, +}; + +export const tooBigTests = { + async test() { + for (const options of toobig) { + const { promise, reject } = Promise.withResolvers(); + const fn = mock.fn((err, actual) => { + if (err) reject(err); + }); + scrypt("pass", "salt", 1, options, fn); + await rejects(promise); + strictEqual(fn.mock.calls.length, 1); + + throws(() => scryptSync("pass", "salt", 1, options)); + } + }, +}; + +export const defaultsTest = { + async test() { + const defaults = { N: 16384, p: 1, r: 8 }; + const expected = scryptSync("pass", "salt", 1, defaults); + const actual = scryptSync("pass", "salt", 1); + strictEqual(actual.toString("hex"), expected.toString("hex")); + const { promise, resolve } = Promise.withResolvers(); + const fn = mock.fn((err, actual) => { + strictEqual(actual.toString("hex"), expected.toString("hex")); + resolve(); + }); + scrypt("pass", "salt", 1, fn); + await promise; + strictEqual(fn.mock.calls.length, 1); + }, +}; + +export const badArgsTest = { + test() { + for (const { args, expected: _expected } of badargs) { + throws(() => scrypt(...args)); + throws(() => scryptSync(...args)); + } + + throws(() => scrypt("", "", 42, null)); + throws(() => scrypt("", "", 42, {}, null)); + throws(() => scrypt("", "", 42, {})); + throws(() => scrypt("", "", 42, {}, {})); + }, +}; diff --git a/src/compat/crypto/validation.ts b/src/compat/crypto/validation.ts index 47c6c31..63184bc 100644 --- a/src/compat/crypto/validation.ts +++ b/src/compat/crypto/validation.ts @@ -1,3 +1,8 @@ +import { + bufferBytes, + viewBytes, + isAnyBuffer, +} from "../../shared/buffer-source.js"; import { Buffer } from "node:buffer"; import { ERR_INVALID_ARG_TYPE, ERR_OUT_OF_RANGE } from "../net/errors.js"; export function validateInteger( @@ -15,12 +20,48 @@ export function validateInteger( } export function bytes(value: unknown, name: string): Buffer { if (typeof value === "string") return Buffer.from(value); - if (value instanceof ArrayBuffer) return Buffer.from(value); - if (ArrayBuffer.isView(value)) - return Buffer.from(value.buffer, value.byteOffset, value.byteLength); + if (isAnyBuffer(value)) return Buffer.from(value); + if (ArrayBuffer.isView(value)) return Buffer.from(viewBytes(value)); throw new ERR_INVALID_ARG_TYPE( name, ["string", "ArrayBuffer", "Buffer", "TypedArray", "DataView"], value, ); } + +// Native Workers crypto entry points use signed 32-bit buffer lengths. +export function validateCryptoLength(length: number, message: string): void { + if (length > 0x7fffffff) throw new RangeError(message); +} + +export { viewBytes, isAnyBuffer } from "../../shared/buffer-source.js"; + +// Preserve observable frontend properties until the native boundary. +export function arrayBufferOrView( + value: unknown, + name: string, + expected = ["string", "ArrayBuffer", "Buffer", "TypedArray", "DataView"], +): ArrayBuffer | ArrayBufferView { + if (typeof value === "string") return Buffer.from(value); + if (isAnyBuffer(value) || ArrayBuffer.isView(value)) return value; + throw new ERR_INVALID_ARG_TYPE(name, expected, value); +} +const arrayBufferLength = Object.getOwnPropertyDescriptor( + ArrayBuffer.prototype, + "byteLength", +)!.get!; +export function inputBytes( + value: ArrayBuffer | ArrayBufferView, + method: string, + parameter: number, +): Uint8Array { + if (ArrayBuffer.isView(value)) return viewBytes(value); + try { + arrayBufferLength.call(value); + } catch { + throw new TypeError( + `Failed to execute '${method}' on 'CryptoImpl': parameter ${parameter} is not of type 'BufferSource'.`, + ); + } + return bufferBytes(value as ArrayBuffer); +} diff --git a/src/compat/crypto/webcrypto.ts b/src/compat/crypto/webcrypto.ts new file mode 100644 index 0000000..e869c5b --- /dev/null +++ b/src/compat/crypto/webcrypto.ts @@ -0,0 +1,71 @@ +import { installDigest } from "./subtle-digest.js"; +import { installResourceSymbols } from "../../shared/globals/symbols.js"; +import { viewBytes } from "./validation.js"; +import { DigestStream } from "./digest-stream.js"; +import { nativeTimingEqual } from "./native-crypto.js"; +import { Buffer } from "node:buffer"; + +type WorkersSubtleCrypto = SubtleCrypto & { + timingSafeEqual( + left: ArrayBuffer | ArrayBufferView, + right: ArrayBuffer | ArrayBufferView, + ): boolean; +}; +type WorkersCrypto = Crypto & { + DigestStream: typeof DigestStream; + subtle: WorkersSubtleCrypto; +}; +installResourceSymbols(globalThis.Symbol); +export const webcrypto = globalThis.crypto as WorkersCrypto; +export const subtle = webcrypto.subtle; +installDigest(subtle); +function source(value: ArrayBuffer | ArrayBufferView, parameter: number) { + if (ArrayBuffer.isView(value)) return viewBytes(value); + try { + Object.getOwnPropertyDescriptor( + ArrayBuffer.prototype, + "byteLength", + )!.get!.call(value); + try { + return Buffer.from(value as ArrayBuffer); + } catch { + return new Uint8Array(0); + } + } catch { + throw new TypeError( + `Failed to execute 'timingSafeEqual' on 'SubtleCrypto': parameter ${parameter} is not of type 'BufferSource'.`, + ); + } +} +function timingSafeEqual( + this: SubtleCrypto, + left: ArrayBuffer | ArrayBufferView, + right: ArrayBuffer | ArrayBufferView, +) { + if (this !== subtle) + throw new TypeError( + "Illegal invocation: function called with incorrect `this` reference. See https://developers.cloudflare.com/workers/observability/errors/#illegal-invocation-errors for details.", + ); + const a = source(left, 1), + b = source(right, 2); + if (a.byteLength !== b.byteLength) + throw new TypeError("Input buffers must have the same byte length."); + return nativeTimingEqual(a, b); +} +// Preserve host Crypto/SubtleCrypto identity. Digest uses the Workers registry; +// other standard operations retain their host implementation. +// These Workers extensions use the owned digest and comparison backend. +if (!("DigestStream" in webcrypto)) + Object.defineProperty(Object.getPrototypeOf(webcrypto), "DigestStream", { + value: DigestStream, + writable: true, + enumerable: true, + configurable: true, + }); +if (!("timingSafeEqual" in subtle)) + Object.defineProperty(Object.getPrototypeOf(subtle), "timingSafeEqual", { + value: timingSafeEqual, + writable: true, + enumerable: true, + configurable: true, + }); diff --git a/src/compat/crypto/x509.ts b/src/compat/crypto/x509.ts index c2775d7..84e78b1 100644 --- a/src/compat/crypto/x509.ts +++ b/src/compat/crypto/x509.ts @@ -26,6 +26,7 @@ /* TODO: the following is adopted code, enabling linting one day */ /* eslint-disable */ +import { viewBytes } from "../../shared/buffer-source.js"; import { NativeX509 } from "./native-crypto.js"; import { validateString, @@ -120,9 +121,7 @@ export class X509Certificate { buffer, ); } - const handle = NativeX509.parse( - new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength), - ); + const handle = NativeX509.parse(viewBytes(buffer)); if (handle == null) { throw new ERR_INVALID_ARG_VALUE( "buffer", diff --git a/src/compat/expansion/README.md b/src/compat/expansion/README.md index 2f9ccd2..9e892f8 100644 --- a/src/compat/expansion/README.md +++ b/src/compat/expansion/README.md @@ -9,38 +9,165 @@ whole modules. Implemented profiles include console's Workers stubs, sys aliasing, public util/types predicates, crypto random integers/UUIDs/Web Crypto exports, -hash/HKDF/scrypt, the four Workers ECDH curves, crypto engine/secure-heap stubs, +Hash/HMAC/PBKDF2/HKDF/scrypt, the four Workers ECDH curves, crypto engine/secure-heap stubs, writable memory-backed /tmp, module metadata, HTTP internal helpers, adapter-owned secret and asymmetric KeyObjects, prime generation and checking, incremental signatures, Certificate SPKAC and X509Certificate operations, -Brotli/Zstd codecs and CRC32. +zlib/Brotli/Zstd codecs and CRC32, and cipher metadata lookup. Additional profiles cover process resource/permission/report stubs, its pinned wall-clock hrtime behavior, and TLS hostname/ALPN/options contracts. Native runs also test real TLS exchange and hostname mismatch rejection. +Custom Duplex wrapping follows Workers' stream behavior, including byte +forwarding, EOF and error cleanup. This path does not perform a TLS handshake or +encrypt the custom Duplex's bytes. Authenticated TLS/STARTTLS requires the native +socket transport; wrapping an arbitrary stream does not provide that security. +The custom byte-stream adapter uses owned BYOB streams on both WebViews. + The SDK starts a fresh /tmp volume for each local fetch dispatch. Handles opened inside that request retain their volume; embedded bundle assets stay read-only. Standalone imports outside SDK dispatch share a fallback volume. Copying embedded files to /tmp preserves immutable embedded bytes. Permission changes are validated no-ops and statfs returns the pinned Workers zero values. -These checks do not cover every descriptor or cross-volume operation. Watching files is unsupported in Workers too. +Embedded files support read-only descriptors, FileHandles, streams and links; copies into /tmp are writable and independent. Numeric file timestamps use milliseconds; embedded timestamps remain unchanged. Watching files is unsupported in Workers too. Directory iteration, recursive listings, glob patterns, device files, symlink copying, +Monotonically allocated descriptors, byte streams and stream fd/custom-fs options follow the pinned Workers +profile. File sizes are capped at 128 MiB and paths at 4096 bytes/48 segments. +`process.cwd()` and `process.chdir()` use the request-local virtual directory; +streams retain the creating request's filesystem when external callbacks deliver +chunks. FileHandle byte streams use the owned Web Streams implementation, +including on WebViews without native BYOB support. These profiles do not certify +every filesystem operation. The vendored upstream fixtures in `src/compat/fs` +retain their license headers and run with `npx vitest run src/compat/fs/directory.spec.ts +src/compat/fs/glob.spec.ts src/compat/fs/writable.spec.ts`; the zlib integration fixture +also exercises a host stream crossing into the request-local filesystem. + +FileHandles expose owned prototype methods and EventEmitter behavior. Their +backend and originating volume are private; the `fd` getter cannot be assigned, +and borrowed methods use their actual receiver. Closed handles reject operations +with Workers' EBADF contract. Descriptor numbers are never reused within a +volume, so a stale handle cannot access a later file. Closing a handle drops its +retained volume reference; finalization also releases an abandoned descriptor. +`readableWebStream()` captures `read` when created. The upstream and additional +receiver/lifetime tests run with `npx vitest run src/compat/fs/file-handle.spec.ts +src/compat/fs/directory.spec.ts`. +`fs.promises.watch()` throws Workers' `Not implemented` error at invocation, +before returning a Promise or iterator. File writes consume the intrinsic view +extent; the positional read/write frontends retain their observable property +access, as in Workers. -Brotli 1.1.0 and Zstd 1.5.7 use bundled, synchronously initialized WebAssembly. +zlib 1.3.1, Brotli 1.1.0 and Zstd 1.5.7 use bundled, synchronously initialized WebAssembly. The profile covers parameters, synchronous/callback operations, incremental streams, flush/reset, byte accounting and output limits for synchronous processing. Streaming output is not cumulatively capped by maxOutputLength. +Fast compression helpers, their callbacks and CRC32 consume intrinsic byte +ranges, including detached views as empty input. Stream frontends retain the +Workers property-access behavior. Owned bindings validate input and output +slices against their intrinsic extents before entering Wasm, including uint32 +overflow and `info: true` calls with shadowed byte lengths. Numeric conversion +rejects BigInt, including objects coercing to BigInt. Shared-memory and +cross-realm helper checks run in Node; ordinary browser/native contracts do not +claim SharedArrayBuffer availability. The pinned source revisions and artifact hashes accompany the codecs; rebuild -with scripts/build-native-codecs.mjs and the pinned Emscripten SDK. Remaining -codec edge cases and inherited gzip/deflate behavior still require acceptance. +with scripts/build-native-codecs.mjs or scripts/build-native-zlib.mjs and the pinned Emscripten SDK. Gzip/deflate and Brotli/Zstd streams use the pinned Workers stream lifecycle. The comparative cases include constructor validation, callback timing, reset, close, byte accounting, dictionaries and output limits; whole-module acceptance remains a separate gate. Scrypt uses a synchronous JavaScript backend, including for callback completion, -so large parameters can block the WebView. +so large parameters can block the WebView. Cost and maxmem validation follow the +pinned Workers limits, including its 1048576 cost ceiling. + +Hash and HMAC use the owned OpenSSL backend, including incremental input, +Hash copying, stream finalization and repeated-digest behavior. `getHashes()` +returns the pinned 17 names, including MD4, MD5-SHA1 and the RSA/DSA aliases. +PBKDF2 and HKDF use the same digest mapping and preserve the pinned Workers +limit of 255 digest blocks. Callback completion is asynchronous, while the +underlying derivation runs synchronously; high PBKDF2 iteration counts can block +the WebView. The upstream digest/KDF fixtures run with +`npx vitest run src/compat/crypto/hash.spec.ts src/compat/crypto/hash-types.spec.ts`. +Native measurements record whether the engine provides `Float16Array`. +Where absent, its input brand is unverified; equivalent half-float bytes are +still tested as a byte-array input. This does not certify complete crypto input +coverage on that engine. + +`crypto.webcrypto.DigestStream` uses the owned incremental digest backend for +SHA-1/256/384/512 and MD5, plus CRC32/CRC32C/CRC64NVME. Its string input follows +Workers' WTF-8 behavior by default; `toWellFormed` carries a trailing high +surrogate across string writes. Digest promises, byte counts, abort and disposal +follow the pinned runtime. `crypto.subtle.timingSafeEqual` uses the Wasm native +comparison primitive and intrinsic BufferSource extents. These extensions retain +the host Crypto/SubtleCrypto objects and their existing WebCrypto operations. +The tests are `src/compat/crypto/digest-stream.spec.ts` and the shared +`cryptoWebExtensions` case. + +Signature digest aliases, constant descriptors, binary signature views and +failure states are compared with Workers. Streaming RSA padding/salt options +apply to the streaming signature API; the pinned one-shot implementation +validates their frontend types but does not apply them. This distinction is +intentional. The strict consumer declarations and parameter matrix cover both +forms; they do not certify every key import/export option. + +Random bytes and fills use the pinned Workers frontend and host cryptographic +entropy. `pseudoRandomBytes` is the same function as `randomBytes`. Sync fills +return the selected subview; callback fills return the original input. Workers' +byte-length validation and typed-array element slicing are preserved, including +its host Web Crypto quota and rejection of floating-point arrays. Input brands +are checked with intrinsic accessors, not `Symbol.toStringTag`. Host Web Crypto +brand-error wording varies by engine; shared contracts verify the error class. +The upstream random/prime fixtures and consumer declaration checks run with +`npx vitest run src/compat/crypto/random.spec.ts`. Adapter-created secret keys support copied raw/JWK export, equality, generation, HMAC, HKDF and symmetric ciphers. KeyObject.from can retain a host CryptoKey, but -synchronous extraction of its opaque key material is unavailable. RSA, EC, Ed25519 and X25519 key generation and PEM/DER/JWK conversion use -bundled OpenSSL 3.5.9 WebAssembly; RSA/EC streaming and one-shot signatures +synchronous extraction of its opaque key material is unavailable. Its public +algorithm metadata is copied through intrinsic getters when the KeyObject is +created. On WebKit, a host key's algorithm object can already have been mutated +before that conversion; its original metadata cannot then be recovered through +the public synchronous API. Transparent Proxy arrays are also indistinguishable +from ordinary arrays in JavaScript, while Workers rejects them in JWK dictionaries. +These remain observable engine boundaries, not whole-module acceptance. +RSA, EC, Ed25519 and X25519 key generation and owned key operations use +bundled OpenSSL 3.5.9 WebAssembly. PEM/DER import first uses the pinned BoringSSL +and ncrypto parsing rules, including unsupported key types, encoding selection, +encrypted input and certificate public keys. PEM/DER export uses the same +BoringSSL writer and ncrypto encoding choices, after the Workers frontend and +native dictionary option conversions. EC encoding flags survive the canonical +key transfer. Plain and encrypted PKCS8/PKCS1/SEC1/SPKI outputs are checked by +re-importing and comparing key material. Temporary private DER copies are +cleared after transfer. JWK import preserves dictionary getter order, snapshots +array lengths, validates base64url and EC private scalar bounds, and accepts +Workers' curve aliases. DSA metadata and the JWK export rejection are preserved. +SharedArrayBuffer input is checked separately on hosts with shared memory; +the ordinary browser profile does not provide that constructor, and the native +key contracts use ArrayBuffer rather than claiming shared-memory coverage. +RSA/EC streaming and one-shot signatures are compared with Workers and independent test signatures. This covers selected -algorithms and operations, not the whole crypto module. Additional key-generation -options and remaining error contracts still need work. +algorithms and operations, not the whole crypto module. Generation validates encoding +options before allocating keys, preserves RSA's 16384-bit limit and 128-bit size +rounding, and matches EC curve aliases and SEC1 parameter flags. DH custom-prime +generation and shared-secret calculation use the pinned BoringSSL backend, +including small primes and rejection of mismatched parameters. Workers' DH key +export rejection is preserved. The upstream key-generation and scrypt fixtures +run with `npx vitest run src/compat/crypto/kdf-limits.spec.ts`. +The DiffieHellman and DiffieHellmanGroup frontend uses the same BoringSSL backend +for parameter checking, key generation, setters and padded shared secrets. +Fixed groups are modp14 through modp18; their pinned `verifyError` is 8, including +BoringSSL's generator-suitability flag. Numeric parameter generation requires an +active local request, including when crypto is first dynamically imported within +that request. The request marker is initialized at dispatch, without changing +host globals merely by importing the dispatcher. Safe-prime generation is +synchronous and can occupy the WebView for a long time. Workers' per-request +CPU/memory limit-enforcer cancellation is not implemented; the maximum prime +size alone does not provide that protection. The DH/ECDH upstream fixtures and +consumer declaration checks run with `npx vitest run src/compat/crypto/dh.spec.ts`. +Remaining key import/export and other crypto error contracts still need work. Cipheriv/Decipheriv use the same pinned BoringSSL revision as Workers. Their +frontend preserves Workers' `authLengthTag` option spelling, AEAD's one-update +limit, early authentication-tag requirements, tag consumption and buffer copies. +The listed classic and AEAD cipher names have shared algorithm/state contracts; +metadata lookup still does not certify every option or the entire crypto module. +The separate cipher Wasm and provenance are in `crypto/native-cipher.*`. +Rebuild with `node scripts/build-native-cipher.mjs` after checking out the pinned +BoringSSL commit into `artifacts/upstream/boringssl` and installing Emscripten +4.0.20 at the pinned SDK revision. The script uses CMake 4.2.0 and Ninja 1.13.0 +from a Python venv at `artifacts/toolchains/boringssl-build` (install with +`python3 -m venv artifacts/toolchains/boringssl-build` then that venv's +`pip install cmake==4.2.0 ninja==1.13.0`). `--link-only` requires libraries +previously built with those settings. No FIPS certification is claimed. X509Certificate parses PEM/DER, exposes certificate fields and legacy objects, extracts RSA/EC public keys, checks names and issuers, and verifies signatures with certificate public keys. The pinned Workers runtime returns false for @@ -61,7 +188,7 @@ writes wait for authenticated TLS; hostname mismatch or cancellation closes the connection without sending queued application bytes. Native acceptance exchanges a plaintext upgrade command followed by TLS, checks connection counts and tests page and Dedicated Worker modes on both OSes. Custom Duplex wrapping -is unsupported. iOS upgrades use the existing OpenSSL Wasm backend with memory +forwards bytes as in Workers and does not itself encrypt them. iOS upgrades use the existing OpenSSL Wasm backend with memory BIOs on the original TCP connection; SecTrust must authenticate the peer chain and hostname before the connection opens or application data is exposed. Android uses the existing Netty channel with the platform SSLEngine. Native tests cover @@ -70,7 +197,29 @@ Full TLS class and HTTP internal state contracts remain open. Consult the generated completion report for remaining work. -Workers reports `fips=true` because of its own crypto backend. The WebView's -JavaScript implementations do not establish FIPS compliance, so that flag is -not copied merely to match the export inventory. `setFips` and `setEngine` -retain Workers' unsupported-operation behavior. +Hash, HMAC, cipher data/IV/AAD/tag, streaming signature and prime native +boundaries read intrinsic BufferSource extents, including detached empty inputs. +Secret-key construction retains the Workers JavaScript frontend's observable +buffer/offset/length copy, so a shadow getter may reject before native processing. +RSA encrypt/decrypt digest names use the pinned Workers digest registry, including +its aliases, case handling and NUL termination. OpenSSL-only digest names are +rejected through the same native error path. + +`crypto.subtle.digest` preserves host SubtleCrypto identity while using the +Workers registry (SHA-1, SHA-256, SHA-384, SHA-512 and MD5), conversion order, +promise rejection and intrinsic input extents. Bare SharedArrayBuffer is rejected; +views over shared memory are accepted where the host exposes shared memory. +Other standard SubtleCrypto operations still use the host implementation and +remain subject to the operation-level compatibility inventory. + +Workers reports `fips=true` because of its own crypto backend. The owned WebView +backends do not run in a validated FIPS mode: `fips` and `getFips()` report false. +This is an explicit capability difference, not Workers FIPS equivalence. +`setFips`, assignment to default-export `fips`, and `setEngine` retain Workers' +unsupported-operation behavior. Surface contracts check names and descriptors; +the local capability value is asserted separately from Workers behavior. + +For focused diagnosis, set `NODE_COMPAT_CASES` to a comma-separated list when +running `expansion-contracts.probe.mjs`. Focused output goes to +`demo/artifacts/node-inventory/targeted/` and does not replace the full baseline +used by native acceptance. Omit this variable for the required complete run. diff --git a/src/compat/expansion/boundaries.spec.mjs b/src/compat/expansion/boundaries.spec.mjs new file mode 100644 index 0000000..85a372c --- /dev/null +++ b/src/compat/expansion/boundaries.spec.mjs @@ -0,0 +1,84 @@ +// Local safety contracts for documented engine differences, not Workers parity. +export const localBoundaryExpected = { + codecDetachedFixedBuffer: { rejected: 32, conversionErrors: 6 }, +}; +export const localBoundaryCases = { + codecDetachedFixedBuffer: async function ({ zlib: z }) { + let rejected = 0, + conversionErrors = 0; + for (const method of ["deflate", "brotliCompress", "zstdCompress"]) + for (const field of method === "deflate" + ? ["input", "dictionary"] + : ["input"]) + for (const length of [0, 3]) + for (const callback of [false, true]) + for (const asView of [false, true]) { + const buffer = new ArrayBuffer(length), + view = new Uint8Array(buffer); + const value = asView ? view : buffer; + const options = { + dictionary: field === "dictionary" ? value : undefined, + get maxOutputLength() { + if (length) view[0] = 120; + structuredClone(buffer, { transfer: [buffer] }); + return 1000; + }, + }; + let calls = 0, + returned = false; + try { + const input = field === "input" ? value : "abc"; + if (callback) + await new Promise((resolve, reject) => { + z[method](input, options, (error, result) => { + calls++; + if (!returned) + reject(new Error("Callback ran synchronously")); + else error ? reject(error) : resolve(result); + }); + returned = true; + }); + else z[method + "Sync"](input, options); + throw new Error("Detached input was processed"); + } catch (error) { + if ( + error.name !== "TypeError" || + error.message !== + "Codec input was detached during option conversion" || + calls !== (callback ? 1 : 0) + ) + throw error; + rejected++; + } + } + for (const method of ["deflate", "brotliCompress", "zstdCompress"]) + for (const callback of [false, true]) { + const input = new Uint8Array(3); + let calls = 0; + try { + const options = { + get maxOutputLength() { + structuredClone(input.buffer, { transfer: [input.buffer] }); + return -1; + }, + }; + if (callback) + z[method](input, options, () => { + calls++; + }); + else z[method + "Sync"](input, options); + throw new Error("Invalid option was accepted"); + } catch (error) { + if ( + error.name !== "TypeError" || + error.message !== + "The value cannot be converted because it is negative and this API expects a positive number." || + calls !== 0 + ) + throw error; + conversionErrors++; + } + } + return { rejected, conversionErrors }; + }, +}; diff --git a/src/compat/expansion/index.spec.mjs b/src/compat/expansion/index.spec.mjs index e3bd0a1..5487ca8 100644 --- a/src/compat/expansion/index.spec.mjs +++ b/src/compat/expansion/index.spec.mjs @@ -1,4 +1,5493 @@ export const eventCases = { + httpEndBodyStates: async function ({ http: h, cloudflareNode: n }) { + const out = {}; + for (const [id, chunk] of [ + ["empty", ""], + ["zero", 0], + ["false", false], + ["invalid", {}], + ["bytes", new Uint8Array(0)], + ]) { + const req = new h.IncomingMessage(); + req.method = "HEAD"; + const res = new h.ServerResponse(req, { + rejectNonStandardBodyWrites: true, + }); + res._hasBody = false; + res.on("error", () => {}); + try { + res.end(chunk); + out[id] = "ok"; + } catch (e) { + out[id] = { name: e.name, code: e.code ?? null, message: e.message }; + } + } + return out; + }, + httpUploadLifetime: async function ({ http: h, cloudflareNode: n }) { + const out = {}; + for (const mode of ["early", "readerCancel"]) { + let cancelled = false; + const upload = new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + request = new Request("https://example.test/", { + method: "POST", + body: upload, + duplex: "half", + }); + let req, res; + const log = []; + const server = h.createServer((q, s) => { + req = q; + res = s; + q.on("aborted", () => log.push("aborted")); + q.on("close", () => log.push("reqClose")); + s.on("close", () => log.push("resClose")); + if (mode === "early") s.end("early"); + else s.flushHeaders(); + }); + try { + const response = await n.httpServerHandler(server).fetch(request); + let body; + if (mode === "early") body = await response.text(); + else await response.body.cancel(); + await new Promise((r) => setTimeout(r, 0)); + out[mode] = { + body, + cancelled, + locked: request.body.locked, + reqDestroyed: req.destroyed, + resDestroyed: res.destroyed, + log, + }; + } catch (e) { + out[mode] = { name: e.name, message: e.message }; + } finally { + server.close(); + } + } + return out; + }, + httpServerStateTransitions: async function ({ http: h, cloudflareNode: n }) { + const out = {}, + error = (e) => ({ + name: e.name, + code: e.code ?? null, + message: e.message, + }); + for (const mode of [ + "lateSetHeader", + "lateWriteHead", + "headReject", + "noContentWrite", + "abortPending", + "custom", + ]) { + let server, req, res; + const log = []; + let observed; + const options = {}; + if (mode === "headReject") options.rejectNonStandardBodyWrites = true; + if (mode === "custom") { + options.IncomingMessage = class extends h.IncomingMessage { + constructor(...a) { + super(...a); + log.push("customRequest"); + } + }; + options.ServerResponse = class extends h.ServerResponse { + constructor(...a) { + super(...a); + log.push("customResponse"); + } + }; + } + const abort = new AbortController(); + try { + server = h.createServer(options, (q, s) => { + req = q; + res = s; + q.on("error", (e) => log.push(["requestError", error(e)])); + q.on("aborted", () => log.push("aborted")); + q.on("close", () => log.push("requestClose")); + s.on("error", (e) => log.push(["responseError", error(e)])); + s.on("close", () => log.push("responseClose")); + if (mode === "abortPending") return; + try { + if (mode === "lateSetHeader") { + s.flushHeaders(); + s.setHeader("x-late", "yes"); + } + if (mode === "lateWriteHead") { + s.flushHeaders(); + s.writeHead(201); + } + if (mode === "headReject" || mode === "noContentWrite") { + if (mode === "noContentWrite") s.statusCode = 204; + observed = s.write("data", (e) => + log.push(["writeCallback", e ? error(e) : null]), + ); + } + if (mode === "custom") + observed = [ + q instanceof options.IncomingMessage, + s instanceof options.ServerResponse, + ]; + } catch (e) { + observed = error(e); + } + s.end(); + }); + const pending = n.httpServerHandler(server).fetch( + new Request("https://test/", { + method: mode === "headReject" ? "HEAD" : "GET", + signal: abort.signal, + }), + ); + let response; + if (mode === "abortPending") { + await Promise.resolve(); + abort.abort(new Error("cancel-request")); + const result = await Promise.race([ + pending.then( + (r) => ({ response: r }), + (e) => ({ failure: error(e) }), + ), + new Promise((resolve) => + setTimeout(() => resolve({ pending: true }), 30), + ), + ]); + observed = { + ...result, + response: result.response ? true : undefined, + requestDestroyed: req?.destroyed, + responseDestroyed: res?.destroyed, + }; + if (result.response) response = result.response; + else { + res.end(); + try { + response = await pending; + } catch {} + } + } else response = await pending; + let body; + if (response) + try { + body = await response.text(); + } catch (e) { + body = error(e); + } + await new Promise((r) => setTimeout(r, 0)); + out[mode] = { observed, status: response?.status, body, log }; + } catch (e) { + out[mode] = { error: error(e), log }; + } finally { + server?.close(); + } + } + return out; + }, + + zlibParamsStates: async function ({ zlib: z }) { + const out = []; + for (const stage of ["before", "between", "after"]) + for (const level of [1, 10]) { + const stream = z.createDeflate(), + trace = [], + original = globalThis.queueMicrotask; + stream.on("error", (e) => + trace.push(["error", e.name, e.code ?? null, e.message]), + ); + stream.resume(); + try { + if (stage === "between") + await new Promise((resolve, reject) => + stream.write("payload", (e) => (e ? reject(e) : resolve())), + ); + if (stage === "after") + await new Promise((resolve) => { + stream.once("close", resolve); + stream.end("payload"); + }); + globalThis.queueMicrotask = (callback) => + original(() => { + try { + callback(); + } catch (error) { + trace.push([ + "uncaught", + error.name, + error.code ?? null, + error.message, + ]); + } + }); + try { + stream.params(level, 0, (error) => + trace.push(["callback", error?.name ?? null]), + ); + trace.push(["returned"]); + } catch (error) { + trace.push([ + "throw", + error.name, + error.code ?? null, + error.message, + ]); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } finally { + globalThis.queueMicrotask = original; + stream.destroy(); + } + out.push({ stage, level, trace }); + } + for (const operation of [ + "invalid-flush", + "invalid-callback", + "close-twice", + ]) { + const stream = z.createDeflate(), + trace = []; + stream.on("error", (error) => + trace.push(["error", error.name, error.code ?? null, error.message]), + ); + stream.on("close", () => trace.push(["close"])); + const callback = () => trace.push(["callback"]); + try { + if (operation === "invalid-flush") stream.flush(-1, callback); + else if (operation === "invalid-callback") stream.flush(0, 1); + else { + stream.close(callback); + stream.close(callback); + } + } catch (error) { + trace.push(["throw", error.name, error.code ?? null, error.message]); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + stream.destroy(); + await new Promise((resolve) => setTimeout(resolve, 0)); + out.push({ operation, trace }); + } + return out; + }, + + cryptoRsaOperationStates: function ({ crypto: c }) { + const pair = c.generateKeyPairSync("rsa", { + modulusLength: 1024, + publicKeyEncoding: { format: "pem", type: "spki" }, + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + }), + secret = c.createSecretKey(new Uint8Array(16)), + out = []; + const capture = (fn) => { + try { + const value = fn(); + return { length: value.length }; + } catch (error) { + return { + name: error.name, + code: error.code ?? null, + message: error.message, + }; + } + }; + for (const method of [ + "publicEncrypt", + "privateDecrypt", + "privateEncrypt", + "publicDecrypt", + ]) { + const key = method.startsWith("private") + ? pair.privateKey + : pair.publicKey; + for (const scenario of [ + "wrong-key", + "short-input", + "malformed-ciphertext", + "invalid-padding", + ]) { + out.push({ + method, + scenario, + result: capture(() => + c[method]( + { + key: scenario === "wrong-key" ? secret : key, + ...(scenario === "invalid-padding" ? { padding: 999 } : {}), + }, + scenario === "malformed-ciphertext" + ? new Uint8Array(128) + : new Uint8Array([1]), + ), + ), + }); + } + } + const text = "label", + bytes = new TextEncoder().encode(text), + extended = new Uint8Array([0, ...bytes, 0]); + for (const [kind, label] of [ + ["string", text], + ["buffer", bytes.buffer], + ["offset", new DataView(extended.buffer, 1, bytes.length)], + ["mismatch", bytes], + ]) { + const encrypted = c.publicEncrypt( + { key: pair.publicKey, padding: 4, oaepLabel: label }, + new Uint8Array([1, 2, 3]), + ); + out.push({ + kind, + result: capture(() => + c.privateDecrypt( + { + key: pair.privateKey, + padding: 4, + oaepLabel: kind === "mismatch" ? "wrong" : label, + }, + encrypted, + ), + ), + }); + } + for (const [encrypt, decrypt] of [ + ["publicEncrypt", "privateDecrypt"], + ["privateEncrypt", "publicDecrypt"], + ]) { + const input = new Uint8Array([1, 2, 3]); + const encoded = c[encrypt]( + encrypt.startsWith("public") ? pair.publicKey : pair.privateKey, + input, + ); + const decoded = c[decrypt]( + decrypt.startsWith("public") ? pair.publicKey : pair.privateKey, + encoded, + ); + out.push({ encrypt, decrypt, roundTrip: Array.from(decoded) }); + } + return out; + }, + + cryptoSubtleOperations: async function ({ crypto: c }) { + const subtle = c.webcrypto.subtle, + bytes = new Uint8Array([1, 2, 3, 4]), + out = []; + const equal = (a, b) => { + a = new Uint8Array(a); + b = new Uint8Array(b); + return ( + a.length === b.length && a.every((value, index) => value === b[index]) + ); + }; + for (const extractable of [true, false]) + for (const flow of [ + "AES-GCM", + "HMAC", + "RSA-OAEP", + "ECDSA", + "ECDH", + "HKDF", + "PBKDF2", + "AES-KW", + "raw-JWK", + ]) { + try { + let result; + if (flow === "AES-GCM") { + const key = await subtle.generateKey( + { name: flow, length: 128 }, + extractable, + ["encrypt", "decrypt"], + ), + algorithm = { + name: flow, + iv: new Uint8Array(12), + additionalData: bytes, + }; + const encrypted = await subtle.encrypt(algorithm, key, bytes); + result = { + length: encrypted.byteLength, + roundTrip: equal( + await subtle.decrypt(algorithm, key, encrypted), + bytes, + ), + }; + } else if (flow === "HMAC") { + const key = await subtle.generateKey( + { name: flow, hash: "SHA-256", length: 128 }, + extractable, + ["sign", "verify"], + ), + signature = await subtle.sign(flow, key, bytes); + result = { + length: signature.byteLength, + valid: await subtle.verify(flow, key, signature, bytes), + invalid: await subtle.verify( + flow, + key, + signature, + new Uint8Array([9]), + ), + }; + } else if (flow === "RSA-OAEP") { + const key = await subtle.generateKey( + { + name: flow, + modulusLength: 1024, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + extractable, + ["encrypt", "decrypt"], + ), + algorithm = { name: flow, label: bytes }; + const encrypted = await subtle.encrypt( + algorithm, + key.publicKey, + bytes, + ); + result = { + length: encrypted.byteLength, + roundTrip: equal( + await subtle.decrypt(algorithm, key.privateKey, encrypted), + bytes, + ), + }; + } else if (flow === "ECDSA") { + const key = await subtle.generateKey( + { name: flow, namedCurve: "P-256" }, + extractable, + ["sign", "verify"], + ), + algorithm = { name: flow, hash: "SHA-256" }, + signature = await subtle.sign(algorithm, key.privateKey, bytes); + result = { + length: signature.byteLength, + valid: await subtle.verify( + algorithm, + key.publicKey, + signature, + bytes, + ), + invalid: await subtle.verify( + algorithm, + key.publicKey, + signature, + new Uint8Array([9]), + ), + }; + } else if (flow === "ECDH") { + const a = await subtle.generateKey( + { name: flow, namedCurve: "P-256" }, + extractable, + ["deriveBits"], + ), + b = await subtle.generateKey( + { name: flow, namedCurve: "P-256" }, + extractable, + ["deriveBits"], + ); + const left = await subtle.deriveBits( + { name: flow, public: b.publicKey }, + a.privateKey, + 256, + ), + right = await subtle.deriveBits( + { name: flow, public: a.publicKey }, + b.privateKey, + 256, + ); + result = { length: left.byteLength, equal: equal(left, right) }; + } else if (flow === "HKDF" || flow === "PBKDF2") { + const key = await subtle.importKey( + "raw", + bytes, + flow, + extractable, + ["deriveBits"], + ); + const algorithm = + flow === "HKDF" + ? { name: flow, hash: "SHA-256", salt: bytes, info: bytes } + : { name: flow, hash: "SHA-256", salt: bytes, iterations: 2 }; + result = { + bytes: Array.from( + new Uint8Array(await subtle.deriveBits(algorithm, key, 128)), + ), + }; + } else if (flow === "AES-KW") { + const wrapping = await subtle.generateKey( + { name: flow, length: 128 }, + extractable, + ["wrapKey", "unwrapKey"], + ), + original = await subtle.generateKey( + { name: "AES-GCM", length: 128 }, + true, + ["encrypt", "decrypt"], + ); + const wrapped = await subtle.wrapKey( + "raw", + original, + wrapping, + flow, + ), + restored = await subtle.unwrapKey( + "raw", + wrapped, + wrapping, + flow, + { name: "AES-GCM" }, + extractable, + ["encrypt", "decrypt"], + ); + const algorithm = { name: "AES-GCM", iv: new Uint8Array(12) }, + encrypted = await subtle.encrypt(algorithm, original, bytes); + result = { + length: wrapped.byteLength, + extractable: restored.extractable, + roundTrip: equal( + await subtle.decrypt(algorithm, restored, encrypted), + bytes, + ), + }; + } else { + const raw = new Uint8Array(16), + key = await subtle.importKey( + "raw", + raw, + { name: "AES-GCM" }, + extractable, + ["encrypt", "decrypt"], + ); + const exported = await subtle.exportKey("jwk", key), + imported = await subtle.importKey( + "jwk", + exported, + { name: "AES-GCM" }, + extractable, + ["encrypt", "decrypt"], + ); + result = { + kty: exported.kty, + alg: exported.alg, + ext: exported.ext, + roundTrip: equal(await subtle.exportKey("raw", imported), raw), + }; + } + out.push({ flow, extractable, result }); + } catch (error) { + out.push({ flow, extractable, error: error.name }); + } + } + return out; + }, + + tlsIdentityBoundaries: function ({ tls }) { + const out = { names: [], alpn: [] }; + for (const [host, cert] of [ + ["api.example.com", { subjectaltname: 'DNS:"api.example.com"' }], + ["api.example.com", { subjectaltname: 'DNS:"api\\u002eexample.com"' }], + ["api.example.com", { subjectaltname: 'DNS:"api\\qexample.com"' }], + [ + "two.example.com", + { subject: { CN: ["one.example.com", "two.example.com"] } }, + ], + ["api.one.example.com", { subjectaltname: "DNS:api.*.example.com" }], + [ + "xn--bcher-kva.example", + { subjectaltname: "DNS:xn--bcher-kva.example" }, + ], + ["bücher.example", { subjectaltname: "DNS:xn--bcher-kva.example" }], + ]) { + try { + const error = tls.checkServerIdentity(host, cert); + out.names.push( + error + ? { + name: error.name, + code: error.code, + message: error.message, + sameCert: error.cert === cert, + } + : null, + ); + } catch (error) { + out.names.push({ + name: error.name, + code: error.code ?? null, + message: error.message, + }); + } + } + for (const value of [ + [], + ["x".repeat(255)], + new Uint8Array(0), + new Uint8Array([2, 104, 50]), + new DataView(new Uint8Array([0, 2, 104, 50, 0]).buffer, 1, 3), + ]) { + const target = {}; + tls.convertALPNProtocols(value, target); + const before = target.ALPNProtocols + ? Array.from(target.ALPNProtocols) + : null; + if (ArrayBuffer.isView(value)) + new Uint8Array(value.buffer)[value.byteOffset] = 99; + out.alpn.push({ + before, + after: target.ALPNProtocols ? Array.from(target.ALPNProtocols) : null, + same: target.ALPNProtocols === value, + }); + } + return out; + }, + + fsWholeFileSignals: async function ({ fs }) { + const out = [], + path = "/tmp/signal-whole-file"; + try { + for (const method of ["readFile", "writeFile", "appendFile"]) + for (const timing of ["before", "getter"]) + for (const mode of ["callback", "promise"]) { + fs.writeFileSync(path, "seed"); + const controller = new AbortController(), + reads = [], + reason = new Error("cancel-file"); + if (timing === "before") controller.abort(reason); + const options = { + get encoding() { + reads.push("encoding"); + if (timing === "getter") controller.abort(reason); + return "utf8"; + }, + get signal() { + reads.push("signal"); + return controller.signal; + }, + }; + let returned = false; + const args = + method === "readFile" + ? [path, options] + : [path, "payload", options]; + let result; + try { + const pending = + mode === "promise" + ? fs.promises[method](...args) + : new Promise((resolve, reject) => + fs[method](...args, (error, value) => + error ? reject(error) : resolve(value), + ), + ); + returned = true; + const value = await pending; + result = { value: value ?? null, type: typeof value }; + } catch (error) { + result = { + error: { + name: error.name, + code: error.code ?? null, + message: error.message, + }, + sameReason: error === reason, + returned, + }; + } + out.push({ + method, + timing, + mode, + reads, + result, + contents: fs.readFileSync(path, "utf8"), + }); + } + } finally { + fs.rmSync(path, { force: true }); + } + return out; + }, + fsBlobDirectoryLifetime: async function ({ fs }) { + const root = "/tmp/lifetime-matrix", + out = {}; + fs.mkdirSync(root, { recursive: true }); + try { + for (const change of ["overwrite", "unlink"]) { + const path = root + "/blob"; + fs.writeFileSync(path, "before"); + const blob = await fs.openAsBlob(path); + if (change === "overwrite") fs.writeFileSync(path, "after"); + else fs.unlinkSync(path); + try { + out[change] = { text: await blob.text(), size: blob.size }; + } catch (error) { + out[change] = { + name: error.name, + code: error.code ?? null, + message: error.message, + }; + } + fs.rmSync(path, { force: true }); + } + fs.writeFileSync(root + "/a", "a"); + fs.writeFileSync(root + "/b", "b"); + const dir = await fs.promises.opendir(root), + seen = []; + for await (const entry of dir) { + seen.push(entry.name); + break; + } + const attempt = async (fn) => { + try { + const value = await fn(); + return value?.name ?? value ?? null; + } catch (error) { + return { + name: error.name, + code: error.code ?? null, + message: error.message, + }; + } + }; + out.early = { + seen, + next: await attempt(() => dir.read()), + close: await attempt(() => dir.close()), + }; + const concurrent = await fs.promises.opendir(root); + out.concurrent = ( + await Promise.all([ + concurrent.read(), + concurrent.read(), + concurrent.read(), + ]) + ).map((entry) => entry?.name ?? null); + await concurrent.close(); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + return out; + }, + + zlibDictionaryStates: async function ({ zlib: z }) { + const dictionary = new TextEncoder().encode("common-prefix-common-prefix"); + const wrong = new TextEncoder().encode("different"); + const plain = "common-prefix-common-prefix-payload"; + const out = []; + for (const [encode, decode, Constructor] of [ + ["deflateSync", "inflate", "Inflate"], + ["deflateRawSync", "inflateRaw", "InflateRaw"], + ]) { + const input = z[encode](plain, { dictionary }); + for (const dict of [undefined, wrong, dictionary]) { + const options = dict === undefined ? {} : { dictionary: dict }; + for (const mode of ["sync", "callback", "info", "stream"]) { + try { + let value; + if (mode === "sync") value = z[decode + "Sync"](input, options); + else if (mode === "info") + value = z[decode + "Sync"](input, { + ...options, + info: true, + }).buffer; + else if (mode === "callback") + value = await new Promise((resolve, reject) => + z[decode](input, options, (error, result) => + error ? reject(error) : resolve(result), + ), + ); + else + value = await new Promise((resolve, reject) => { + const stream = new z[Constructor](options), + chunks = []; + stream.on("data", (chunk) => chunks.push(...chunk)); + stream.once("error", (error) => { + stream.destroy(); + reject(error); + }); + stream.once("end", () => resolve(new Uint8Array(chunks))); + stream.end(input); + }); + out.push({ decode, mode, text: new TextDecoder().decode(value) }); + } catch (error) { + out.push({ + decode, + mode, + error: { + name: error.name, + code: error.code ?? null, + message: error.message, + }, + }); + } + } + } + } + return out; + }, + zlibUnzipHeaderStates: async function ({ zlib: z }) { + const out = []; + for (const method of ["gzipSync", "deflateSync"]) + for (const split of [1, 2]) + for (const truncate of [false, true]) { + const encoded = z[method]("split-header-payload"); + out.push( + await new Promise((resolve) => { + const stream = z.createUnzip(), + chunks = []; + stream.on("data", (chunk) => chunks.push(...chunk)); + stream.once("error", (error) => { + stream.destroy(); + resolve({ + method, + split, + truncate, + error: { + name: error.name, + code: error.code ?? null, + message: error.message, + }, + }); + }); + stream.once("end", () => + resolve({ + method, + split, + truncate, + text: new TextDecoder().decode(new Uint8Array(chunks)), + }), + ); + stream.write(encoded.subarray(0, split)); + stream.end(truncate ? undefined : encoded.subarray(split)); + }), + ); + } + return out; + }, + zlibTerminalRaces: async function ({ zlib: z }) { + const out = []; + for (const Constructor of ["Deflate", "BrotliCompress", "ZstdCompress"]) + for (const scenario of ["write-destroy", "flush-destroy", "end-write"]) { + const stream = new z[Constructor](), + trace = [], + failure = new Error("controlled-cancel"); + const callback = (name) => (error) => + trace.push([name, error?.message ?? null]); + stream.on("error", callback("error")); + stream.on("end", () => trace.push(["end", null])); + stream.on("finish", () => trace.push(["finish", null])); + const closed = new Promise((resolve) => + stream.once("close", () => { + trace.push(["close", null]); + resolve(); + }), + ); + stream.resume(); + if (scenario === "write-destroy") { + stream.write("payload", callback("write")); + stream.destroy(failure); + } else if (scenario === "flush-destroy") { + stream.write("payload", callback("write")); + stream.flush(callback("flush")); + stream.destroy(failure); + } else { + stream.end("payload", callback("end-callback")); + stream.write("late", callback("late-write")); + } + await closed; + await new Promise((resolve) => setTimeout(resolve, 0)); + out.push({ + Constructor, + scenario, + trace, + destroyed: stream.destroyed, + closed: stream._closed, + }); + } + return out; + }, + + zlibFrameBoundaries: async function ({ zlib: z }) { + const out = {}; + for (const size of [64, 16384, 32769]) + for (const mode of ["valid", "truncated", "concat", "emptybetween"]) { + const plain = "a".repeat(size), + v = z.zstdCompressSync(plain), + input = + mode === "truncated" + ? v.subarray(0, v.length - 1) + : mode === "concat" + ? new Uint8Array([...v, ...v]) + : v; + for (const split of [1, 7, input.length]) { + const key = size + "/" + mode + "/" + split; + try { + const n = await new Promise((res, rej) => { + const s = new z.ZstdDecompress({ chunkSize: 64 }); + let len = 0; + s.on("data", (v) => (len += v.length)); + s.on("error", rej); + s.on("end", () => res(len)); + for (let i = 0; i < input.length; i += split) { + s.write(input.subarray(i, i + split)); + if (mode === "emptybetween") s.write(new Uint8Array(0)); + } + s.end(); + }); + out[key] = { len: n }; + } catch (e) { + out[key] = { + name: e.name, + message: e.message, + code: e.code ?? null, + errno: e.errno ?? null, + }; + } + } + } + return out; + }, + zlibDecodeStates: async function ({ zlib: z }) { + const out = {}; + for (const [decode, encode, ctor] of [ + ["inflate", "deflate", "Inflate"], + ["gunzip", "gzip", "Gunzip"], + ["inflateRaw", "deflateRaw", "InflateRaw"], + ["unzip", "gzip", "Unzip"], + ["brotliDecompress", "brotliCompress", "BrotliDecompress"], + ["zstdDecompress", "zstdCompress", "ZstdDecompress"], + ]) { + const valid = z[encode + "Sync"]("abcdef"); + const concat = (a, b) => new Uint8Array([...a, ...b]); + for (const [id, input] of [ + ["empty", new Uint8Array(0)], + ["garbage", new Uint8Array([97, 98, 99])], + ["valid", valid], + ["truncated", valid.subarray(0, valid.length - 1)], + ["half", valid.subarray(0, Math.floor(valid.length / 2))], + ["concat", concat(valid, valid)], + ["trailing", concat(valid, new Uint8Array([97]))], + ]) + for (const mode of [ + "sync", + "info", + "callback", + "callbackInfo", + "stream", + ]) { + try { + let v; + if (mode === "stream") + v = await new Promise((resolve, reject) => { + const s = new z[ctor](), + chunks = []; + s.on("data", (c) => chunks.push(...c)); + s.on("error", reject); + s.on("end", () => resolve(chunks)); + s.end(input); + }); + else if (mode.startsWith("callback")) + v = await new Promise((resolve, reject) => + z[decode](input, { info: mode === "callbackInfo" }, (e, v) => { + if (e) reject(e); + else if (mode === "callbackInfo") { + resolve(v.buffer); + v.engine.close(); + } else resolve(v); + }), + ); + else { + v = z[decode + "Sync"](input, { info: mode === "info" }); + if (mode === "info") { + const engine = v.engine; + v = v.buffer; + engine.close(); + } + } + out[decode + "/" + id + "/" + mode] = { + ok: true, + value: Array.from(v), + }; + } catch (e) { + out[decode + "/" + id + "/" + mode] = { + name: e.name, + message: e.message, + code: e.code ?? null, + errno: e.errno ?? null, + }; + } + } + } + return out; + }, + zlibCallbackOptions: async function ({ zlib: z }) { + const out = {}; + for (const method of [ + "deflate", + "inflate", + "gzip", + "gunzip", + "deflateRaw", + "inflateRaw", + "unzip", + "brotliCompress", + "brotliDecompress", + "zstdCompress", + "zstdDecompress", + ]) + for (const info of [false, true]) + for (const [id, options] of [ + ["default", {}], + ["dictionary", { dictionary: "a" }], + ["flush", { flush: Infinity }], + ["finishFlush", { finishFlush: -1 }], + ["chunkSize", { chunkSize: -1 }], + ["params", { params: { 0: 2147483648 } }], + ]) { + let stage = "call"; + let calls = 0; + const result = await new Promise((resolve) => { + try { + z[method]("abc", { ...options, info }, (error, value) => { + calls++; + if (error) + resolve({ + stage, + name: error.name, + message: error.message, + code: error.code ?? null, + }); + else { + resolve({ + stage, + ok: true, + length: (info ? value.buffer : value).length, + engine: info ? value.engine.constructor.name : null, + }); + if (info) value.engine.close(); + } + }); + stage = "returned"; + } catch (e) { + resolve({ + stage, + name: e.name, + message: e.message, + code: e.code ?? null, + }); + } + }); + await Promise.resolve(); + out[method + "/" + info + "/" + id] = { ...result, calls }; + } + return out; + }, + zlibBufferLifetimes: function ({ zlib: z }) { + const out = {}; + for (const [encode, decode] of [ + ["deflateSync", "inflateSync"], + ["brotliCompressSync", "brotliDecompressSync"], + ["zstdCompressSync", "zstdDecompressSync"], + ]) + for (const form of ["buffer", "typed", "data"]) + for (const mode of [ + "mutate", + "detach", + "shrink", + "grow", + "dictionaryMutate", + ]) { + const buffer = new ArrayBuffer(3, { maxByteLength: 6 }); + const view = new Uint8Array(buffer); + view.set([97, 98, 99]); + const input = + form === "buffer" + ? buffer + : form === "data" + ? new DataView(buffer) + : view; + const dictionary = new Uint8Array([97, 98, 99]); + const options = { + dictionary, + get maxOutputLength() { + if (mode === "mutate") view[0] = 120; + if (mode === "detach") + structuredClone(buffer, { transfer: [buffer] }); + if (mode === "shrink") buffer.resize(1); + if (mode === "grow") buffer.resize(6); + if (mode === "dictionaryMutate") dictionary[0] = 120; + return 1000; + }, + }; + try { + const compressed = z[encode](input, options); + out[encode + "/" + form + "/" + mode] = Array.from( + z[decode](compressed, { dictionary }), + ); + } catch (error) { + out[encode + "/" + form + "/" + mode] = { + name: error.name, + message: error.message, + code: error.code ?? null, + }; + } + } + return out; + }, + zlibFastOptions: function ({ zlib: z }) { + const out = {}; + const attempt = (id, fn) => { + try { + const v = fn(); + out[id] = { ok: true, type: typeof v, length: v?.length }; + } catch (e) { + out[id] = { name: e.name, message: e.message, code: e.code ?? null }; + } + }; + for (const method of [ + "deflateSync", + "inflateSync", + "gzipSync", + "gunzipSync", + "deflateRawSync", + "inflateRawSync", + "unzipSync", + "brotliCompressSync", + "brotliDecompressSync", + "zstdCompressSync", + "zstdDecompressSync", + ]) { + for (const [id, value] of [ + ["string", "a"], + ["buffer", new Uint8Array([1])], + ["number", 1], + ["null", null], + ]) + attempt(method + "/dictionary/" + id, () => + z[method]("abc", { dictionary: value }), + ); + for (const key of [ + "flush", + "finishFlush", + "chunkSize", + "windowBits", + "level", + "memLevel", + "strategy", + "maxOutputLength", + ]) + for (const value of [-1, 0, 1.5, Infinity, "1", null]) + attempt(method + "/" + key + "/" + String(value), () => + z[method]("abc", { [key]: value }), + ); + } + return out; + }, + zlibNumericOptions: function ({ zlib: z }) { + const out = {}; + const attempt = (id, fn) => { + try { + const v = fn(); + out[id] = { ok: true, type: typeof v, length: v?.length }; + } catch (e) { + out[id] = { name: e.name, message: e.message, code: e.code ?? null }; + } + }; + for (const method of [ + "deflateSync", + "inflateSync", + "gzipSync", + "gunzipSync", + "deflateRawSync", + "inflateRawSync", + "unzipSync", + "brotliCompressSync", + "brotliDecompressSync", + "zstdCompressSync", + "zstdDecompressSync", + ]) { + for (const [id, value] of [ + ["string", "a"], + ["buffer", new Uint8Array([1])], + ["number", 1], + ["null", null], + ]) + attempt(method + "/dictionary/" + id, () => + z[method]("abc", { dictionary: value }), + ); + for (const key of [ + "flush", + "finishFlush", + "chunkSize", + "windowBits", + "level", + "memLevel", + "strategy", + "maxOutputLength", + ]) + for (const value of [ + -0.5, + NaN, + -Infinity, + 4294967295, + 4294967296, + 2147483648, + BigInt(1), + ]) + attempt(method + "/" + key + "/" + String(value), () => + z[method]("abc", { [key]: value }), + ); + } + return out; + }, + zlibOptionOrder: function ({ zlib: z }) { + const out = {}; + for (const method of [ + "deflateSync", + "inflateSync", + "gzipSync", + "gunzipSync", + "deflateRawSync", + "inflateRawSync", + "unzipSync", + "brotliCompressSync", + "brotliDecompressSync", + "zstdCompressSync", + "zstdDecompressSync", + ]) + for (const mode of ["default", "params", "mutate", "dictionary"]) { + const log = []; + const input = new Uint8Array([97, 98, 99]); + const value = + mode === "params" + ? { + params: new Proxy( + {}, + { + ownKeys(t) { + log.push("params.ownKeys"); + return Reflect.ownKeys(t); + }, + get(t, p, r) { + log.push("params." + String(p)); + return Reflect.get(t, p, r); + }, + }, + ), + } + : mode === "dictionary" + ? { dictionary: new Uint8Array([97]) } + : {}; + const options = new Proxy(value, { + get(t, p, r) { + log.push(String(p)); + if (mode === "mutate" && p === "chunkSize") input[0] = 120; + return Reflect.get(t, p, r); + }, + }); + try { + const v = z[method](input, options); + out[method + "/" + mode] = { + value: Array.from( + { + deflateSync: "inflateSync", + gzipSync: "gunzipSync", + deflateRawSync: "inflateRawSync", + brotliCompressSync: "brotliDecompressSync", + zstdCompressSync: "zstdDecompressSync", + }[method] + ? z[ + { + deflateSync: "inflateSync", + gzipSync: "gunzipSync", + deflateRawSync: "inflateRawSync", + brotliCompressSync: "brotliDecompressSync", + zstdCompressSync: "zstdDecompressSync", + }[method] + ]( + v, + mode === "dictionary" + ? { dictionary: new Uint8Array([97]) } + : {}, + ) + : v, + ), + log, + }; + } catch (e) { + out[method + "/" + mode] = { + name: e.name, + message: e.message, + code: e.code ?? null, + log, + }; + } + } + return out; + }, + httpUniqueHeaders: async function ({ http: h, cloudflareNode: n }) { + const result = {}; + for (const uniqueHeaders of [ + undefined, + [], + ["X-List", ["Set-Cookie"]], + "x-list", + [1], + ]) { + let server; + const id = JSON.stringify(uniqueHeaders) ?? "undefined"; + try { + server = h.createServer({ uniqueHeaders }, (_req, res) => { + res.setHeader("x-list", ["a", "b"]); + res.setHeader("cookie", ["a=1", "b=2"]); + res.setHeader("set-cookie", ["a=1", "b=2"]); + res.end("body"); + }); + const response = await n + .httpServerHandler(server) + .fetch(new Request("https://test/")); + result[id] = { + list: response.headers.get("x-list"), + cookie: response.headers.get("cookie"), + cookies: response.headers.getSetCookie(), + body: await response.text(), + }; + } catch (error) { + result[id] = { + name: error.name, + code: error.code ?? null, + // Native TypeError wording differs between JavaScriptCore and V8. + invalidHeaderMethod: error.message.includes("toLowerCase"), + }; + } finally { + server?.close(); + } + } + return result; + }, + httpOptionValidation: function ({ http: h }) { + const out = {}; + const attempt = (id, fn) => { + try { + const v = fn(); + out[id] = { + ok: true, + requestTimeout: v.requestTimeout, + headersTimeout: v.headersTimeout, + highWaterMark: v.highWaterMark, + maxTotalSockets: v.maxTotalSockets, + scheduling: v.scheduling, + }; + } catch (e) { + out[id] = { name: e.name, message: e.message, code: e.code ?? null }; + } + }; + const values = [ + undefined, + null, + false, + true, + -1, + 0, + 1, + 1.5, + NaN, + Infinity, + Number.MAX_SAFE_INTEGER + 1, + "1", + {}, + ]; + for (const field of [ + "maxHeaderSize", + "insecureHTTPParser", + "requestTimeout", + "headersTimeout", + "keepAliveTimeout", + "keepAliveTimeoutBuffer", + "connectionsCheckingInterval", + "requireHostHeader", + "joinDuplicateHeaders", + "rejectNonStandardBodyWrites", + "highWaterMark", + ]) + for (let i = 0; i < values.length; i++) + attempt(`server/${field}/${i}`, () => + h.createServer({ [field]: values[i] }), + ); + for (const field of [ + "scheduling", + "maxTotalSockets", + "maxSockets", + "maxFreeSockets", + "keepAlive", + "keepAliveMsecs", + ]) + for (let i = 0; i < values.length; i++) + attempt( + `agent/${field}/${i}`, + () => new h.Agent({ [field]: values[i] }), + ); + for (const kind of ["server", "agent"]) + for (const mode of ["defaults", "all", "invalid"]) { + const log = []; + const options = new Proxy( + mode === "defaults" + ? {} + : { + requestTimeout: 1000, + headersTimeout: mode === "invalid" ? 2000 : 500, + joinDuplicateHeaders: false, + maxTotalSockets: 2, + }, + { + get(t, p, r) { + if (typeof p === "string") log.push(p); + return Reflect.get(t, p, r); + }, + ownKeys(t) { + log.push("ownKeys"); + return Reflect.ownKeys(t); + }, + }, + ); + attempt(`getter/${kind}/${mode}`, () => + kind === "server" ? h.createServer(options) : new h.Agent(options), + ); + out[`getter/${kind}/${mode}`].log = log; + } + return out; + }, + cryptoKdfInputLifetime: async function ({ crypto: c }) { + const result = {}; + const run = (method, callback, key, salt, info) => { + const args = + method === "hkdf" + ? ["sha256", key, salt, info, 8] + : method === "scrypt" + ? [key, salt, 8, { N: 16, r: 1, p: 1 }] + : [key, salt, 2, 8, "sha256"]; + return callback + ? new Promise((resolve, reject) => + c[method](...args, (error, value) => + error ? reject(error) : resolve(value), + ), + ) + : c[method + "Sync"](...args); + }; + const serialize = (value) => + Array.from(value instanceof ArrayBuffer ? new Uint8Array(value) : value); + for (const method of ["hkdf", "scrypt", "pbkdf2"]) + for (const callback of [false, true]) + for (const field of method === "hkdf" + ? ["key", "salt", "info"] + : ["key", "salt"]) + for (const mode of ["detachedBuffer", "detachedView"]) { + const values = { + key: new Uint8Array([1]), + salt: new Uint8Array([2]), + info: new Uint8Array([3]), + }; + const buffer = values[field].buffer; + if (mode === "detachedBuffer") values[field] = buffer; + structuredClone(buffer, { transfer: [buffer] }); + try { + result[`${method}/${callback}/${field}/${mode}`] = serialize( + await run( + method, + callback, + values.key, + values.salt, + values.info, + ), + ); + } catch (error) { + result[`${method}/${callback}/${field}/${mode}`] = { + name: error.name, + code: error.code ?? null, + message: error.message, + }; + } + } + for (const field of ["key", "salt"]) + for (const mode of ["mutate", "detach"]) + for (const callback of [false, true]) { + const values = { + key: new Uint8Array([1]), + salt: new Uint8Array([2]), + info: new Uint8Array([3]), + }; + let reads = 0; + Object.defineProperty(values.info, "byteLength", { + get() { + reads++; + if (mode === "mutate") values[field][0] = 9; + else + structuredClone(values[field].buffer, { + transfer: [values[field].buffer], + }); + return 1; + }, + }); + try { + result[`hkdfGetter/${field}/${mode}/${callback}`] = { + bytes: serialize( + await run( + "hkdf", + callback, + values.key, + values.salt, + values.info, + ), + ), + reads, + }; + } catch (error) { + result[`hkdfGetter/${field}/${mode}/${callback}`] = { + name: error.name, + code: error.code ?? null, + message: error.message, + reads, + }; + } + } + for (const digest of [ + "SHA256", + "DSA-SHA", + "sha256\0junk", + "sha3-256", + "bad\0x", + "bad\nx", + ]) { + try { + result["pbkdfDigest/" + digest] = serialize( + c.pbkdf2Sync("k", "s", 2, 8, digest), + ); + } catch (error) { + result["pbkdfDigest/" + digest] = { + name: error.name, + code: error.code ?? null, + message: error.message, + }; + } + } + return result; + }, + cryptoX509InputViews: function ({ crypto: c }) { + const pem = + "-----BEGIN CERTIFICATE-----\nMIID6DCCAtCgAwIBAgIUFH02wcL3Qgben6tfIibXitsApCYwDQYJKoZIhvcNAQEL\nBQAwejELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQswCQYDVQQHDAJTRjEPMA0G\nA1UECgwGSm95ZW50MRAwDgYDVQQLDAdOb2RlLmpzMQwwCgYDVQQDDANjYTExIDAe\nBgkqhkiG9w0BCQEWEXJ5QHRpbnljbG91ZHMub3JnMCAXDTIyMDkwMzIxNDAzN1oY\nDzIyOTYwNjE3MjE0MDM3WjB9MQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExCzAJ\nBgNVBAcMAlNGMQ8wDQYDVQQKDAZKb3llbnQxEDAOBgNVBAsMB05vZGUuanMxDzAN\nBgNVBAMMBmFnZW50MTEgMB4GCSqGSIb3DQEJARYRcnlAdGlueWNsb3Vkcy5vcmcw\nggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDUVjIK+yDTgnCT3CxChO0E\n37q9VuHdrlKeKLeQzUJW2yczSfNzX/0zfHpjY+zKWie39z3HCJqWxtiG2wxiOI8c\n3WqWOvzVmdWADlh6EfkIlg+E7VC6JaKDA+zabmhPvnuu3JzogBMnsWl68lCXzuPx\ndeQAmEwNtqjrh74DtM+Ud0ulb//Ixjxo1q3rYKu+aaexSramuee6qJta2rjrB4l8\nB/bU+j1mDf9XQQfSjo9jRnp4hiTFdBl2k+lZzqE2L/rhu6EMjA2IhAq/7xA2MbLo\n9cObVUin6lfoo5+JKRgT9Fp2xEgDOit+2EA/S6oUfPNeLSVUqmXOSWlXlwlb9Nxr\nAgMBAAGjYTBfMF0GCCsGAQUFBwEBBFEwTzAjBggrBgEFBQcwAYYXaHR0cDovL29j\nc3Aubm9kZWpzLm9yZy8wKAYIKwYBBQUHMAKGHGh0dHA6Ly9jYS5ub2RlanMub3Jn\nL2NhLmNlcnQwDQYJKoZIhvcNAQELBQADggEBAMM0mBBjLMt9pYXePtUeNO0VTw9y\nFWCM8nAcAO2kRNwkJwcsispNpkcsHZ5o8Xf5mpCotdvziEWG1hyxwU6nAWyNOLcN\nG0a0KUfbMO3B6ZYe1GwPDjXaQnv75SkAdxgX5zOzca3xnhITcjUUGjQ0fbDfwFV5\nix8mnzvfXjDONdEznVa7PFcN6QliFUMwR/h8pCRHtE5+a10OSPeJSrGG+FtrGnRW\nG1IJUv6oiGF/MvWCr84REVgc1j78xomGANJIu2hN7bnD1nEMON6em8IfnDOUtynV\n9wfWTqiQYD5Zifj6WcGa0aAHMuetyFG4lIfMAHmd3gaKpks7j9l26LwRPvI=\n-----END CERTIFICATE-----\n"; + const out = {}, + original = new c.X509Certificate(pem).raw; + for (const kind of ["typed", "data"]) + for (const property of [ + "buffer", + "byteOffset", + "byteLength", + "detached", + ]) { + const bytes = new Uint8Array(original.length + 2); + bytes.set(original, 1); + const view = + kind === "typed" + ? new Uint8Array(bytes.buffer, 1, original.length) + : new DataView(bytes.buffer, 1, original.length); + if (property === "detached") + structuredClone(bytes.buffer, { transfer: [bytes.buffer] }); + else + Object.defineProperty(view, property, { + get() { + throw Error("shadow " + property); + }, + }); + try { + const cert = new c.X509Certificate(view); + out[kind + "/" + property] = { + fingerprint: cert.fingerprint256, + subject: cert.subject, + }; + } catch (e) { + out[kind + "/" + property] = { + name: e.name, + // inspect embeds the host's intrinsic stack; retain its category and + // failed property while excluding engine-specific stack text. + message: + kind === "data" && property === "detached" + ? { + inspectionByteLengthFailure: String(e.message).includes( + "byteLength", + ), + } + : e.message, + code: e.code ?? null, + }; + } + } + return out; + }, + cryptoKdfInputViews: function ({ crypto: c }) { + const result = {}; + for (const operation of [ + "pbkdfPassword", + "pbkdfSalt", + "scryptPassword", + "scryptSalt", + "hkdfKey", + "hkdfSalt", + "hkdfInfo", + ]) + for (const type of ["buffer", "typed", "data"]) + for (const property of ["buffer", "byteOffset", "byteLength"]) { + const buffer = new Uint8Array([8, 1, 9]), + value = + type === "buffer" + ? buffer.buffer + : type === "typed" + ? new Uint8Array(buffer.buffer, 1, 1) + : new DataView(buffer.buffer, 1, 1); + Object.defineProperty(value, property, { + get() { + throw Error("shadow " + property); + }, + }); + try { + let out; + if (operation.startsWith("pbkdf")) + out = c.pbkdf2Sync( + operation === "pbkdfPassword" ? value : "p", + operation === "pbkdfSalt" ? value : "s", + 2, + 8, + "sha256", + ); + if (operation.startsWith("scrypt")) + out = c.scryptSync( + operation === "scryptPassword" ? value : "p", + operation === "scryptSalt" ? value : "s", + 8, + { N: 16, r: 1, p: 1 }, + ); + if (operation.startsWith("hkdf")) + out = c.hkdfSync( + "sha256", + operation === "hkdfKey" ? value : "p", + operation === "hkdfSalt" ? value : "s", + operation === "hkdfInfo" ? value : "i", + 8, + ); + result[`${operation}/${type}/${property}`] = Array.from( + out instanceof ArrayBuffer ? new Uint8Array(out) : out, + ); + } catch (e) { + result[`${operation}/${type}/${property}`] = { + name: e.name, + message: e.message, + code: e.code ?? null, + }; + } + } + for (const digest of ["SHA256", "DSA-SHA", "sha256\0junk", "sha3-256"]) + try { + result["hkdf/" + digest] = Array.from( + new Uint8Array(c.hkdfSync(digest, "k", "s", "i", 8)), + ); + } catch (e) { + result["hkdf/" + digest] = { + name: e.name, + message: e.message, + code: e.code ?? null, + }; + } + return result; + }, + cryptoKeyDetailsShape: async function ({ crypto: c }) { + const result = {}; + const record = (name, key) => { + const detail = key.asymmetricKeyDetails; + result[name] = Reflect.ownKeys(detail).map((name) => { + const d = Object.getOwnPropertyDescriptor(detail, name); + return { + name: String(name), + value: + d.value === undefined + ? "undefined" + : typeof d.value === "bigint" + ? d.value.toString() + : d.value, + enumerable: d.enumerable, + writable: d.writable, + configurable: d.configurable, + }; + }); + }; + for (const [algorithm, options] of [ + ["rsa", { modulusLength: 1024 }], + ["ec", { namedCurve: "prime256v1" }], + ["ed25519", {}], + ["x25519", {}], + ["dh", { group: "modp14" }], + ]) { + const pair = c.generateKeyPairSync(algorithm, options); + for (const kind of ["privateKey", "publicKey"]) + record(`${algorithm}/${kind}`, pair[kind]); + } + // Pinned Workers rejects DSA generation; import its public upstream fixture. + const dsa = c.createPrivateKey( + "-----BEGIN DSA PRIVATE KEY-----\nMIIB0gIBAAKBiQDp3xGIsNcKZWSpur2Ab2f5rirhu5AsYGLI/XZvzueoO4kLPp3s\nzKHXabBObdxU07kRFkfpV9lftdXT3a6E/Wxb+w2d7g0F7pWTdwpmmofcRtwgbynm\nJKvqWt/OwfVFsdUvvpVci9AxY/5rdM/K54Srp6iSpfteBYKGqCpPnswikND0GxKM\n1MbzAhUAqAPKCZ4hxromirUu33tn3Nhsq20CgYgf0tH2nwzWlPLoN4OJvC5dVLPv\nMRyJTQekW8tR0oY6PeshmZOaoI0L3e/M4KeZ/4qiY7IRNksP2YGJxjfS6W+h+MNj\nPO5r0aYYFYCPQ8JbhBO7l0hdXmkTY3FcdyRHeh2gwe3bBX6Auev2MGgL616Cgd/K\nH8PAX9EHmrNPcQOWHem4KcfJXqouAoGIcZ/4bJurOAw5OL4+5BsE4jfSVFq4nmNl\n+m6Iy6ls3hOHOZ9sJlw0Fi+ZtdeddOTYnIngW/kH09XGoF5JJOaLQv2O4GmZfDfn\nla+vfHZPsOxmYqeEbSwA2pqlhOn/dCshBOymhwNLXp1FJ3JUEyh8i605X06XmWoj\n4ZIu/tr1xbnZDLHuFjBmXAIURbB5/IDf2h0sW+qL5gHFO8bgr+E=\n-----END DSA PRIVATE KEY-----\n", + ); + record("dsa/privateKey", dsa); + record("dsa/publicKey", c.createPublicKey(dsa)); + for (const algorithm of [ + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 1024, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + { name: "ECDSA", namedCurve: "P-384" }, + { name: "ECDH", namedCurve: "P-256" }, + ]) { + const pair = await c.subtle.generateKey( + algorithm, + true, + algorithm.name === "ECDH" ? ["deriveBits"] : ["sign", "verify"], + ); + for (const kind of ["privateKey", "publicKey"]) + record(`host/${algorithm.name}/${kind}`, c.KeyObject.from(pair[kind])); + } + return result; + }, + cryptoExportSurface: function ({ crypto: c }) { + const descriptor = Object.getOwnPropertyDescriptor(c.default, "fips"); + let setter; + try { + c.default.fips = false; + setter = "success"; + } catch (error) { + setter = { + name: error.name, + code: error.code ?? null, + message: error.message, + }; + } + return { + named: Object.keys(c).sort(), + default: Object.keys(c.default).sort(), + fips: { + namedType: typeof c.fips, + getterType: typeof c.getFips, + descriptor: { + get: typeof descriptor?.get, + set: typeof descriptor?.set, + enumerable: descriptor?.enumerable, + configurable: descriptor?.configurable, + }, + internallyConsistent: + c.fips === c.getFips() && c.default.fips === c.getFips(), + setter, + }, + }; + }, + cryptoKeyInspection: function ({ crypto: c, util }) { + const pair = c.generateKeyPairSync("rsa", { modulusLength: 1024 }); + const result = {}; + const symbol = Symbol.for("nodejs.util.inspect.custom"); + for (const [name, key] of Object.entries({ + secret: c.createSecretKey("key"), + private: pair.privateKey, + public: pair.publicKey, + })) { + const prototypes = []; + let proto = Object.getPrototypeOf(key); + while (proto !== Object.prototype) { + prototypes.push( + Reflect.ownKeys(proto).map((name) => { + const d = Object.getOwnPropertyDescriptor(proto, name); + return { + name: String(name), + enumerable: d.enumerable, + configurable: d.configurable, + writable: d.writable, + valueType: typeof d.value, + length: + typeof d.value === "function" ? d.value.length : undefined, + get: typeof d.get, + set: typeof d.set, + }; + }), + ); + proto = Object.getPrototypeOf(proto); + } + const inspections = []; + for (const depth of [-1, 0, 1, 2]) { + const output = key[symbol](depth, { depth }); + inspections.push({ + direct: output === key ? "same-key" : output, + inspect: util.inspect(key, { depth }), + }); + } + result[name] = { prototypes, inspections }; + } + return result; + }, + cryptoSubtleDigestViews: async function ({ crypto: c }) { + const result = {}; + for (const algorithm of ["SHA-1", "SHA-256", "SHA-384", "SHA-512", "MD5"]) { + for (const mode of [ + "buffer", + "typed", + "data", + "shadow", + "detached", + "snapshot", + "algorithmGetter", + ]) { + const backing = new Uint8Array([8, 1, 9]); + let input = + mode === "buffer" + ? backing.buffer + : mode === "data" + ? new DataView(backing.buffer, 1, 1) + : new Uint8Array(backing.buffer, 1, 1); + const log = []; + if (mode === "shadow") + for (const field of ["buffer", "byteOffset", "byteLength"]) + Object.defineProperty(input, field, { + get() { + throw new Error("shadow " + field); + }, + }); + if (mode === "detached") + structuredClone(backing.buffer, { transfer: [backing.buffer] }); + const name = + mode === "algorithmGetter" + ? { + get name() { + log.push("name"); + structuredClone(backing.buffer, { + transfer: [backing.buffer], + }); + return { + toString() { + log.push("string"); + return algorithm; + }, + }; + }, + } + : algorithm; + try { + const pending = c.subtle.digest(name, input); + if (mode === "snapshot") backing.fill(7); + result[`${algorithm}/${mode}`] = { + bytes: Array.from(new Uint8Array(await pending)), + log, + }; + } catch (error) { + result[`${algorithm}/${mode}`] = { + name: error.name, + message: error.message, + log, + }; + } + } + } + return result; + }, + cryptoSubtleDigest: async function ({ crypto: c }) { + const subtle = c.subtle; + const result = { + surface: { + length: subtle.digest.length, + keys: Reflect.ownKeys(subtle.digest), + }, + }; + try { + const f = subtle.digest; + new f(); + result.constructor = "success"; + } catch (error) { + result.constructor = { name: error.name }; + } + const cases = [ + ["md5", "MD5"], + ["case", "md5"], + ["sha", "SHA-256"], + ["nul", "MD5\0junk"], + ["alias", "sha256"], + ["missing", "missing"], + ["undefined", undefined], + ["null", null], + ["number", 1], + ["symbol", Symbol("x")], + ["empty", {}], + ["nullName", { name: null }], + ["numberName", { name: 1 }], + ["undefinedName", { name: undefined }], + ["symbolName", { name: Symbol("x") }], + ["array", ["MD5"]], + ["stringObject", new String("MD5")], + ["dictionary", { name: "MD5" }], + ]; + for (const [name, algorithm] of cases) { + let stage = "call"; + try { + const p = subtle.digest(algorithm, new Uint8Array([1])); + stage = "await"; + result[name] = { + hex: Array.from(new Uint8Array(await p)), + promise: p instanceof Promise, + }; + } catch (e) { + result[name] = { + stage, + name: e.name, + message: e.message, + code: e.code ?? null, + }; + } + } + for (const [name, data] of [ + ["none", undefined], + ["null", null], + ["fake", {}], + ["view", new Uint8Array([1])], + ["buffer", new Uint8Array([1]).buffer], + ]) { + let stage = "call"; + try { + const p = subtle.digest("MD5", data); + stage = "await"; + result["data/" + name] = Array.from(new Uint8Array(await p)); + } catch (e) { + result["data/" + name] = { + stage, + name: e.name, + message: e.message, + code: e.code ?? null, + }; + } + } + let stage = "call"; + try { + const p = subtle.digest.call({}, "MD5", new Uint8Array()); + stage = "await"; + await p; + result.receiver = "success"; + } catch (e) { + result.receiver = { + stage, + name: e.name, + message: e.message, + code: e.code ?? null, + }; + } + return result; + }, + cryptoKeyExportRoundTrips: function ({ crypto: c }) { + const result = {}; + for (const algorithm of ["rsa", "ec", "ed25519", "x25519"]) + for (const encoding of algorithm === "ec" + ? ["default", "named", "explicit"] + : ["default"]) { + const pair = c.generateKeyPairSync( + algorithm, + algorithm === "rsa" + ? { modulusLength: 1024 } + : algorithm === "ec" + ? { + namedCurve: "prime256v1", + ...(encoding === "default" + ? {} + : { paramEncoding: encoding }), + } + : {}, + ); + for (const kind of ["private", "public"]) + for (const format of ["pem", "der"]) + for (const type of kind === "public" + ? algorithm === "rsa" + ? ["pkcs1", "spki"] + : ["spki"] + : algorithm === "rsa" + ? ["pkcs1", "pkcs8"] + : algorithm === "ec" + ? ["sec1", "pkcs8"] + : ["pkcs8"]) + for (const cipher of [undefined, "aes-256-cbc", "des-ede3-cbc"]) { + const name = `${algorithm}/${encoding}/${kind}/${format}/${type}/${cipher}`; + try { + const key = pair[kind + "Key"]; + const password = + cipher !== undefined && kind === "private" + ? { passphrase: "pass\0wordé" } + : {}; + const exported = key.export({ + format, + type, + cipher, + ...password, + }); + const imported = c[ + kind === "private" ? "createPrivateKey" : "createPublicKey" + ]({ key: exported, format, type, ...password }); + const raw = imported.export({ format: "der", type }); + const original = key.export({ format: "der", type }); + result[name] = { + ...(algorithm === "ec" + ? { + originalLength: original.length, + importedLength: raw.length, + } + : {}), + equal: imported.equals(key), + publicEqual: (kind === "private" + ? c.createPublicKey(imported) + : imported + ).equals(pair.publicKey), + }; + } catch (error) { + result[name] = { + name: error.name, + code: error.code ?? null, + message: error.message, + }; + } + } + } + return result; + }, + cryptoKeyExportDictionary: function ({ crypto: c }) { + const pair = c.generateKeyPairSync("rsa", { modulusLength: 1024 }), + result = {}; + for (const kind of ["public", "private"]) + for (const format of ["jwk", "pem"]) + for (const field of ["type", "format", "cipher", "passphrase"]) + for (const mode of ["trace", "null", "number", "object", "throw"]) { + const log = [], + options = {}; + for (const prop of ["type", "format", "cipher", "passphrase"]) + Object.defineProperty(options, prop, { + enumerable: true, + configurable: true, + get() { + log.push(prop); + if (prop === field) { + if (mode === "throw") throw Error("getter " + prop); + if (mode === "null") return null; + if (mode === "number") return 1; + if (mode === "object") + return { + toString() { + log.push("toString " + prop); + return prop === "type" + ? kind === "private" + ? "pkcs8" + : "spki" + : prop === "format" + ? format + : "aes-256-cbc"; + }, + }; + } + return prop === "type" + ? kind === "private" + ? "pkcs8" + : "spki" + : prop === "format" + ? format + : undefined; + }, + set(v) { + log.push("set " + prop); + Object.defineProperty(options, prop, { + value: v, + writable: true, + enumerable: true, + configurable: true, + }); + }, + }); + let output; + try { + const value = pair[kind + "Key"].export(options); + output = + typeof value === "string" ? "pem" : (value.kty ?? "buffer"); + } catch (e) { + output = { + name: e.name, + code: e.code ?? null, + message: e.message, + }; + } + result[`${kind}/${format}/${field}/${mode}`] = { log, output }; + } + return result; + }, + cryptoKeyExportFormats: function ({ crypto: c }) { + const result = {}; + const pairs = { + rsa: c.generateKeyPairSync("rsa", { modulusLength: 1024 }), + ec: c.generateKeyPairSync("ec", { namedCurve: "prime256v1" }), + ed25519: c.generateKeyPairSync("ed25519"), + }; + const run = (name, key, options) => { + try { + const v = key.export(options); + result[name] = + typeof v === "string" + ? "pem" + : v instanceof Uint8Array + ? "der" + : (v.kty ?? "object"); + } catch (e) { + result[name] = { + name: e.name, + message: e.message, + code: e.code ?? null, + }; + } + }; + for (const [algorithm, pair] of Object.entries(pairs)) + for (const kind of ["public", "private"]) { + const key = pair[kind + "Key"]; + for (const format of [undefined, "buffer", "pem", "der", "jwk", "wat"]) + for (const type of [ + undefined, + "pkcs1", + "pkcs8", + "sec1", + "spki", + "nope", + ]) + run(`${algorithm}/${kind}/${format}/${type}`, key, { + format, + type, + }); + for (const cipher of [ + "not-cipher", + "aes-256-cbc", + "aes-256-gcm", + "aes-256-ctr", + "chacha20-poly1305", + "AES-256-CBC", + "aes-256-cbc\0junk", + "des-ede3-cbc", + ]) + run(`${algorithm}/${kind}/cipher/${cipher}`, key, { + format: "pem", + type: kind === "private" ? "pkcs8" : "spki", + cipher, + passphrase: "password", + }); + } + return result; + }, + cryptoPrimeBrands: function ({ crypto: c }) { + const result = {}; + for (const operation of ["check", "add", "rem"]) { + for (const kind of [ + "fake", + "taggedBuffer", + "taggedView", + "taggedDataView", + ]) { + const bytes = new Uint8Array([ + operation === "check" ? 3 : operation === "add" ? 12 : 11, + ]); + const value = + kind === "fake" + ? { [Symbol.toStringTag]: "ArrayBuffer" } + : kind === "taggedBuffer" + ? bytes.buffer + : kind === "taggedDataView" + ? new DataView(bytes.buffer) + : bytes; + if (kind !== "fake") + Object.defineProperty(value, Symbol.toStringTag, { value: "Fake" }); + try { + const output = + operation === "check" + ? c.checkPrimeSync(value) + : c.generatePrimeSync(8, { + bigint: true, + add: operation === "add" ? value : 12n, + rem: operation === "rem" ? value : 11n, + }); + result[`${operation}/${kind}`] = + typeof output === "bigint" ? output % 12n === 11n : output; + } catch (error) { + result[`${operation}/${kind}`] = { + name: error.name, + code: error.code ?? null, + message: error.message, + }; + } + } + } + return result; + }, + cryptoCipherInputViews: function ({ crypto: c }) { + const result = {}; + for (const operation of [ + "secretKey", + "cipherKey", + "iv", + "aad", + "tag", + "primeAdd", + ]) { + for (const type of ["typed", "data"]) { + for (const property of ["buffer", "byteOffset", "byteLength"]) { + const view = (input) => { + const bytes = new Uint8Array(input.length + 2); + bytes.set(input, 1); + const value = + type === "typed" + ? new Uint8Array(bytes.buffer, 1, input.length) + : new DataView(bytes.buffer, 1, input.length); + Object.defineProperty(value, property, { + get() { + throw new Error("shadow " + property); + }, + }); + return value; + }; + const name = `${operation}/${type}/${property}`; + try { + let key = new Uint8Array(16), + iv = new Uint8Array(12); + if (operation === "secretKey") { + result[name] = Array.from(c.createSecretKey(view(key)).export()); + continue; + } + if (operation === "primeAdd") { + const prime = c.generatePrimeSync(8, { + add: view([12]), + rem: view([11]), + bigint: true, + }); + result[name] = prime % 12n === 11n; + continue; + } + if (operation === "cipherKey") key = view(key); + if (operation === "iv") iv = view(iv); + const enc = c.createCipheriv("aes-128-gcm", key, iv); + enc.setAAD(operation === "aad" ? view([3]) : new Uint8Array([3])); + const encrypted = enc.update("abc", "utf8"); + enc.final(); + const tag = enc.getAuthTag(); + const dec = c.createDecipheriv("aes-128-gcm", key, iv); + dec.setAAD(new Uint8Array([3])); + dec.setAuthTag(operation === "tag" ? view(tag) : tag); + result[name] = Array.from(dec.update(encrypted)); + dec.final(); + } catch (error) { + result[name] = { + name: error.name, + code: error.code ?? null, + message: error.message, + }; + } + } + } + } + return result; + }, + cryptoRsaDigestRegistry: function ({ crypto: c }) { + const pair = c.generateKeyPairSync("rsa", { modulusLength: 2048 }); + const result = {}; + for (const hash of [ + ...c.getHashes(), + "SHA256", + "sha256\0ignored", + "sha3-256", + "ripemd160", + "blake2b512", + "", + "missing", + ]) { + for (const padding of [4, 1, 999]) { + for (const operation of [ + "publicEncrypt", + "privateDecrypt", + "privateEncrypt", + "publicDecrypt", + ]) { + const options = { + key: operation.startsWith("private") + ? pair.privateKey + : pair.publicKey, + oaepHash: hash, + padding, + }; + const name = `${operation}/${padding}/${hash}`; + try { + const output = c[operation](options, new Uint8Array([1])); + result[name] = { length: output.length }; + } catch (error) { + result[name] = { + name: error.name, + code: error.code ?? null, + message: error.message, + }; + } + } + } + } + return result; + }, + cryptoInputViews: function ({ crypto: c }) { + const result = {}; + const pair = c.generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + for (const operation of [ + "hash", + "hmacKey", + "hmacUpdate", + "cipher", + "prime", + "sign", + "verifySignature", + ]) { + for (const type of ["typed", "data"]) { + for (const property of [ + "none", + "buffer", + "byteOffset", + "byteLength", + "detached", + ]) { + const original = + operation === "verifySignature" + ? c.createSign("sha256").update("message").sign(pair.privateKey) + : new Uint8Array([3]); + const backing = new Uint8Array(original.length + 2); + backing.set(original, 1); + const view = + type === "typed" + ? new Uint8Array(backing.buffer, 1, original.length) + : new DataView(backing.buffer, 1, original.length); + if (property === "detached") + structuredClone(backing.buffer, { transfer: [backing.buffer] }); + else if (property !== "none") + Object.defineProperty(view, property, { + get() { + throw new Error("shadow " + property); + }, + }); + const name = `${operation}/${type}/${property}`; + try { + let output; + if (operation === "hash") + output = c.createHash("sha256").update(view).digest("hex"); + if (operation === "hmacKey") + output = c.createHmac("sha256", view).update("a").digest("hex"); + if (operation === "hmacUpdate") + output = c.createHmac("sha256", "key").update(view).digest("hex"); + if (operation === "cipher") { + const cipher = c.createCipheriv( + "aes-128-ctr", + new Uint8Array(16), + new Uint8Array(16), + ); + output = Array.from(cipher.update(view)); + cipher.final(); + } + if (operation === "prime") output = c.checkPrimeSync(view); + if (operation === "sign") { + const signature = c + .createSign("sha256") + .update(view) + .sign(pair.privateKey); + output = c + .createVerify("sha256") + .update( + property === "detached" + ? new Uint8Array() + : new Uint8Array([3]), + ) + .verify(pair.publicKey, signature); + } + if (operation === "verifySignature") + output = c + .createVerify("sha256") + .update("message") + .verify(pair.publicKey, view); + result[name] = output; + } catch (error) { + result[name] = { + name: error.name, + code: error.code ?? null, + message: error.message, + }; + } + } + } + } + return result; + }, + codecNativeBounds: function ({ zlib: z }) { + const result = {}; + for (const factory of [ + "createDeflate", + "createBrotliCompress", + "createZstdCompress", + ]) { + for (const [ + name, + inputOffset, + inputLength, + outputOffset, + outputLength, + ] of [ + ["inputExtent", 0, 2, 0, 1], + ["inputOverflow", 4294967295, 1, 0, 1], + ["inputOffset", 2, 0, 0, 1], + ["outputExtent", 0, 1, 0, 2], + ["outputOverflow", 0, 1, 4294967295, 1], + ["outputOffset", 0, 1, 2, 0], + ["negative", 0, -1, 0, 1], + ["bigintInputOffset", 0n, 1, 0, 1], + ["bigintInputLength", 0, 1n, 0, 1], + ["bigintOutputOffset", 0, 1, 0n, 1], + ["bigintOutputLength", 0, 1, 0, 1n], + [ + "objectBigInt", + { + valueOf() { + return 0n; + }, + }, + 1, + 0, + 1, + ], + [ + "exoticBigInt", + { + [Symbol.toPrimitive]() { + return 0n; + }, + }, + 1, + 0, + 1, + ], + + ["huge", 0, 4294967296, 0, 1], + ["nan", 0, NaN, 0, 1], + ]) { + const engine = z[factory](); + engine.on("error", () => {}); + const input = new Uint8Array([65]), + output = new Uint8Array(1); + Object.defineProperty(input, "byteLength", { value: 32 }); + Object.defineProperty(output, "byteLength", { value: 32 }); + try { + engine._handle.writeSync( + 4, + input, + inputOffset, + inputLength, + output, + outputOffset, + outputLength, + ); + result[factory + "/" + name] = "accepted"; + } catch (e) { + result[factory + "/" + name] = { + name: e.name, + message: e.message, + code: e.code ?? null, + }; + } finally { + engine.destroy(); + } + } + } + return result; + }, + + codecInputForms: async function ({ zlib: z }) { + const result = {}; + for (const family of ["deflate", "brotliCompress", "zstdCompress"]) + for (const info of [false, true]) + for (const mode of ["sync", "callback"]) + for (const kind of [ + "array", + "typed", + "data", + "shadowBuffer", + "shadowLength", + "length0", + "length2", + "lengthNegative", + "lengthFraction", + "lengthHuge", + "lengthNaN", + "detached", + "null", + "number", + "fake", + ]) { + let buffer = new ArrayBuffer(3); + new Uint8Array(buffer).set([8, 1, 9]); + let input = + kind === "array" || kind === "detached" + ? buffer + : kind === "data" + ? new DataView(buffer, 1, 1) + : kind === "null" + ? null + : kind === "number" + ? 1 + : kind === "fake" + ? { [Symbol.toStringTag]: "ArrayBuffer" } + : new Uint8Array(buffer, 1, 1); + if (kind === "shadowBuffer") + Object.defineProperty(input, "buffer", { + value: new Uint8Array([7]).buffer, + }); + if (kind === "shadowLength") + Object.defineProperty(input, "byteLength", { + get() { + throw new Error("shadow length"); + }, + }); + if (kind.startsWith("length")) + Object.defineProperty(input, "byteLength", { + value: { + length0: 0, + length2: 2, + lengthNegative: -1, + lengthFraction: 1.5, + lengthHuge: 4294967296, + lengthNaN: NaN, + }[kind], + }); + if (kind === "detached") + structuredClone(buffer, { transfer: [buffer] }); + const key = `${family}/${info}/${mode}/${kind}`; + const error = (e) => ({ + name: e.name, + code: e.code ?? null, + ...(info && kind === "null" && e.name === "TypeError" && !e.code + ? {} + : { message: e.message }), + }); + const decode = (value) => { + const output = info ? value.buffer : value; + return Array.from( + z[ + family === "deflate" + ? "inflateSync" + : family === "brotliCompress" + ? "brotliDecompressSync" + : "zstdDecompressSync" + ](output), + ); + }; + try { + if (mode === "sync") + result[key] = { + stage: "return", + value: decode(z[family + "Sync"](input, { info })), + }; + else + result[key] = await new Promise((resolve, reject) => { + let returned = false; + z[family](input, { info }, (e, v) => { + try { + resolve({ + stage: "callback", + later: returned, + ...(e ? { error: error(e) } : { value: decode(v) }), + }); + } catch (failure) { + reject(failure); + } + }); + returned = true; + }); + } catch (e) { + result[key] = { stage: "throw", error: error(e) }; + } + } + for (const name of [ + "crc32", + "deflateSync", + "brotliCompressSync", + "zstdCompressSync", + ]) + for (const input of [ + null, + undefined, + 1, + true, + { [Symbol.toStringTag]: "ArrayBuffer" }, + ]) { + try { + result[name + "/invalid/" + String(input)] = { + value: z[name](input), + }; + } catch (e) { + result[name + "/invalid/" + String(input)] = { + name: e.name, + message: e.message, + code: e.code ?? null, + }; + } + } + return result; + }, + + codecInputViews: async function ({ zlib: z }) { + const result = {}, + attempt = async (name, fn) => { + try { + result[name] = await fn(); + } catch (e) { + result[name] = { + name: e.name, + ...(name.startsWith("detached") && + name.includes("/stream/") && + e.name === "TypeError" + ? {} + : { message: e.message }), + code: e.code ?? null, + }; + } + }; + for (const family of ["deflate", "brotliCompress", "zstdCompress"]) + for (const kind of [ + "typed", + "data", + "detachedTyped", + "detachedData", + "detachedBuffer", + ]) + for (const shadow of ["none", "buffer", "byteOffset", "byteLength"]) + for (const mode of ["crc32", "sync", "callback", "stream"]) { + await attempt(`${kind}/${shadow}/${mode}/${family}`, async () => { + let buffer = new ArrayBuffer(3); + new Uint8Array(buffer).set([8, 1, 9]); + let value = + kind.includes("Data") || kind === "data" + ? new DataView(buffer, 1, 1) + : kind === "detachedBuffer" + ? buffer + : new Uint8Array(buffer, 1, 1); + if (shadow !== "none") + Object.defineProperty(value, shadow, { + get() { + throw new Error("shadow " + shadow); + }, + }); + if (kind.startsWith("detached")) + structuredClone(buffer, { transfer: [buffer] }); + if (mode === "crc32") return z.crc32(value); + if (mode === "sync") + return Array.from( + z[ + family === "deflate" + ? "inflateSync" + : family === "brotliCompress" + ? "brotliDecompressSync" + : "zstdDecompressSync" + ](z[family + "Sync"](value)), + ); + if (mode === "callback") { + const compressed = await new Promise((resolve, reject) => + z[family](value, (e, r) => (e ? reject(e) : resolve(r))), + ); + return Array.from( + z[ + family === "deflate" + ? "inflateSync" + : family === "brotliCompress" + ? "brotliDecompressSync" + : "zstdDecompressSync" + ](compressed), + ); + } + const stream = + z["create" + family[0].toUpperCase() + family.slice(1)](), + chunks = []; + try { + await new Promise((resolve, reject) => { + stream.on("data", (chunk) => chunks.push(chunk)); + stream.on("end", resolve); + stream.on("error", reject); + stream.end(value); + }); + } finally { + stream.destroy(); + } + const total = chunks.reduce((n, c) => n + c.length, 0), + data = new Uint8Array(total); + let at = 0; + for (const c of chunks) { + data.set(c, at); + at += c.length; + } + return Array.from( + z[ + family === "deflate" + ? "inflateSync" + : family === "brotliCompress" + ? "brotliDecompressSync" + : "zstdDecompressSync" + ](data), + ); + }); + } + return result; + }, + fsInputViews: async function ({ fs }) { + const r = {}; + for (const kind of ["typed", "data"]) + for (const property of ["buffer", "byteOffset", "byteLength"]) + for (const name of ["writeFileSync", "writeSync", "readSync"]) { + const buffer = new ArrayBuffer(3); + new Uint8Array(buffer).set([8, 1, 9]); + const view = + kind === "typed" + ? new Uint8Array(buffer, 1, 1) + : new DataView(buffer, 1, 1); + Object.defineProperty(view, property, { + get() { + throw new Error("shadow " + property); + }, + }); + fs.writeFileSync("/tmp/p", "a"); + const fd = fs.openSync("/tmp/p", "r+"); + try { + if (name === "writeFileSync") fs.writeFileSync("/tmp/p", view); + else fs[name](fd, view, 0, 1, 0); + r[`${kind}/${property}/${name}`] = { + file: Array.from(fs.readFileSync("/tmp/p")), + view: Array.from(new Uint8Array(buffer)), + }; + } catch (e) { + r[`${kind}/${property}/${name}`] = { + name: e.name, + message: e.message, + code: e.code ?? null, + }; + } finally { + fs.closeSync(fd); + } + } + return r; + }, + fsPromiseWatch: async function ({ fs }) { + const results = []; + for (const args of [ + [], + ["/tmp"], + [null], + [1], + ["/tmp", null], + ["/tmp", { signal: AbortSignal.abort() }], + ]) { + let stage = "call"; + try { + const iterator = fs.promises.watch(...args); + stage = "next"; + await iterator.next(); + stage = "return"; + results.push(await iterator.return()); + } catch (e) { + results.push({ + stage, + name: e.name, + message: e.message, + code: e.code ?? null, + }); + } + } + return results; + }, + tlsPrototypeContracts: function ({ tls: t }) { + const r = { prototype: Reflect.ownKeys(t.TLSSocket.prototype).map(String) }, + methods = { + renegotiate: [[], [{}], [null], [{}, 1], [{}, () => {}]], + exportKeyingMaterial: [ + [], + [0, "label"], + [1, "label"], + [-1, "label"], + [1, 2], + [1, "label", new Uint8Array(1)], + [1, "label", new ArrayBuffer(1)], + ], + setMaxSendFragment: [[], [0], [-1], [1.5], [2147483648], ["1"]], + setServername: [[], [null], [1], ["name"]], + setSession: [[], [null], [1], ["session"]], + disableRenegotiation: [[]], + }; + for (const name of [ + "getPeerCertificate", + "getCertificate", + "getPeerX509Certificate", + "getX509Certificate", + "setKeyCert", + "getCipher", + "getSharedSigalgs", + "getEphemeralKeyInfo", + "getFinished", + "getPeerFinished", + "getProtocol", + "getSession", + "getTLSTicket", + "isSessionReused", + "enableTrace", + ]) + methods[name] = [[]]; + for (const [name, cases] of Object.entries(methods)) { + r[name] = []; + for (const args of cases) { + const socket = new t.TLSSocket(); + socket.on("error", () => {}); + try { + const v = socket[name](...args); + r[name].push({ + value: v === undefined ? "undefined" : v, + servername: socket.servername ?? null, + }); + } catch (e) { + r[name].push({ + name: e.name, + message: e.message, + code: e.code ?? null, + }); + } finally { + socket.destroy(); + } + } + } + return r; + }, + + cryptoKeyInputOptions: async function ({ crypto }) { + const result = {}, + attempt = (name, fn) => { + try { + result[name] = fn(); + } catch (e) { + result[name] = { + name: e.name, + message: e.message, + code: e.code ?? null, + }; + } + }; + const privateKey = crypto.generateKeyPairSync("ec", { + namedCurve: "prime256v1", + }).privateKey; + const pem = privateKey.export({ type: "pkcs8", format: "pem" }), + jwk = privateKey.export({ format: "jwk" }); + for (const kind of ["private", "public"]) { + const method = + crypto[kind === "private" ? "createPrivateKey" : "createPublicKey"]; + const order = [], + options = {}; + for (const name of ["key", "encoding", "format", "type", "passphrase"]) + Object.defineProperty(options, name, { + get() { + order.push(name); + return name === "key" ? jwk : undefined; + }, + }); + attempt(kind + "/order", () => ({ type: method(options).type, order })); + for (const [name, value] of Object.entries({ + format: null, + type: null, + encoding: null, + passphrase: null, + })) + attempt( + kind + "/null/" + name, + () => method({ key: pem, [name]: value }).type, + ); + for (const [name, value] of Object.entries({ + number: 1, + boolean: true, + function: () => {}, + string: "", + symbol: Symbol("x"), + })) + attempt(kind + "/key/" + name, () => method({ key: value }).type); + const data = new TextEncoder().encode(pem), + shared = new ArrayBuffer(data.length); + new Uint8Array(shared).set(data); + attempt(kind + "/arrayBuffer", () => method(shared).asymmetricKeyType); + attempt( + kind + "/arrayBufferOptions", + () => method({ key: shared }).asymmetricKeyType, + ); + } + return result; + }, + + cryptoKeyDictionary: async function ({ crypto }) { + const result = {}, + attempt = (name, fn) => { + try { + result[name] = fn(); + } catch (e) { + result[name] = { + name: e.name, + message: e.message, + code: e.code ?? null, + }; + } + }; + const fields = [ + "kty", + "use", + "key_ops", + "alg", + "ext", + "crv", + "x", + "y", + "d", + "n", + "e", + "p", + "q", + "dp", + "dq", + "qi", + "oth", + "k", + "pub", + "priv", + ], + order = []; + const input = {}; + for (const name of fields) + Object.defineProperty(input, name, { + get() { + order.push(name); + return { kty: "RSA", n: "AQ", e: "AQAB" }[name]; + }, + }); + attempt("order", () => { + const key = crypto.createPublicKey({ key: input }); + return { order, key: key.export({ format: "jwk" }) }; + }); + let reads = 0; + attempt("changing", () => { + const key = crypto.createPublicKey({ + key: { + kty: new String("RSA"), + get n() { + return ++reads === 1 ? "AQ" : "@@"; + }, + e: "AQAB", + }, + }); + return { reads, key: key.export({ format: "jwk" }) }; + }); + attempt( + "unused", + () => + crypto.createPublicKey({ + key: { + kty: "RSA", + n: "AQ", + e: "AQAB", + get d() { + throw new Error("unused getter"); + }, + }, + }).type, + ); + for (const name of ["key_ops", "oth"]) + for (const [label, value] of Object.entries({ + null: null, + number: 2, + string: "", + set: new Set(), + arraylike: { length: 0 }, + empty: [], + mixed: [null, undefined, 1, {}, Symbol("x")], + })) + attempt( + name + "/" + label, + () => + crypto.createPublicKey({ + key: { kty: "RSA", n: "AQ", e: "AQAB", [name]: value }, + }).type, + ); + for (const name of ["kty", "n", "alg", "ext"]) + attempt( + "symbol/" + name, + () => + crypto.createPublicKey({ + key: { kty: "RSA", n: "AQ", e: "AQAB", [name]: Symbol("x") }, + }).type, + ); + const ec = { + crv: "P-256", + kty: "EC", + x: "X0mMYR_uleZSIPjNztIkAS3_ud5LhNpbiIFp6fNf2Gs", + y: "UbJuPy2Xi0lW7UYTBxPK3yGgDu9EAKYIecjkHX5s2lI", + }; + for (const d of ["", "AA", "AQ", "_w", "_".repeat(43), "A".repeat(86)]) + attempt( + "scalar/" + d, + () => + crypto + .createPrivateKey({ key: { ...ec, d } }) + .export({ format: "jwk" }).d, + ); + return result; + }, + + cryptoKeyParsing: async function ({ crypto, Buffer }) { + // Public test fixtures from pinned workerd crypto_keys-test.js (Apache-2.0). + const fixtures = { + "agent1-cert.pem": + "-----BEGIN CERTIFICATE-----\nMIID6DCCAtCgAwIBAgIUFH02wcL3Qgben6tfIibXitsApCYwDQYJKoZIhvcNAQEL\nBQAwejELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQswCQYDVQQHDAJTRjEPMA0G\nA1UECgwGSm95ZW50MRAwDgYDVQQLDAdOb2RlLmpzMQwwCgYDVQQDDANjYTExIDAe\nBgkqhkiG9w0BCQEWEXJ5QHRpbnljbG91ZHMub3JnMCAXDTIyMDkwMzIxNDAzN1oY\nDzIyOTYwNjE3MjE0MDM3WjB9MQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExCzAJ\nBgNVBAcMAlNGMQ8wDQYDVQQKDAZKb3llbnQxEDAOBgNVBAsMB05vZGUuanMxDzAN\nBgNVBAMMBmFnZW50MTEgMB4GCSqGSIb3DQEJARYRcnlAdGlueWNsb3Vkcy5vcmcw\nggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDUVjIK+yDTgnCT3CxChO0E\n37q9VuHdrlKeKLeQzUJW2yczSfNzX/0zfHpjY+zKWie39z3HCJqWxtiG2wxiOI8c\n3WqWOvzVmdWADlh6EfkIlg+E7VC6JaKDA+zabmhPvnuu3JzogBMnsWl68lCXzuPx\ndeQAmEwNtqjrh74DtM+Ud0ulb//Ixjxo1q3rYKu+aaexSramuee6qJta2rjrB4l8\nB/bU+j1mDf9XQQfSjo9jRnp4hiTFdBl2k+lZzqE2L/rhu6EMjA2IhAq/7xA2MbLo\n9cObVUin6lfoo5+JKRgT9Fp2xEgDOit+2EA/S6oUfPNeLSVUqmXOSWlXlwlb9Nxr\nAgMBAAGjYTBfMF0GCCsGAQUFBwEBBFEwTzAjBggrBgEFBQcwAYYXaHR0cDovL29j\nc3Aubm9kZWpzLm9yZy8wKAYIKwYBBQUHMAKGHGh0dHA6Ly9jYS5ub2RlanMub3Jn\nL2NhLmNlcnQwDQYJKoZIhvcNAQELBQADggEBAMM0mBBjLMt9pYXePtUeNO0VTw9y\nFWCM8nAcAO2kRNwkJwcsispNpkcsHZ5o8Xf5mpCotdvziEWG1hyxwU6nAWyNOLcN\nG0a0KUfbMO3B6ZYe1GwPDjXaQnv75SkAdxgX5zOzca3xnhITcjUUGjQ0fbDfwFV5\nix8mnzvfXjDONdEznVa7PFcN6QliFUMwR/h8pCRHtE5+a10OSPeJSrGG+FtrGnRW\nG1IJUv6oiGF/MvWCr84REVgc1j78xomGANJIu2hN7bnD1nEMON6em8IfnDOUtynV\n9wfWTqiQYD5Zifj6WcGa0aAHMuetyFG4lIfMAHmd3gaKpks7j9l26LwRPvI=\n-----END CERTIFICATE-----\n", + "dh_private.pem": + "-----BEGIN PRIVATE KEY-----\nMIIBPgIBADCCARcGCSqGSIb3DQEDATCCAQgCggEBAP//////////rfhUWKK7Spqv\n3FYgJz088di5xYPOLTaVqeE2QRRkM/vMk53OJJs++X0v42NjDHXY9oGyAq7EYXrT\n3x7V1f1lYSQz9R9fBm7QhWNlVT3tGvO1VxNef1fJNZhPDHDg5ot34qaJ2vPv6HId\n8VihNq3nNTCsyk9IOnl6vAqxgrMk+2HRCKlLssjj+7lq2rdg1/RoHU9Co945TfSu\nVu3nY3K7GQsHp8juCm1wngL84c334uzANATNKDQvYZFy/pzphYP/jk8SMu7ygYPD\n/jsbTG+tczu1/LwuwiAFxY7xg30Wg7LG80omwbLv+ohrQjhhKFyX//////////8C\nAQIEHgIcKNGyhQRxIhVXoyktdymwbN6MgXv85vPax+8eqQ==\n-----END PRIVATE KEY-----\n", + "dh_public.pem": + "-----BEGIN PUBLIC KEY-----\nMIICJTCCARcGCSqGSIb3DQEDATCCAQgCggEBAP//////////rfhUWKK7Spqv3FYg\nJz088di5xYPOLTaVqeE2QRRkM/vMk53OJJs++X0v42NjDHXY9oGyAq7EYXrT3x7V\n1f1lYSQz9R9fBm7QhWNlVT3tGvO1VxNef1fJNZhPDHDg5ot34qaJ2vPv6HId8Vih\nNq3nNTCsyk9IOnl6vAqxgrMk+2HRCKlLssjj+7lq2rdg1/RoHU9Co945TfSuVu3n\nY3K7GQsHp8juCm1wngL84c334uzANATNKDQvYZFy/pzphYP/jk8SMu7ygYPD/jsb\nTG+tczu1/LwuwiAFxY7xg30Wg7LG80omwbLv+ohrQjhhKFyX//////////8CAQID\nggEGAAKCAQEA2whDVdYtNbr/isSFdw7rOSdbmcWrxiX6ppqDZ6yp8XjUj3/CEf/P\n60X7HndX+nXD7YaPtVZxktkIpArI7C+AH7fZxBduuv2eLnvYwK82jFHKe7zvfdMr\n26akMCV0kBA3ktgcftHlqYsIj52BaJlG37FRha3SDOL2yJOij3hNQhHCXTWLg7tP\nGtXmD202OoZ6Ll+LxBzBCFnxVauiKnzBGeawy4gDycUEHmq5oDRR68I2gmxmsLg5\nMQVAP5ljp+FEu4+TZm6hR4wQ5PRjCQ+teq+VqMro7EbbvZpn+X9kAgKSl2WDu0fT\nFbUnBn3HPBmUa/Fv/ooXrlckTUDjLkbWZQ==\n-----END PUBLIC KEY-----\n", + "dsa_private.pem": + "-----BEGIN DSA PRIVATE KEY-----\nMIIDVwIBAAKCAQEAoSw3Ghf02sMSmd5k2rvSqf6eJPFO7fHDRyvDDbifjO6/BKSI\nkXM43qyCqddC04arKg7wc1QDEQ8gb13pCmnC0RBiljE6ke4yK46Q5JjiEKH9U1eC\nbtTrhcGrLDgwbqvRM06EN6IfAL3OBF6YzS9wn3/EfSwW2Z8gAIkjZrTjEUTV+/gE\nAdfEgd/WAZxcc9zYKOwPy0/LjjldQw5fsPlIEkS1yJFlWMokSsZVYlJLR06h1S4k\nQoE3BqELirH/FQfJ36RMRFsaKZ6nQYS66Qc8rybQw2VlOJsqiRoTSDwREPz6j9oL\nYh1Ee86j5Xt9jbiBrK33UbkTr/jBtO0J2PR0+wIhAIXIexS5LQJPSoi96k6OU4yr\nLLmAIY8gS9mdYTdbpwcdAoIBAQCCN3gTjFiPgBQ/bj/Edp9w90SA+dQ/VnnYDTMc\nz+Mi/8sgtlQ3O9CCFb0327YnOLwvxsmSadT9XrIq1/5jGD2VtjFDVlridjYASrje\nzR2kdr781G+bxtVNQuIOKZl9xqruCmHUSSRL/vuCR6pKsA81ZPfpdcLh3RYYxDIo\nTK6tVX4GrX5bcxGDIUCQiTaqKv9Nzpm1liBLRm6LHczBsFk2OVrRyMsT3gh0J6DS\nUw+dw/Vru1J6glkrr0CxBWoJ65btcqtFyQV/76btor9Qgc/z9suYBoJZ3Ua0yAfv\n3J2ErOs1CAbh4LWNULA9eJObY2R4sAV7Q8wOMT5jmjKo4unpAoIBAQCE1m+DUb9L\nT58u6XV/L1p6K9T2mc6jAmzD51fPiUwsRov9sDGJmSnQjQ5pt3hVp8inVfNkhqOI\n1rpdKmx5W00fPu6VCiPuximuHSNHzJpCAVUrIH8YasS+AurCOwGMdvODLF6dx7yR\nMdxbiszrBry8J0TdvqElHZ1YmQDwoHH7R4pUd31jsk4gnE6pkqLgWwVAy0LXXGsg\n2JfnDvdQY8fIHkuezLdhOyO9pRlXSYv4fLdMaSjHyEcwr2hnm5tm5RsBwM+u0sDc\nyBqUjwoN8NTuLLasfJzzmjeHWDcRGFbzKt/xlUkQ7pf+xdelnLOstuPDGglB1U85\nREhx4rQGKg7nAiA0FX4e4Ms3OXUnmtsTALk5YMiMF3jUp4pRDhHFKBgsYQ==\n-----END DSA PRIVATE KEY-----\n", + "dsa_private_1025.pem": + "-----BEGIN DSA PRIVATE KEY-----\nMIIB0gIBAAKBiQDp3xGIsNcKZWSpur2Ab2f5rirhu5AsYGLI/XZvzueoO4kLPp3s\nzKHXabBObdxU07kRFkfpV9lftdXT3a6E/Wxb+w2d7g0F7pWTdwpmmofcRtwgbynm\nJKvqWt/OwfVFsdUvvpVci9AxY/5rdM/K54Srp6iSpfteBYKGqCpPnswikND0GxKM\n1MbzAhUAqAPKCZ4hxromirUu33tn3Nhsq20CgYgf0tH2nwzWlPLoN4OJvC5dVLPv\nMRyJTQekW8tR0oY6PeshmZOaoI0L3e/M4KeZ/4qiY7IRNksP2YGJxjfS6W+h+MNj\nPO5r0aYYFYCPQ8JbhBO7l0hdXmkTY3FcdyRHeh2gwe3bBX6Auev2MGgL616Cgd/K\nH8PAX9EHmrNPcQOWHem4KcfJXqouAoGIcZ/4bJurOAw5OL4+5BsE4jfSVFq4nmNl\n+m6Iy6ls3hOHOZ9sJlw0Fi+ZtdeddOTYnIngW/kH09XGoF5JJOaLQv2O4GmZfDfn\nla+vfHZPsOxmYqeEbSwA2pqlhOn/dCshBOymhwNLXp1FJ3JUEyh8i605X06XmWoj\n4ZIu/tr1xbnZDLHuFjBmXAIURbB5/IDf2h0sW+qL5gHFO8bgr+E=\n-----END DSA PRIVATE KEY-----\n", + "dsa_private_encrypted.pem": + "-----BEGIN DSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-256-CBC,FABA263DD471F214EF3E02699B837C20\n\nTj2+4x9MEIaQGFQ4o7hk12MriVYyvLO5aCbqq7LG5uhVk546/+bJc6hewdSwb6oT\nMYPbuV+QTdtqshqFESA0McyGlj4w1tOg5TomP84NTKvwTO1EirVLMukfF3dqaguw\nC117AZJkGbqgbi6lZ2bG0Hta6HRbhI5+ODFtOp3rKQ2KwVmtL7zw6vt3PCISeMHN\nfLqikDc2+YoI9V1FJis9/FATyqV8yrJYYQQpP1RQN+gDY4SSs/eUr+Me7RNy6Lz7\noH0tDaPGbiafwrZe1okksjxT2JQz1Q3hciBPikgdQIoE2NWTUlOeRYX0T0N2n37S\n6Odbcr522e+2XjcLj34Ozthp+Q5mIDcLuakazxkXhq0RyhJ7vo+xA2YiP7Q3vH7g\noAnsJPFNVY6wJhprZi2VofKIUJUiajAXGDVX2yEIG/DOA9rnx0ZP+zopXMi4ptu0\nRzWyAL+P4jn0b8vgPf9CYJmn4VNfOcVmomZ1Bw6hzqTE2FnThJCXU3l2eaC/wcSR\nuMRp8c6IM8AR5DUzUBKIckkvXj1m5iSZoKuR8dB7s9BhrRtBAI7K3G254G06sByv\n0pnft8r+BkMqgdfG4rJQoQJw7tVYln+pL/gYPDuYsqyJ9kFuHDqtBvlozqXY5AL1\nXQaXoD6xMACEoJSIv5y+TzFzXwFQrDW+G1724YOSbiioUfGD0tRfjj2ei63PThQr\nZ50SryfKQQf4UgcJeokMhmRWT2vPXEFWEP1b2FMEQxBy6fyKcqwZBAbhqF6usGEB\nnwr/S1HXQAGEsWoc/Z4yynB7uhOwWu/Vpj+V6B98NmC7EUX15Why8zCsT9gzaIgC\nM6sZafHhcmjfwc+lL9xFlU/wnAOz0LeKZWry3D0sXZn1r2FRlOJdtLLx01Sve/MU\nZRsgEDTkzv8E9dDltMeq8HQDCgLT1USTMWcY1kMELBj7y7ZdCWjH1QhTq2KlId+o\n1X28zJOsOL/XRseUSlpjmSSLRw1QQEypNCY2+tcvViAvn3AifipBbdzUNhvygLhc\na2+5rYsd8BBEFnMJx7lDiyqXGnZkBbhbCSIudppNcjC+akFlFp6fBzkp4mKBuKpc\nhwBBdfqdEyzqu6SVHM8nGV/aDoRuu9shV6MX0y/KnIgLedudn8aN2eLgjR5k1+99\n-----END DSA PRIVATE KEY-----\n", + "dsa_private_encrypted_1025.pem": + "-----BEGIN ENCRYPTED PRIVATE KEY-----\nMIIBvTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQIqTW00yecdxMCAggA\nMAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBBKgO4UF0LfCkPyS+iCvSrtBIIB\nYD3W6FyEZ97/crnoyRqjPUtr2Mm4KJMtaB5ZiGFzZEzd6AH7N/dbtAAMIibtsjmd\nRYdIptpET6xTpUhM8TvpULyYaZnhZJKTpVUrTVdvFTS3DYDutu7aWRLTrle6LzcY\nXpIppeP8ZmYFdRBQxhF+KoDsP4O0QA+vWl2W2VmRfr+sK9R+qV89w0YMjEWHsYY+\nVZsDbJBGKkj9gzIvxIsRyack/+RsbiSDrh6WTw+D0jrX/IMbgPjvYfBFhpxGC7zR\nhDn9r3JaO2KdHh9kMtvQfshA1n636kb0X6ewY57BhEs3J4hpMg46c6YFry94to24\njxl5KutM0CFea7mYGtNf6WJXBsm7JSW03kjlqYoZGK43KNgZhzKAsXaNkoRkA5cw\nBzGfgmG6dHTpeAY9G4vM4inhCmGFA8Tx189g+xzRv16uFXRb8WFIllne1fEFaXRr\n1Rz2G6SPJkA3fsrl8zUIB0Y=\n-----END ENCRYPTED PRIVATE KEY-----\n", + "dsa_private_pkcs8.pem": + "-----BEGIN PRIVATE KEY-----\nMIICZQIBADCCAjoGByqGSM44BAEwggItAoIBAQChLDcaF/TawxKZ3mTau9Kp/p4k\n8U7t8cNHK8MNuJ+M7r8EpIiRczjerIKp10LThqsqDvBzVAMRDyBvXekKacLREGKW\nMTqR7jIrjpDkmOIQof1TV4Ju1OuFwassODBuq9EzToQ3oh8Avc4EXpjNL3Cff8R9\nLBbZnyAAiSNmtOMRRNX7+AQB18SB39YBnFxz3Ngo7A/LT8uOOV1DDl+w+UgSRLXI\nkWVYyiRKxlViUktHTqHVLiRCgTcGoQuKsf8VB8nfpExEWxopnqdBhLrpBzyvJtDD\nZWU4myqJGhNIPBEQ/PqP2gtiHUR7zqPle32NuIGsrfdRuROv+MG07QnY9HT7AiEA\nhch7FLktAk9KiL3qTo5TjKssuYAhjyBL2Z1hN1unBx0CggEBAII3eBOMWI+AFD9u\nP8R2n3D3RID51D9WedgNMxzP4yL/yyC2VDc70IIVvTfbtic4vC/GyZJp1P1esirX\n/mMYPZW2MUNWWuJ2NgBKuN7NHaR2vvzUb5vG1U1C4g4pmX3Gqu4KYdRJJEv++4JH\nqkqwDzVk9+l1wuHdFhjEMihMrq1VfgatfltzEYMhQJCJNqoq/03OmbWWIEtGbosd\nzMGwWTY5WtHIyxPeCHQnoNJTD53D9Wu7UnqCWSuvQLEFagnrlu1yq0XJBX/vpu2i\nv1CBz/P2y5gGglndRrTIB+/cnYSs6zUIBuHgtY1QsD14k5tjZHiwBXtDzA4xPmOa\nMqji6ekEIgIgNBV+HuDLNzl1J5rbEwC5OWDIjBd41KeKUQ4RxSgYLGE=\n-----END PRIVATE KEY-----\n", + "dsa_public.pem": + "-----BEGIN PUBLIC KEY-----\nMIIDSDCCAjoGByqGSM44BAEwggItAoIBAQChLDcaF/TawxKZ3mTau9Kp/p4k8U7t\n8cNHK8MNuJ+M7r8EpIiRczjerIKp10LThqsqDvBzVAMRDyBvXekKacLREGKWMTqR\n7jIrjpDkmOIQof1TV4Ju1OuFwassODBuq9EzToQ3oh8Avc4EXpjNL3Cff8R9LBbZ\nnyAAiSNmtOMRRNX7+AQB18SB39YBnFxz3Ngo7A/LT8uOOV1DDl+w+UgSRLXIkWVY\nyiRKxlViUktHTqHVLiRCgTcGoQuKsf8VB8nfpExEWxopnqdBhLrpBzyvJtDDZWU4\nmyqJGhNIPBEQ/PqP2gtiHUR7zqPle32NuIGsrfdRuROv+MG07QnY9HT7AiEAhch7\nFLktAk9KiL3qTo5TjKssuYAhjyBL2Z1hN1unBx0CggEBAII3eBOMWI+AFD9uP8R2\nn3D3RID51D9WedgNMxzP4yL/yyC2VDc70IIVvTfbtic4vC/GyZJp1P1esirX/mMY\nPZW2MUNWWuJ2NgBKuN7NHaR2vvzUb5vG1U1C4g4pmX3Gqu4KYdRJJEv++4JHqkqw\nDzVk9+l1wuHdFhjEMihMrq1VfgatfltzEYMhQJCJNqoq/03OmbWWIEtGbosdzMGw\nWTY5WtHIyxPeCHQnoNJTD53D9Wu7UnqCWSuvQLEFagnrlu1yq0XJBX/vpu2iv1CB\nz/P2y5gGglndRrTIB+/cnYSs6zUIBuHgtY1QsD14k5tjZHiwBXtDzA4xPmOaMqji\n6ekDggEGAAKCAQEAhNZvg1G/S0+fLul1fy9aeivU9pnOowJsw+dXz4lMLEaL/bAx\niZkp0I0Oabd4VafIp1XzZIajiNa6XSpseVtNHz7ulQoj7sYprh0jR8yaQgFVKyB/\nGGrEvgLqwjsBjHbzgyxence8kTHcW4rM6wa8vCdE3b6hJR2dWJkA8KBx+0eKVHd9\nY7JOIJxOqZKi4FsFQMtC11xrINiX5w73UGPHyB5Lnsy3YTsjvaUZV0mL+Hy3TGko\nx8hHMK9oZ5ubZuUbAcDPrtLA3MgalI8KDfDU7iy2rHyc85o3h1g3ERhW8yrf8ZVJ\nEO6X/sXXpZyzrLbjwxoJQdVPOURIceK0BioO5w==\n-----END PUBLIC KEY-----\n", + "dsa_public_1025.pem": + "-----BEGIN PUBLIC KEY-----\nMIIBzjCCATsGByqGSM44BAEwggEuAoGJAOnfEYiw1wplZKm6vYBvZ/muKuG7kCxg\nYsj9dm/O56g7iQs+nezModdpsE5t3FTTuREWR+lX2V+11dPdroT9bFv7DZ3uDQXu\nlZN3Cmaah9xG3CBvKeYkq+pa387B9UWx1S++lVyL0DFj/mt0z8rnhKunqJKl+14F\ngoaoKk+ezCKQ0PQbEozUxvMCFQCoA8oJniHGuiaKtS7fe2fc2GyrbQKBiB/S0faf\nDNaU8ug3g4m8Ll1Us+8xHIlNB6Rby1HShjo96yGZk5qgjQvd78zgp5n/iqJjshE2\nSw/ZgYnGN9Lpb6H4w2M87mvRphgVgI9DwluEE7uXSF1eaRNjcVx3JEd6HaDB7dsF\nfoC56/YwaAvrXoKB38ofw8Bf0Qeas09xA5Yd6bgpx8leqi4DgYwAAoGIcZ/4bJur\nOAw5OL4+5BsE4jfSVFq4nmNl+m6Iy6ls3hOHOZ9sJlw0Fi+ZtdeddOTYnIngW/kH\n09XGoF5JJOaLQv2O4GmZfDfnla+vfHZPsOxmYqeEbSwA2pqlhOn/dCshBOymhwNL\nXp1FJ3JUEyh8i605X06XmWoj4ZIu/tr1xbnZDLHuFjBmXA==\n-----END PUBLIC KEY-----\n", + "ec_p256_private.pem": + "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgDxBsPQPIgMuMyQbx\nzbb9toew6Ev6e9O6ZhpxLNgmAEqhRANCAARfSYxhH+6V5lIg+M3O0iQBLf+53kuE\n2luIgWnp81/Ya1Gybj8tl4tJVu1GEwcTyt8hoA7vRACmCHnI5B1+bNpS\n-----END PRIVATE KEY-----\n", + "ec_p256_public.pem": + "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEX0mMYR/uleZSIPjNztIkAS3/ud5L\nhNpbiIFp6fNf2GtRsm4/LZeLSVbtRhMHE8rfIaAO70QApgh5yOQdfmzaUg==\n-----END PUBLIC KEY-----\n", + "ec_p384_private.pem": + "-----BEGIN PRIVATE KEY-----\nMIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDB3B+4e4C1OUxGftkEI\nGb/SCulzUP/iE940CB6+B6WWO4LT76T8sMWiwOAGUsuZmyKhZANiAASE43efMYmC\n/7Tx90elDGBEkVnOUr4ZkMZrl/cqe8zfVy++MmayPhR46Ah3LesMCNV+J0eG15w0\nIYJ8uqasuMN6drU1LNbNYfW7+hR0woajldJpvHMPv7wlnGOlzyxH1yU=\n-----END PRIVATE KEY-----\n", + "ec_p384_public.pem": + "-----BEGIN PUBLIC KEY-----\nMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEhON3nzGJgv+08fdHpQxgRJFZzlK+GZDG\na5f3KnvM31cvvjJmsj4UeOgIdy3rDAjVfidHhtecNCGCfLqmrLjDena1NSzWzWH1\nu/oUdMKGo5XSabxzD7+8JZxjpc8sR9cl\n-----END PUBLIC KEY-----\n", + "ec_p521_private.pem": + "-----BEGIN PRIVATE KEY-----\nMIHuAgEAMBAGByqGSM49AgEGBSuBBAAjBIHWMIHTAgEBBEIAEghuafcab9jXW4gO\nQLeDaKOlHEiskQFjiL8klijk6i6DNOXcFfaJ9GW48kxpodw16ttAf9Z1WQstfzpK\nGUetHImhgYkDgYYABAGixYI8Gbc5zNze6rH2/OmsFV3unOnY1GDqG9RTfpJZXpL9\nChF1dG8HA4zxkM+X+jMSwm4THh0Wr1Euj9dK7E7QZwHd35XsQXgH13Hjc0QR9dvJ\nBWzlg+luNTY8CkaqiBdur5oFv/AjpXRimYxZDkhAEsTwXLwNohSUVMkN8IQtNI9D\naQ==\n-----END PRIVATE KEY-----\n", + "ec_p521_public.pem": + "-----BEGIN PUBLIC KEY-----\nMIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBosWCPBm3Oczc3uqx9vzprBVd7pzp\n2NRg6hvUU36SWV6S/QoRdXRvBwOM8ZDPl/ozEsJuEx4dFq9RLo/XSuxO0GcB3d+V\n7EF4B9dx43NEEfXbyQVs5YPpbjU2PApGqogXbq+aBb/wI6V0YpmMWQ5IQBLE8Fy8\nDaIUlFTJDfCELTSPQ2k=\n-----END PUBLIC KEY-----\n", + "ec_secp256k1_private.pem": + "-----BEGIN PRIVATE KEY-----\nMIGEAgEAMBAGByqGSM49AgEGBSuBBAAKBG0wawIBAQQgc34ocwTwpFa9NZZh3l88\nqXyrkoYSxvC0FEsU5v1v4IOhRANCAARw7OEVKlbGFqUJtY10/Yf/JSR0LzUL1PZ1\n4Ol/ErujAPgNwwGU5PSD6aTfn9NycnYB2hby9XwB2qF3+El+DV8q\n-----END PRIVATE KEY-----\n", + "ec_secp256k1_public.pem": + "-----BEGIN PUBLIC KEY-----\nMFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEcOzhFSpWxhalCbWNdP2H/yUkdC81C9T2\ndeDpfxK7owD4DcMBlOT0g+mk35/TcnJ2AdoW8vV8Adqhd/hJfg1fKg==\n-----END PUBLIC KEY-----\n", + "ed25519_private.pem": + "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIMFSujN0jIUIdzSvuxka0lfgVVkMdRTuaVvIYUHrvzXQ\n-----END PRIVATE KEY-----\n", + "ed25519_public.pem": + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAK1wIouqnuiA04b3WrMa+xKIKIpfHetNZRv3h9fBf768=\n-----END PUBLIC KEY-----\n", + "ed448_private.pem": + "-----BEGIN PRIVATE KEY-----\nMEcCAQAwBQYDK2VxBDsEOdOtCnu9bDdBqSHNNZ5xoDA5KdLBTUNPcKFaOADNX32s\ndfpo52pCtPqfku/l3/OfUHsF43EfZsaaWA==\n-----END PRIVATE KEY-----\n", + "ed448_public.pem": + "-----BEGIN PUBLIC KEY-----\nMEMwBQYDK2VxAzoAoX/ee5+jlcU53+BbGRsGIzly0V+SZtJ/oGXY0udf84q2hTW2\nRdstLktvwpkVJOoNb7oDgc2V5ZUA\n-----END PUBLIC KEY-----\n", + "rsa_private.pem": + "-----BEGIN RSA PRIVATE KEY-----\nMIIEpQIBAAKCAQEAt9xYiIonscC3vz/A2ceR7KhZZlDu/5bye53nCVTcKnWd2seY\n6UAdKersX6njr83Dd5OVe1BW/wJvp5EjWTAGYbFswlNmeD44edEGM939B6Lq+/8i\nBkrTi8mGN4YCytivE24YI0D4XZMPfkLSpab2y/Hy4DjQKBq1ThZ0UBnK+9IhX37J\nu/ZoGYSlTIGIhzyaiYBh7wrZBoPczIEu6et/kN2VnnbRUtkYTF97ggcv5h+hDpUQ\njQW0ZgOMcTc8n+RkGpIt0/iM/bTjI3Tz/gsFdi6hHcpZgbopPL630296iByyigQC\nPJVzdusFrQN5DeC+zT/nGypQkZanLb4ZspSx9QIDAQABAoIBAQCS2erYu8gyoGPi\n3E/zYgQ6ishFAZWzDWSFubwD5wSm4SSAzvViL/RbO6kqS25xR569DmLRiHzD17VI\nmJMsNECUnPrqR2TL256OJZaXrNHh3I1lUwVhEzjeKMsL4/ys+d70XPXoiocVblVs\nmoDXEIGEqa48ywPvVE3Fngeuxrsq3/GCVBNiwtt0YjAOZxmKEh31UZdHO+YI+wNF\n/Z8KQCPscN5HGlR0SIQOlqMANz49aKStrevdvjS1UcpabzDEkuK84g3saJhcpAhb\npGFmAf5GTjkkhE0rE1qDF15dSqrKGfCFtOjUeK17SIEN7E322ChmTReZ1hYGfoSV\ncdFntUINAoGBAPFKL5QeJ6wZu8R/ru11wTG6sQA0Jub2hGccPXpbnPrT+3CACOLI\nJTCLy/xTKW3dqRHj/wZEe+jUw88w7jwGb1BkWr4BI8tDvY9jQLP1jyuLWRfrxXbp\n4Z0oeBBwBeCI/ZG7FIvdDTqWxn1aj3Tmh6s4ByqEdtwrrrJPcBUNl01fAoGBAMMR\n3RGE/ca6X6xz6kgUD6TtHVhiiRJK1jm/u+q0n7i/MBkeDgTZkHYS7lPc0yIdtqaI\nPlz5yzwHnAvuMrv8LSdkjwioig2yQa3tAij8kXxqs7wN5418DMV2s1OJBrPthYPs\nbv4im2iI8V63JQS4ZMYQbckq8ABYccTpOnxXDy0rAoGBAKkvzHa+QjERhjB9GyoT\n1FhLQIsVBmYSWrp1+cGO9V6HPxoeHJzvm+wTSf/uS/FmaINL6+j4Ii4a6gWgmJts\nI6cqBtqNsAx5vjQJczf8KdxthBYa0sXTrsfktXNJKUXMqIgDtp9vazQ2vozs8AQX\nFPAAhD3SzgkJdCBBRSTt97ZfAoGAWAziKpxLKL7LnL4dzDcx8JIPIuwnTxh0plCD\ndCffyLaT8WJ9lXbXHFTjOvt8WfPrlDP/Ylxmfkw5BbGZOP1VLGjZn2DkH9aMiwNm\nbDXFPdG0G3hzQovx/9fajiRV4DWghLHeT9wzJfZabRRiI0VQR472300AVEeX4vgb\nrDBn600CgYEAk7czBCT9rHn/PNwCa17hlTy88C4vXkwbz83Oa+aX5L4e5gw5lhcR\n2ZuZHLb2r6oMt9rlD7EIDItSs+u21LOXWPTAlazdnpYUyw/CzogM/PN+qNwMRXn5\nuXFFhmlP2mVg2EdELTahXch8kWqHaCSX53yvqCtRKu/j76V31TfQZGM=\n-----END RSA PRIVATE KEY-----\n", + "rsa_private_2048.pem": + "-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEArk4OqxBqU5/k0FoUDU7CpZpjz6YJEXUpyqeJmFRVZPMUv/Rc\n7U4seLY+Qp6k26T/wlQ2WJWuyY+VJcbQNWLvjJWks5HWknwDuVs6sjuTM8CfHWn1\n960JkK5Ec2TjRhCQ1KJy+uc3GJLtWb4rWVgTbbaaC5fiR1/GeuJ8JH1Q50lB3mDs\nNGIk1U5jhNaYY82hYvlbErf6Ft5njHK0BOM5OTvQ6BBv7c363WNG7tYlNw1J40du\np9OQPo5JmXN/h+sRbdgG8iUxrkRibuGv7loh52QQgq2snznuRMdKidRfUZjCDGgw\nbgK23Q7n8VZ9Y10j8PIvPTLJ83PX4lOEA37JlwIDAQABAoIBACoL2Ev5lLyBaI+9\n+vJO2nNaL9OKSMu2SJODIJTnWwYUASBg0P3Jir6/r3sgi8IUJkH5UHbD/LrQcPkA\n4X7PU9vEyUsr1efWFIvk7t7JsjOctoVA5z2Mty74arivUIe5PUadvUC6/7Zk0u6A\nCjLuJRmlH7nGNKZk+xrvgWTH+fkgc5ddbFxoGH129RcVC+ePbsi1EF60C9KbJvp1\nxjUJ5cDtNYnZ/g+ULo6ZJjRG5kUCVSI8H/Nc/DmStKsjN0isKpNGofU5ArEwywGC\nCqxz/tr4hT2haAkVEto04ooYpqDUSqGEfPpLWL+CjFNPfCsWJ1tX5LQRvpu6eukd\nFO72oVECgYEA4+Ot7RQtGOtPeaxl3P7sbEzBZk0W/ZhCk+mzE2iYh0QXU44VtjtO\n/9CENajTklckiwlxjqBZ5NO1SiMQKBcmqkoA03x/DEujo2rMVuNPoc6ZYp1Gc4qA\n4ImkMQNsM7Swum42rKE960WoiWW7dsdEAq6vqgeApZlMU8lcKRAlOZkCgYEAw85H\n3bjF7gMatVibsWzj0zp2L4616m2v5Z3YkgohGRAvm1912DI5ku5Nmy9am57Z1EP2\nUtDOxahd/Vf6mK9lR4YEbNW1TenykViQJ6lmljOFUeZEZYYO3O+fthkyN/42l5yn\nMyUANTTb2rvt8amdRr0ARdRqWJmt5NfJzYBV+q8CgYB1ZjuZoQVCiybcRcYMPX/K\noxgW/avUZPYXgRNx8jZxqNBjiRUCVjdybhdOFXU5NI9s2SaZFV56Fd6VHM8b+CFB\nJPKcAMzqpqTccQ5nzJ6fevFl7iP3LekKw53EakD5uiI5SMH92OsvIymZ7sDOhgUx\nZJC2hTrvFLRPjbJerSSgMQKBgAv5iZuduT0dI30DtkHbjvNUF/ZAnA+CNcetJ5mG\n1Q9bVg4CgIqAR9UcjdJ3yurJhDjfDylxa7Pa4CSmRMUhtOfy4kJlr3jcXeFVsTs7\nuPJmpDimBHjRAgew/+t7Dv8tpNkQ04jlMmYOnYN7CspEvUGePW4H15kjjOb563WN\n67QxAoGAdhJPaHVtDBUrobn50ORVkVNJVZPBhEDbR6tNtHsgEevH7iYjxAdxEeUa\nc9S2iV9lir3hkrQceiYWAADcphWfO1HyP5Uld4sJzYiEGbSM7a0zRQjYlQgXFbZo\nSAc6Gok78kwECPwpmeH4lpGVeKNmzEteSBVYxGb9b6C/SSsu7l0=\n-----END RSA PRIVATE KEY-----\n", + "rsa_private_4096.pem": + "-----BEGIN RSA PRIVATE KEY-----\nMIIJKAIBAAKCAgEAxeStwofbjtZuol4lwKn1w08AzcSNLHfCqNFHa+W7er8is7LQ\nsPljtPT4yn4lsao83ngHFvSC3tbMiRNDpUHYqH2wBuUkuOmCtYkZLi0307H0CwcV\nV6W5P3tNEt80IJ+PqlRxtTknezUtbOasbIi/aornVWG+psgqDGrFZ4oTsWtiE0Sv\ni7sDqN5E2dijmH/YYnlnwqszgzHdtAnARp1bG34E64sqWCmLoGCfPdHtym/CSdxO\nLOsDV15jrwODZQ/TJZ5thkwKZRxu7g9fwlhA1PiI5WDP4reXNaqa2bSgrzpAljQE\nxYs4N0L7okSVOJQX9BEaoWtq8NLU8MpMdGoHNDU0Xr60Lfr58Z5qn8RGEvlTxoCb\nPJzPV2zgzD/lmEqft6NnfTclveA3sd8xSrOBUn4o3S8hS0b9Su7PBukHjM96/e0R\neoIshSwXlQTLr2Ft8KwupyPm1ltNcTDtjqHcIWU6Bg+kPy9mxSVtGGZYAPtqGzNB\nA/m+oOja/OSPxAblPdln691DaDuZs5nuZCGwGcLaJWgiyoqvXAcyXDZFyH4OZZh8\nrsBLKbnFXHZ/ziG0cAozEygZEPJappw8Lx/ady7WL/SJjxooiKapc7Bnfy8eSLV3\n+XAKxhLW/MQ6ChJ+e/8ExAY02ca4MpCvqwIk9TfV6FM8pWGqHzQFj0v3NL0CAwEA\nAQKCAgBTb8eTbZS09NRQwUFJql9kqbq9B1I+nYAFjbd/Vq1lY5FOEubKt1vCwEbl\nmapq7kwbwJ+8nftP2WEDqouq8chXwialwZdqH4ps4BEt1wLizvUGcUYeXlFs4p/s\nhQ+FccExH8mRjzeGSzWL5PZuDHoogchnx36K83pHIf15Wk5TT+NaHGunjoJMgOqm\nryDK+5xQaL/G5Egj2LKRZksbet0fClMovNRtt5aXWCXL+uc3o0dXvPt5FN2jyLhe\n4ixUQAfWpKWpKgZ3+zUKSpElb/Bl2yRdEiSUgrPOfNAtWmsldnok2mnooHpjUmqm\nUCRaZpZy4YNI6/F6+Gmv3Ju/ubSvHzoxQLlvgUqWAnVshivF1TJImHSIiLIvBKPp\n29SD6maWIT1DC9sKC4E1gq7VO4762l1//zEOAY7XK0Z7LrbZO4WXHnsgFOpGthQ3\ng9Qi/SeM6mb5xEJTBUBTmkhGs1x8jolzca30mqv8T63W4PXkXHmZdK7vyH5useiI\ns0eGUeaYK892WgfxCBo24JCNQiAcH/wTwV4l4yROqeH2V4ShbIYmCzla++7vsPYW\nhAwQR9eH0+4ogTkaMQrm16plZk0ezVX9BKK8KTnd4G9/T18VstQbiowF2/cKnGKC\nOqrmoR2vHOksQdUJVmnwCRqU1symBxhY0GSIps98v+lUYExKQQKCAQEA/uVYE2/H\neNcV/uWAI9LspANXHJE33TFMZ8SuyOYtp3MYJizmQ1uT7Om2LEanDnNiz+fAQhrE\nvo1sDIF9xOAde2qjIH+iDzcLvFPgC3gkQspFjU31M9OO5xAjzBxfL3KDiG2MtmTR\nhNuKJX56eCOqkEp6WKaWOA35ccaKYHxNzMS49weCv95ZPpR9q0J1sgzD7HtVh4yu\nXI01/BC8F0RmYjtsuUo+PmB6sO2K94uqqo0GPUos7Mhgrbff3L36EkOPgmRiA1AV\nZy1sKKxUKspGQ3m1fg+CA/+GZGckvYkVot1lFrwmrS2dok8EhT1HcVJde+++jx7z\nJsRLgFRvKHXklwKCAQEAxsAfxIQjjjKmuyJCzIvxG7lnuzovdy4OEdSuJL4yK5m3\n4BHJHn+yHeRIcrDnJKUTUYffcH/OjOnJS94BA6wH1tEuvGQz6LV6UpwApZ1M/2md\nnP0eC2L2JtSRL8mdxfyqXDloWMpD7rncBZ6ChLEZ6sWYa6WBQTARmPVePyUpNNG2\nqymxN3/vRBGGBunD3j6zX0M0szWK5iU+qsYDy3KzCKG8FU7XxwzRbP7iARRD5Hpt\nZmy2W52EJg1uhmlVXJMm32SEBfrD2oDmlnjAqaZdqi5Mq2e4uB3dhM9RwJppSALG\nBY6k9DeanAFbOlawMJri2pk7B0phCn+DN2pg0+W3ywKCAQBeTwzfZCQxmaMRxGg8\n2PWlWXcJotFAjdTvL95bho6tve/ZcBNiKKf6qB43E40L07VjpyODUdQpjLnFhsO5\n7BH8b+AbTh3v8zXsYDwtAi6oZ56EQavPmR7ubxJPms+9BmmUOLQvZ+39ch0S8lDt\n0oRxDp1l330FEGaSqhrYyCUg9khZXfYKd4IdnWNB0j0pu39iJ9/lXy/EHpsywB5X\nnX8kKUh45fdRrPC4NauNG6fxomwEkUU99oWOwNGbIs87orOeUvXQs/i3TB8QjXI2\nwtBsdsOn+KTqRci7rU3ysp3GvJOCbesBeDcyrnnFsn6Udx0Plgyzd4gPd+FXgeX+\n2l/RAoIBAH81FKAY2xD2RlTb1tlIcGeIQWZKFXs4VPUApP0LZt0VI+UcPRdyL7SG\nGgCeTTLdHQI/7rj4dGEoeRg/3XJWNyY8+KbHk5nMHaCmDJvzlAaduK10LDipfFba\nEpr9dif0Ua15aNn7i4NOHg7Sp0L6f1YOZkHvykzI0VqPIWVVCYyu9TWUF8Mn9SIh\n/SCLmjuy8ed1AlP5Xw9yoyt2VZNvtDtAGTuiHOVfxOL4N/rs149y9HZr+kOlC6G3\nUxhgbqwz2tt8YCvblmNRwURpwRZUTvrPa28Bke713oRUlUSrD9txOwDvjZBpzmEv\nVQ5/0YEqgSvcizVdW8L2XiunwJWfIAUCggEBALr4RF9TYa37CImZOs+vJ8FGRKMz\nh1EUwO2PvuITvkTtu/7E4IjyxAo5dkAokkWQCGABciiDJJEYUWqcUX45qQChOgtm\nNU2od6f9tgyDFxN5KS8cE32NXV3rJXs3bBZmIKLSPETf3uIPuEpFPjpdR5v5jlV+\nTDjH4RrItE3hDCvypTXhXXMmWp3VfYbgEfIP03uR2iIhL+/g3BUqbrywPEsTViSN\nNM/uBDQyamXLXB1bQ2I/Ob41I82PD1iNCqGi7ZvZ3eVYGgUTQyw6Q4O8glTPP9cC\nSFVXwE9gHbLe8TqfTZCWrM6crGX6Bb6hV2tqNsA+7J69U9NGuw5GNqXjafU=\n-----END RSA PRIVATE KEY-----\n", + "rsa_private_b.pem": + "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAyb1grrN+29fxeeEbTaSEja6TKDTpT/WXnqrFCS+h7IYcnDoA\nVwcsPU5FZeUPvLKMzi9NHSJ34LQCurqHgH8X+cw0YT3gdYS/7qoQiXs+zKv615Nc\nttD3xlQLceY+NwznoPXyyZwOeZqyU5Hiqbrqu6hdr6gQYogMNLn2NxBW2pGegd6+\nZGMCX3+/BtMP/6tXmttYjY+yhN2SrGz5cKhWpcHiC6X+B7uCKoKZy+t2jUxYVKUw\nWr1ZuM8kpSnuVCcv1OoMGEimEHA7v/eaF/y+z/VdQ4Y88GhTnVN4KbtgZ+o9Pohj\nxLFU62VeTALixU5mPQKSgSICKfjev0FUUurF6wIDAQABAoIBAQCs11C/PM/iYNfl\nmSSAWAStMrWni/Wc6QhXC2422ZV8hMZ8XwEtjtqrR6UTkLXz8HHMsR/7Zy2X2gJA\no1E2mS0ceoUiDxaA+RRL0W7Lq0j5qBsImZukkdLHG/iWRDJnjenhsParHsYUD6Lb\nELFGw/safjyOI4quMGtsvSqisKAJL5ZCPD5JHLhnNP8HXT6icSZrsqGhunb2tsa+\nOgcx1+bZzqdTsbvXdbw07Lnd/LRU0NDhjeEVl4J2yFNYY+OIj7/qrxSnZnGLLG0Y\nDFxiD+HCMvTBSooqvWI6FAipfyCGjUznGsVaRv7TuzHPuKE4LtbIC/Ac3Q10rKWq\nPmHALir5AoGBAOhGUCToWfYnj2zH0GIZQxnkrv9iRqmdGeCDX6ZM00Bs5tASnRo0\no90UtLbhWjHe1PKRKFyD4I7a8iIWxcWWun2XHgOtItctPN+lbjpTHTyE2yA1iZhe\ndKCV3bAo4t+puKrPkZmaBqFD/fQx7DNxYdRERa1giiZGhlMUN3l7/S21AoGBAN5Y\nnZ68NkTgklk4YBzsxwsMpQbgbihyG79gtDFxWonxZUQ29EsL01yd30pJNhg1LxDN\n0fADfHVzkZ3qYz9knge9a75Yk8UBM3DM+xu+DRkjKhK5mPX5oLvj6061u3Scs6tj\norpU/mV1amz5gqrkefMaelsdHRuGGZQVx9KTV2kfAoGBAN7EAL1E8nK4Qj/r6xkK\nbWZ6ArQABxFJELZYiPWvnLOfPka0c2PctIOmBiOXQa+urMDvIqyH9mhL6Al1mbwE\n8VreAfU4qb+BLW649FyPteyC5r2fWxV9EZGp6fG3ZM9psShw5o1QQaeM1BTNhGFa\nDp9L0x+TBSvsW4t2SjYDCjA5AoGASzxxGWVWd7gFzWrmGuOD9pkwvkLzA3yZJwjx\n8EkK+eJVAeAWic5WluBUzi43v7k/U9BRWYXUd2nDvEuziZ/iWXwfGSmf1umxHlo+\nHgURKZBcjDmBKLpvSSS2WsvjwnHD2hq81ZAtBOfWO0myjWECYuByxqHzV3zo6tLz\n6q0wxsECgYA26twPrAoRqvfvPnNj6o0LrsE39Tj6jHIVijT7Lbcf2xVnaDiQ18PQ\nRC6Tgkz5KZf8GKfMRMA3WopGn9QE2luI4RLIbhLozEDrkk2L7wSYqI9DZ1Hd26wf\nv3+3jdpsXkzHwWYz1a2+FhCF5mJJRQl6kd/B0wu00vdfwviK9OVO7w==\n-----END RSA PRIVATE KEY-----\n", + "rsa_private_encrypted.pem": + "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-256-CBC,DB3D20E60E8FDC3356BD79712FF8EF7E\n\nK+vu0U3IFTJBBi6zW5Zng80O1jXq/ZmlOFs/j/SQpPwfW1Do9i/Dwa7ntBlTwrCm\nsd3IIPgu2ikfLwxvbxsZN540oCaCqaZ/bmmyzH3MyVDA9MllUu+X8+Q3ATzcYa9R\nU5XfF5DAXsSRnstCbmKagWVQpO0oX8k3ratfny6Ixq86Y82tK8+o5YiBFq1kqa+9\n4yat7IWQbqV5ifUtUPCHZwEqBt+WKazX05BqERjkckHdpfaDrBvSSPXTwoLm6uRR\nktkUVpO4tHMZ4VlcTfFtpz8gdYYod0nM6vz26hvbESHSwztSgMhmKdsE5eqmYfgu\nF4WkEN4bqAiPjKK3jnUKPt/vg2oKYFQlVYFl9QnBjiRqcQTi3e9lwn1hI7uoMb6g\nHuaCc57JJHPN/ZLP3ts4ZxFbwUjTGioh5Zh6WozG3L3+Ujwq/sDrAskRyzdcuP7I\nRs3oLbHY03OHyg8IbxR5Iu89l6FLqnR45yvbxXtZ7ImGOPM5Z9pB1CzDhGDx2F6g\nJ/Kf/7ZF2DmYUVbVKDfESEDhRfuMAVzhasDPTRqipSA5QvJVQY+J/6QDPrNNmHVB\n4e4ouHIDWERUf0t1Be7THvP3X8OJozj2HApzqa5ZCaJDo8eaL8TCD5uH75ID5URJ\nVscGHaUXT8/sxfHi1x8BibW5W5J/akFsnrnJU/1BZgGznIxjf5tKfHGppSIVdlKP\n3ghYNmEIFPNJ6cxuUA0D2IOV4uO3FTCU6seIzvJhYkmXnticcZYGtmGxXKrodtzS\nJ1YuaNkkO/YRZah285lQ6QCIhCFo4Oa4ILjgoTQISuw7nQj5ESyncauzLUBXKX0c\nXDUej64KNTvVF9UXdG48fYvNmSZWCnTye4UmPu17FmwpVra38U+EdoLyWyMIAI5t\nrP6Hhgc9BxOo41Im9QpTcAPfKAknP8Rbm3ACJG5T9FKq/c29d1E//eFR6SL51e/a\nyWdCgJN/FJOAX60+erPwoVoRFEttAeDPkklgFGdc8F4LIYAig9gEZ92ykFFz3fWz\njIcUVLrL+IokFbPVUBoMihqVyMQsWH+5Qq9wjxf6EDIf0BVtm9U4BJoOkPStFIfF\nKof7OVv7izyL8R/GIil9VQs9ftwkIUPeXx2Hw0bE3HJ3C8K4+mbLg3tKhGnBDU5Z\nXm5mLHoCRBa3ZRFWZtigX7POszdLAzftYo8o65Be4OtPS+tQAORk9gHsXATv7dDB\nOGw61x5KA55LHVHhWaRvu3J8E7nhxw0q/HskyZhDC+Y+Xs6vmQSb4nO4ET4NYX1P\nm3PMdgGoqRDJ2jZw4eoQdRKCM0EHSepSAYpO1tcAXhPZS4ITogoRgPpVgOebEQUL\nnKNeNu/BxMSH/IH15jjDLF3TiEoguF9xdTaCxIBzE1SFpVO0u9m9vXpWdPThVgsb\nVcEI487p7v9iImP3BYPT8ZYvytC26EH0hyOrwhahTvTb4vXghkLIyvPUg1lZHc6e\naPHb2AzYAHLnp/ehDQGKWrCOJ1JE2vBv8ZkLa+XZo7YASXBRZitPOMlvykEyzxmR\nQAmNhKGvFmeM2mmHAp0aC03rgF3lxNsXQ1CyfEdq3UV9ReSnttq8gtrJfCwxV+wY\n-----END RSA PRIVATE KEY-----\n", + "rsa_private_pkcs8.pem": + "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC33FiIiiexwLe/\nP8DZx5HsqFlmUO7/lvJ7necJVNwqdZ3ax5jpQB0p6uxfqeOvzcN3k5V7UFb/Am+n\nkSNZMAZhsWzCU2Z4Pjh50QYz3f0Hour7/yIGStOLyYY3hgLK2K8TbhgjQPhdkw9+\nQtKlpvbL8fLgONAoGrVOFnRQGcr70iFffsm79mgZhKVMgYiHPJqJgGHvCtkGg9zM\ngS7p63+Q3ZWedtFS2RhMX3uCBy/mH6EOlRCNBbRmA4xxNzyf5GQaki3T+Iz9tOMj\ndPP+CwV2LqEdylmBuik8vrfTb3qIHLKKBAI8lXN26wWtA3kN4L7NP+cbKlCRlqct\nvhmylLH1AgMBAAECggEBAJLZ6ti7yDKgY+LcT/NiBDqKyEUBlbMNZIW5vAPnBKbh\nJIDO9WIv9Fs7qSpLbnFHnr0OYtGIfMPXtUiYkyw0QJSc+upHZMvbno4llpes0eHc\njWVTBWETON4oywvj/Kz53vRc9eiKhxVuVWyagNcQgYSprjzLA+9UTcWeB67Guyrf\n8YJUE2LC23RiMA5nGYoSHfVRl0c75gj7A0X9nwpAI+xw3kcaVHRIhA6WowA3Pj1o\npK2t692+NLVRylpvMMSS4rziDexomFykCFukYWYB/kZOOSSETSsTWoMXXl1KqsoZ\n8IW06NR4rXtIgQ3sTfbYKGZNF5nWFgZ+hJVx0We1Qg0CgYEA8UovlB4nrBm7xH+u\n7XXBMbqxADQm5vaEZxw9eluc+tP7cIAI4sglMIvL/FMpbd2pEeP/BkR76NTDzzDu\nPAZvUGRavgEjy0O9j2NAs/WPK4tZF+vFdunhnSh4EHAF4Ij9kbsUi90NOpbGfVqP\ndOaHqzgHKoR23Cuusk9wFQ2XTV8CgYEAwxHdEYT9xrpfrHPqSBQPpO0dWGKJEkrW\nOb+76rSfuL8wGR4OBNmQdhLuU9zTIh22pog+XPnLPAecC+4yu/wtJ2SPCKiKDbJB\nre0CKPyRfGqzvA3njXwMxXazU4kGs+2Fg+xu/iKbaIjxXrclBLhkxhBtySrwAFhx\nxOk6fFcPLSsCgYEAqS/Mdr5CMRGGMH0bKhPUWEtAixUGZhJaunX5wY71Xoc/Gh4c\nnO+b7BNJ/+5L8WZog0vr6PgiLhrqBaCYm2wjpyoG2o2wDHm+NAlzN/wp3G2EFhrS\nxdOux+S1c0kpRcyoiAO2n29rNDa+jOzwBBcU8ACEPdLOCQl0IEFFJO33tl8CgYBY\nDOIqnEsovsucvh3MNzHwkg8i7CdPGHSmUIN0J9/ItpPxYn2VdtccVOM6+3xZ8+uU\nM/9iXGZ+TDkFsZk4/VUsaNmfYOQf1oyLA2ZsNcU90bQbeHNCi/H/19qOJFXgNaCE\nsd5P3DMl9lptFGIjRVBHjvbfTQBUR5fi+BusMGfrTQKBgQCTtzMEJP2sef883AJr\nXuGVPLzwLi9eTBvPzc5r5pfkvh7mDDmWFxHZm5kctvavqgy32uUPsQgMi1Kz67bU\ns5dY9MCVrN2elhTLD8LOiAz8836o3AxFefm5cUWGaU/aZWDYR0QtNqFdyHyRaodo\nJJfnfK+oK1Eq7+PvpXfVN9BkYw==\n-----END PRIVATE KEY-----\n", + "rsa_private_pkcs8_bad.pem": + "-----BEGIN RSA PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC33FiIiiexwLe/\nP8DZx5HsqFlmUO7/lvJ7necJVNwqdZ3ax5jpQB0p6uxfqeOvzcN3k5V7UFb/Am+n\nkSNZMAZhsWzCU2Z4Pjh50QYz3f0Hour7/yIGStOLyYY3hgLK2K8TbhgjQPhdkw9+\nQtKlpvbL8fLgONAoGrVOFnRQGcr70iFffsm79mgZhKVMgYiHPJqJgGHvCtkGg9zM\ngS7p63+Q3ZWedtFS2RhMX3uCBy/mH6EOlRCNBbRmA4xxNzyf5GQaki3T+Iz9tOMj\ndPP+CwV2LqEdylmBuik8vrfTb3qIHLKKBAI8lXN26wWtA3kN4L7NP+cbKlCRlqct\nvhmylLH1AgMBAAECggEBAJLZ6ti7yDKgY+LcT/NiBDqKyEUBlbMNZIW5vAPnBKbh\nJIDO9WIv9Fs7qSpLbnFHnr0OYtGIfMPXtUiYkyw0QJSc+upHZMvbno4llpes0eHc\njWVTBWETON4oywvj/Kz53vRc9eiKhxVuVWyagNcQgYSprjzLA+9UTcWeB67Guyrf\n8YJUE2LC23RiMA5nGYoSHfVRl0c75gj7A0X9nwpAI+xw3kcaVHRIhA6WowA3Pj1o\npK2t692+NLVRylpvMMSS4rziDexomFykCFukYWYB/kZOOSSETSsTWoMXXl1KqsoZ\n8IW06NR4rXtIgQ3sTfbYKGZNF5nWFgZ+hJVx0We1Qg0CgYEA8UovlB4nrBm7xH+u\n7XXBMbqxADQm5vaEZxw9eluc+tP7cIAI4sglMIvL/FMpbd2pEeP/BkR76NTDzzDu\nPAZvUGRavgEjy0O9j2NAs/WPK4tZF+vFdunhnSh4EHAF4Ij9kbsUi90NOpbGfVqP\ndOaHqzgHKoR23Cuusk9wFQ2XTV8CgYEAwxHdEYT9xrpfrHPqSBQPpO0dWGKJEkrW\nOb+76rSfuL8wGR4OBNmQdhLuU9zTIh22pog+XPnLPAecC+4yu/wtJ2SPCKiKDbJB\nre0CKPyRfGqzvA3njXwMxXazU4kGs+2Fg+xu/iKbaIjxXrclBLhkxhBtySrwAFhx\nxOk6fFcPLSsCgYEAqS/Mdr5CMRGGMH0bKhPUWEtAixUGZhJaunX5wY71Xoc/Gh4c\nnO+b7BNJ/+5L8WZog0vr6PgiLhrqBaCYm2wjpyoG2o2wDHm+NAlzN/wp3G2EFhrS\nxdOux+S1c0kpRcyoiAO2n29rNDa+jOzwBBcU8ACEPdLOCQl0IEFFJO33tl8CgYBY\nDOIqnEsovsucvh3MNzHwkg8i7CdPGHSmUIN0J9/ItpPxYn2VdtccVOM6+3xZ8+uU\nM/9iXGZ+TDkFsZk4/VUsaNmfYOQf1oyLA2ZsNcU90bQbeHNCi/H/19qOJFXgNaCE\nsd5P3DMl9lptFGIjRVBHjvbfTQBUR5fi+BusMGfrTQKBgQCTtzMEJP2sef883AJr\nXuGVPLzwLi9eTBvPzc5r5pfkvh7mDDmWFxHZm5kctvavqgy32uUPsQgMi1Kz67bU\ns5dY9MCVrN2elhTLD8LOiAz8836o3AxFefm5cUWGaU/aZWDYR0QtNqFdyHyRaodo\nJJfnfK+oK1Eq7+PvpXfVN9BkYw==\n-----END RSA PRIVATE KEY-----\n", + "rsa_pss_private_2048.pem": + "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADALBgkqhkiG9w0BAQoEggSoMIIEpAIBAAKCAQEAy4OMdS84PlgI5CRL\nbdbud9Ru7vprFr2YNNUmdT7D3YgApiv8CjzKXLiVDnbMET+lwmtag/EcZsxVCKov\nsu30pYASBriHOiMVYui9+ZaJoQ9yI6lOjG1RbuUBJXNSjHBJxqBqmcgZOb1mdRr/\neXzpAMWJ3hfuLojU2+zUSJ3/rvepepcLFG2q9nA0+PJskJ7Pnh3L0ydnv3U3hduM\nn5OVfm/Jx1FPyZpD184tJff+N+MY3s3hIcfuOnL9Pl4RPGeaTC4T1o460NaG6bG7\nc2Whg6NOaVgaFIaiNbrTTNCpVjeTyalsTXYlQQ3hiKjst0Q7pfFEkJDo8qiqLad1\nMsl59wIDAQABAoIBAQC6G8aqs0/f02nuGDLSc6cH9kCsUlz0ItW6GuJcfdVoFSNi\n0v5d7lGwkSveWk0ryOSw8rOHzUqHx3xLvDZ6jpkXcBMMClu/kq3QEb8JK90YaKOc\ncQvf52h83Pc7ZEatH1KYTcKudwp6fvXfSZ0vYEdD6WG2tHOgIollxSIsdjCHs1qi\n7baNHdK9T4DveuEZNcZ+LraZ1haHmFeqIPcy+KvpGuTaLCg5FPhH2jsIkw9apr7i\niFLi+IJ7S5Bn/8XShmJWk4hPyx0jtIkC5r2iJnHf4x+XYWZfdo7oew3Dg6Pa7T6r\nI164Nnaw0u0LvO4gQdvYaJ/j9A602nHTp7Tsq8chAoGBAOtVHgIqpmdzwR5KjotW\nLuGXDdO9X6Xfge9ca2MlWH1jOj+zqEV7JtrjnZAzzOgP2kgqzpIR71Njs8wkaxTJ\nTle0Ke6R/ghU9YOQgRByKjqJfQXHZnYFPsMg0diNYLroJ4SG8LO4+2SygTYZ4eKL\nqU0bda3QvQ7FL+rTNQBy01b9AoGBAN1jEQI80JxCT7AMvXE6nObIhbkASHte4yOE\n1CBwcOuBEGcuvMOvQVMzKITgea6+kgsu4ids4dM5PTPapQgpKqIIQ2/eSesaf56g\n73clGGSTPHJP0v+EfKg4+GYJf8o2swT0xDHkgWLgjjdsncB9hATc2j6DvHeau18d\npgCLz9kDAoGAXl/SGvhTp2UqayVnKMW1I07agrGNLA4II5+iiS4u4InskCNSNhr/\nKATj6TJ82AuTdCGGmdmLapuvPQzVzI42VsGvlzcA8wJvOwW2XIwMF1GPy8N9eZL8\n6m+89+Uqh4oWXvVmjgx+9JEJdFLI3Xs4t+1tMfll+AhoAPoWZUmnK1kCgYAvEBxR\niXQfg8lE97BeHcO1G/OxfGnsMCPBLT+bFcwrhGhkRv9B6kPM2BdJCB9WEpUhY3oY\nP4FSUdy85UIoFfhGMdOEOJEmNZ/jrPq7LVueJd63vlhwkU2exV2o82QDLNWpvA7p\nPFZ1Gp+hEKoIfaZPElQi7gZmtrIWaksb2pz42QKBgQCct9NP2qJfqeS4206RDnfv\nM238/O2lNhLWdSwY0g+tcN+I1sGs3+4vvrm95cxwAmXZyIM11wjdMcAPNxibodY7\nvufsebPHDBA0j0yuTjGkXefUKd1GdO88i5fppzxB7prDX9//DsWWrFhIMMRNYe0Q\naeHd/NPuHcjZKcnaVBgukQ==\n-----END PRIVATE KEY-----\n", + "rsa_pss_private_2048_sha1_sha1_20.pem": + "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQowAASCBKcwggSjAgEAAoIBAQCpdutzsPFQ1100\nouR5aAwYry8aAtG0c+zX9UqNXGCpRDWzPPpXHUZSB1BmTTL4EhK2tkAfblYNqzRu\nCAYlKHbFpFLs2zLEorfp0WsFNPaBHE9JHpLIM4oXxPCUypZ7JAn56ZYonYCZ8Il5\n8SzD9aoF41RTEmpcx3XkL2RQa022RiSccYZKx/yzskUUAdTvTvYyujH1MkvsfVP+\nNs5bRL6IVqowFd3xv6ctvfQMxz0rltgTC+wOm3CFtn+G63y6P/Z0U2DRdacsNkN6\nPFGXAIB0kSvKzs8gVocEBiSwMkcT/KD3R68PY18b2auqaGcm8gA+gaVJ36KAW4dO\nAjbY+YitAgMBAAECggEAfPvfFXln0Ra1gE+vMDdjzITPuWBg57Uj9fbMIEwEYnKT\nJHmRrNRDe9Y3HuxK7hjuQmFSE5xdzUD6rzgtyBP63TOfkV7tJ4dXGxS/2JxCPeDy\nPNxWp18Ttwoh4as0pudikDYN8DCRm3eC/TO5r2EtH6CVHZuUZI8bTMsDMiihrQ8F\nB8+KucBG5DDy/OlDeieAZxZA4Y0/c+W0DNZ/LIPGwaqMzYCSZJXyV0t33HytUwM2\nQZ+RbWqcUcrCI3lFAO8IyEULCi+RnSByZeJ0xwUkdQTI5jT6+G8BrO70Oiab8g+Q\nRx2s7PxWpIMVS7/JD1PsL4hLrVh3uqh8PZl3/FG9IQKBgQDZWkOR2LA+ixmD6XJb\nQ+7zW2guHnK6wDrQFKmBGLaDdAER64WL1Unt6Umu7FPxth2niYMEgRexBgnj5hQN\nLfPYTiIeXs5ErrU96fVQABsV0Hra1M2Rhve5nynjFFpbHjDXtizzLpE30MsC7YkN\nEqD4YYzjWHrbk/UlQ7tx3eAvtQKBgQDHmNM4TRuyH2yaYxDqnho6fgJv7Z4KgbM0\n1wcUxi5kPDQsFtaVOzFhNserzsWvotQjLkC2+CK5qlCdm59ZlpUqszF6+YyUs5Gq\nWmHdqryduT1VxSV/pd6wGEQo27fxFV7LsT1JhVMh9Iri8MK0b1BD6+kVUf5NcKDB\nOd2o8A1gGQKBgA5Y3Pj1mrymJesFL91CYLWDpR7WN7CIG9m8Y2v4G6QVtjRenZQb\nYiPoMErxoqDj6pUyiIl1lADFa0W13ED6dYwjrDDhBTCXb7NEjELZnvATsOhc/6zJ\ngfSowvUQVN6K4aJ7jgAHZOKQT7ZDw7YvMpzyo4AmSQXRgG8TR34+rRu5AoGACApP\n9+SjSPmbFl0HQWw9Aj4xOvEHfMTcwzQmRN/23nLOZzhETJ6lzpS2VmVt8TVN9lzW\nnohAXdpOhQrP0HwQZjfxtlJ3J0ZUh9g8OQG3t2LO5bWbXRkBb3aKyFqRflSuDOaG\n4X9NagC/14R7U2loglPuf71d0SDIWQBLvZJt94ECgYEAnY7aKHnWdLszcB8uyEkJ\nEJkUEaa+K/nTqOzqffZ01cTWJmUG7a2KuvQ+UQM2BHk2+wBmUo45Iz/dyePOJY0B\nFu2agiV4+R4z2XVQnIvXgY5HaPxvLz0THksY/pD58gBmFaLMx4ADEwQ+s4Y2g12H\nABsKNRHfSnKTwOm/dYvcVqs=\n-----END PRIVATE KEY-----\n", + "rsa_pss_private_2048_sha256_sha256_16.pem": + "-----BEGIN PRIVATE KEY-----\nMIIE7wIBADA9BgkqhkiG9w0BAQowMKANMAsGCWCGSAFlAwQCAaEaMBgGCSqGSIb3\nDQEBCDALBglghkgBZQMEAgGiAwIBEASCBKkwggSlAgEAAoIBAQDfqNM4C+QtD73i\nILqOkqfV8ha3O19jpX8UujIk1Z72bbbuwEzh0+sBw0dD0N8CgkXnePOEEd6q7HNm\nbyCNqRpDK6NDvaCMDWgEaD/PlHkRntvKh81IXSMC5imjRfOcZIE/Gnw7h8tanab0\nn75+ODvLJrmEWUG2q79Im1mWMx7Spod+Np6XEY+7I7nAUUWivr35Yx5DeyxY8rxF\nGpsLtGsi7JNQO4aHyeBpj8tz0Fhv23uPywE2nGmPHfnkXWbrTcHGbzYBgEbeSH9K\nUkRwczqDXNOPhtfaEHEFTm0MoeKCnJe1VOjSywev77dV1KZfpVh3Kh0ZRQIe9YOV\nJhj4lMx3AgMBAAECggEBAIc+IgK5Bg/NfgeXvNdrjPuM+PlxeHvb3h1dfebSGd5v\nd3elZpgDug6F07kJO2Db/4M5mx7YY2m9swZU2j1u7MeDQqU6rDMkBCruEu/lmtPx\n2Hv+ZD6Gux4MqU7mhKmkCJds34Rr16aCwCsZ0WmnfViZoQKLqnXYIsG31pNBdDjx\ngke0HhX1LkA9yTVwlk8xOaHPqI4KfsFAyoiiHzyttGDexzb1PzmM0pybAPDMhpN/\nwXp2kLjyzmUmPe2Y2yva69WVWo7qS6joKjY75MQ1t20HYgEL69IApvCPu4CANfi9\nj3FAaV/+WrnhKCi6QyUi5PCI/+AJLsjNQmqTXIdBEoECgYEA+XsgFbeZ6+ZzEHa7\nHyFH6kiyBLd0q7w+ZLPsoOmEApDaP3yXSC7eJU7M/tPUPj8VQMMSK2D6fgmUDwhb\n3mEXFZxf67UlPFsFjweYcBihwy4r8QKBwury6dEbHPSUq4mXFJF5XRQdGqRGkr/F\n8OLZ0MwmHLUzczA67PxP/wF8TsECgYEA5YD4RxxJJYfAk1rFbqHRhNB8UeYVL+Wo\nwsRET1JeFg+S5grLGUga+KBB8Jn7Ahaip7MVE0Iud1cgaDi4WBEJsbJ6oJTlHJEg\nJq7QAaBafwjeSCSnaEsVBVNvriy2WF7uAomLSKmW6uSUOBBFFt4+G+akG56EfOPc\n7YKBfsf5ITcCgYBvjVZzX307tfeNTQmuibsWTxsKcN2CTNG5RZpw+PlGDG8KJDOg\n2xQJqoqPBzjH/H0MUC03qE1ZPf8uGZa6gL9JsnpRctYLfselhMfsl5b9JxAO3AgZ\nl+S2GAH/mH1BlmwvjjyuGehJmVrVE1r2sviiHCaOf5dZ0h8HCGrco1VqAQKBgQCf\nfYkMofOTSUvjG2mpAHuCOQCsSaDfsFIfSBXQqgUIf7ouc8HAyAM2VOh+NAPj56cR\ns7opsAxqkvnKc+BoEy8Rdl8RyWeO+qvFNicHelBph9gxeod8SvFIyjsKZ7gwoYf1\n63AIBxMCGeeHLodU5Q10hkv1hau8vv2BcPhdCstu8QKBgQDgO4Rr36Pa5rjbNbGN\nPsJqALjthTeU0yaU8hdC7Hc7B/T6npEIhw3s1eNq7e0eeDltqz7tDnY8qysazbQZ\np1cV5TJ8+gtwGmDoADBnU1NXqX4Squfml6OhNEucpTdjux7JdLVmmQFraOT2Eu5f\n9uuNtA+d8uhBEXhskuvEC552ug==\n-----END PRIVATE KEY-----\n", + "rsa_pss_private_2048_sha512_sha256_20.pem": + "-----BEGIN PRIVATE KEY-----\nMIIE5wIBADA4BgkqhkiG9w0BAQowK6ANMAsGCWCGSAFlAwQCA6EaMBgGCSqGSIb3\nDQEBCDALBglghkgBZQMEAgEEggSmMIIEogIBAAKCAQEAvM9NgMCDqy5dqj5Ua2cZ\ncc4zVr+fCF34bZn63OBeYG8RTJKM3j36lO/yamtfDctDhb87b45CS6ipEr8J57I9\nWF55TNngsn6GNpXgwAe0eFXUnKonuqnGEC7x8r3vkAg99PBKhAtFc5oTaaZDAFKM\nzc5dIC/J0Y+kxhqjCPNI0ydQgZmKBrmYjM9cvbOYgRQL10GrWeJ+XHk2E33endaF\n+4dwjgyrzInt/l6OTkiCL8F59J/htk1GPru9BT6w5yOS/vH6q6FD6uizULVznytd\nlHjgnrVaHJmsqVjrYQa9OAZj9GBrTelBWvQ9b6+FBHUFHBp8HSp82lWCZThPrcZ/\nQwIDAQABAoIBADDzUfWic8CKuc/sbviVdzxRKHBCJ9oEeub3d9mR9gXsZcDDcfAg\ng3nfp6q9gZxS6YOga6llaXyyEnuAufGu/UaO38Xz6tR8BxHZ07YViU11ezTOzJQR\ndf82HJZBdf2SlXWOYtNPFMd+16+ZYl+QB19INE6m9Rz2r9KIj2I/qM7NPPVhDRF0\nG4O0Yf2vaPhjoIaewn7xtQ6wmX7pAGcd8dmYEIGGkBi8aY3BVwrRK1X4AmD+oSmE\nwXqR6MQIzD4KdypL4UD1Cb4GoFeVRclXvegOG+EKl0iD+mjTodB4yjoJh98NYe3+\nGtpR/2u3Ltq8RqWpIg8ryShQk/MIqGJ5KpkCgYEA80uNEYWlBt6QGNvVIYrhnw+V\n2nLJWedioKV4H1sr9OLF/7WFOUfsaflpVybnmwfNV15lEyHb/m/sCM9jTrNk1t/q\nqhRnvtmy3kntxWBeoA2o0TRg/XZKWjabZsr/4UE/Ztws2opOvl9x05IYeZlU+PbZ\nB1lX2e+vtMOpllvRr28CgYEAxqtfrYv8brp/fAUqGu/MtdHxQdU1+vE+auN17gam\nZM6ojIeasX4k2z0Rd/+8Dga9wPgO7hjtFZ2NWD5UwfBiw5U2PVZ6bp3iKSBPGHEh\nRsIR+nw8pFIgsQKoYnVK58tEnfQ31GSupKpYybHbaL5SdId+mfXU86SbKX/MefZX\ng20CgYBjn8hAKI2O5ovy4fHALnJ9A5DFRsOUgN8uERPDIz44pLOXJelLr1vreSnd\nehzUqrk20Xxp/S9sXMA2S1XK4EKmikI5KungiJxp0bP/Yprcxzsdj2k34LxJfJrd\n2Lo2rtUbdYUYaBIeek7N58EF6feVit8L11XV9APq7UQAQdD3GQKBgBmxuGIdpLw9\napeDo3pwYS1yxZ0aEi0uXkA8wtfSDFslTy89qogiJGomb8fxT0URIiF+849fseoF\nwm4TQasDiAJ7ndQ5BwSfbsya3R/wIbmhB+o5fy5RYOED0vtI6DMqWumC2GWjz+KE\nFY+gbRwS4V8o1vrajHwmYdrwKGXtskvRAoGADe/EdlCj98r6Zle9giF1C0GDIDTx\n8bR2m+Ogh9ZO218GpDMNaQ/WmbYVPDYMbRSOEA7y2gVkd1OeYhJMF6aYffp3aWhN\nt9g0uojLY0jfEpWBLBQdlYOTl7hOnLWRRcOAKTlHVg+OuD/O/GmdQ2Rg2H/hAWlI\nmuuTQPuQTCV1aTc=\n-----END PRIVATE KEY-----\n", + "rsa_pss_public_2048.pem": + "-----BEGIN PUBLIC KEY-----\nMIIBIDALBgkqhkiG9w0BAQoDggEPADCCAQoCggEBAMuDjHUvOD5YCOQkS23W7nfU\nbu76axa9mDTVJnU+w92IAKYr/Ao8yly4lQ52zBE/pcJrWoPxHGbMVQiqL7Lt9KWA\nEga4hzojFWLovfmWiaEPciOpToxtUW7lASVzUoxwScagapnIGTm9ZnUa/3l86QDF\nid4X7i6I1Nvs1Eid/673qXqXCxRtqvZwNPjybJCez54dy9MnZ791N4XbjJ+TlX5v\nycdRT8maQ9fOLSX3/jfjGN7N4SHH7jpy/T5eETxnmkwuE9aOOtDWhumxu3NloYOj\nTmlYGhSGojW600zQqVY3k8mpbE12JUEN4Yio7LdEO6XxRJCQ6PKoqi2ndTLJefcC\nAwEAAQ==\n-----END PUBLIC KEY-----\n", + "rsa_pss_public_2048_sha1_sha1_20.pem": + "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQowAAOCAQ8AMIIBCgKCAQEAqXbrc7DxUNddNKLkeWgM\nGK8vGgLRtHPs1/VKjVxgqUQ1szz6Vx1GUgdQZk0y+BIStrZAH25WDas0bggGJSh2\nxaRS7NsyxKK36dFrBTT2gRxPSR6SyDOKF8TwlMqWeyQJ+emWKJ2AmfCJefEsw/Wq\nBeNUUxJqXMd15C9kUGtNtkYknHGGSsf8s7JFFAHU7072Mrox9TJL7H1T/jbOW0S+\niFaqMBXd8b+nLb30DMc9K5bYEwvsDptwhbZ/hut8uj/2dFNg0XWnLDZDejxRlwCA\ndJErys7PIFaHBAYksDJHE/yg90evD2NfG9mrqmhnJvIAPoGlSd+igFuHTgI22PmI\nrQIDAQAB\n-----END PUBLIC KEY-----\n", + "rsa_pss_public_2048_sha256_sha256_16.pem": + "-----BEGIN PUBLIC KEY-----\nMIIBUjA9BgkqhkiG9w0BAQowMKANMAsGCWCGSAFlAwQCAaEaMBgGCSqGSIb3DQEB\nCDALBglghkgBZQMEAgGiAwIBEAOCAQ8AMIIBCgKCAQEA36jTOAvkLQ+94iC6jpKn\n1fIWtztfY6V/FLoyJNWe9m227sBM4dPrAcNHQ9DfAoJF53jzhBHequxzZm8gjaka\nQyujQ72gjA1oBGg/z5R5EZ7byofNSF0jAuYpo0XznGSBPxp8O4fLWp2m9J++fjg7\nyya5hFlBtqu/SJtZljMe0qaHfjaelxGPuyO5wFFFor69+WMeQ3ssWPK8RRqbC7Rr\nIuyTUDuGh8ngaY/Lc9BYb9t7j8sBNpxpjx355F1m603Bxm82AYBG3kh/SlJEcHM6\ng1zTj4bX2hBxBU5tDKHigpyXtVTo0ssHr++3VdSmX6VYdyodGUUCHvWDlSYY+JTM\ndwIDAQAB\n-----END PUBLIC KEY-----\n", + "rsa_pss_public_2048_sha512_sha256_20.pem": + "-----BEGIN PUBLIC KEY-----\nMIIBTTA4BgkqhkiG9w0BAQowK6ANMAsGCWCGSAFlAwQCA6EaMBgGCSqGSIb3DQEB\nCDALBglghkgBZQMEAgEDggEPADCCAQoCggEBALzPTYDAg6suXao+VGtnGXHOM1a/\nnwhd+G2Z+tzgXmBvEUySjN49+pTv8mprXw3LQ4W/O2+OQkuoqRK/CeeyPVheeUzZ\n4LJ+hjaV4MAHtHhV1JyqJ7qpxhAu8fK975AIPfTwSoQLRXOaE2mmQwBSjM3OXSAv\nydGPpMYaowjzSNMnUIGZiga5mIzPXL2zmIEUC9dBq1niflx5NhN93p3WhfuHcI4M\nq8yJ7f5ejk5Igi/BefSf4bZNRj67vQU+sOcjkv7x+quhQ+ros1C1c58rXZR44J61\nWhyZrKlY62EGvTgGY/Rga03pQVr0PW+vhQR1BRwafB0qfNpVgmU4T63Gf0MCAwEA\nAQ==\n-----END PUBLIC KEY-----\n", + "rsa_public.pem": + "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt9xYiIonscC3vz/A2ceR\n7KhZZlDu/5bye53nCVTcKnWd2seY6UAdKersX6njr83Dd5OVe1BW/wJvp5EjWTAG\nYbFswlNmeD44edEGM939B6Lq+/8iBkrTi8mGN4YCytivE24YI0D4XZMPfkLSpab2\ny/Hy4DjQKBq1ThZ0UBnK+9IhX37Ju/ZoGYSlTIGIhzyaiYBh7wrZBoPczIEu6et/\nkN2VnnbRUtkYTF97ggcv5h+hDpUQjQW0ZgOMcTc8n+RkGpIt0/iM/bTjI3Tz/gsF\ndi6hHcpZgbopPL630296iByyigQCPJVzdusFrQN5DeC+zT/nGypQkZanLb4ZspSx\n9QIDAQAB\n-----END PUBLIC KEY-----\n", + "rsa_public_2048.pem": + "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArk4OqxBqU5/k0FoUDU7C\npZpjz6YJEXUpyqeJmFRVZPMUv/Rc7U4seLY+Qp6k26T/wlQ2WJWuyY+VJcbQNWLv\njJWks5HWknwDuVs6sjuTM8CfHWn1960JkK5Ec2TjRhCQ1KJy+uc3GJLtWb4rWVgT\nbbaaC5fiR1/GeuJ8JH1Q50lB3mDsNGIk1U5jhNaYY82hYvlbErf6Ft5njHK0BOM5\nOTvQ6BBv7c363WNG7tYlNw1J40dup9OQPo5JmXN/h+sRbdgG8iUxrkRibuGv7loh\n52QQgq2snznuRMdKidRfUZjCDGgwbgK23Q7n8VZ9Y10j8PIvPTLJ83PX4lOEA37J\nlwIDAQAB\n-----END PUBLIC KEY-----\n", + "rsa_public_4096.pem": + "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAxeStwofbjtZuol4lwKn1\nw08AzcSNLHfCqNFHa+W7er8is7LQsPljtPT4yn4lsao83ngHFvSC3tbMiRNDpUHY\nqH2wBuUkuOmCtYkZLi0307H0CwcVV6W5P3tNEt80IJ+PqlRxtTknezUtbOasbIi/\naornVWG+psgqDGrFZ4oTsWtiE0Svi7sDqN5E2dijmH/YYnlnwqszgzHdtAnARp1b\nG34E64sqWCmLoGCfPdHtym/CSdxOLOsDV15jrwODZQ/TJZ5thkwKZRxu7g9fwlhA\n1PiI5WDP4reXNaqa2bSgrzpAljQExYs4N0L7okSVOJQX9BEaoWtq8NLU8MpMdGoH\nNDU0Xr60Lfr58Z5qn8RGEvlTxoCbPJzPV2zgzD/lmEqft6NnfTclveA3sd8xSrOB\nUn4o3S8hS0b9Su7PBukHjM96/e0ReoIshSwXlQTLr2Ft8KwupyPm1ltNcTDtjqHc\nIWU6Bg+kPy9mxSVtGGZYAPtqGzNBA/m+oOja/OSPxAblPdln691DaDuZs5nuZCGw\nGcLaJWgiyoqvXAcyXDZFyH4OZZh8rsBLKbnFXHZ/ziG0cAozEygZEPJappw8Lx/a\ndy7WL/SJjxooiKapc7Bnfy8eSLV3+XAKxhLW/MQ6ChJ+e/8ExAY02ca4MpCvqwIk\n9TfV6FM8pWGqHzQFj0v3NL0CAwEAAQ==\n-----END PUBLIC KEY-----\n", + "rsa_public_b.pem": + "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyb1grrN+29fxeeEbTaSE\nja6TKDTpT/WXnqrFCS+h7IYcnDoAVwcsPU5FZeUPvLKMzi9NHSJ34LQCurqHgH8X\n+cw0YT3gdYS/7qoQiXs+zKv615NcttD3xlQLceY+NwznoPXyyZwOeZqyU5Hiqbrq\nu6hdr6gQYogMNLn2NxBW2pGegd6+ZGMCX3+/BtMP/6tXmttYjY+yhN2SrGz5cKhW\npcHiC6X+B7uCKoKZy+t2jUxYVKUwWr1ZuM8kpSnuVCcv1OoMGEimEHA7v/eaF/y+\nz/VdQ4Y88GhTnVN4KbtgZ+o9PohjxLFU62VeTALixU5mPQKSgSICKfjev0FUUurF\n6wIDAQAB\n-----END PUBLIC KEY-----\n", + "x25519_private.pem": + "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VuBCIEIJi/yFpueUawC1BkXyWM8ONIBGFjL7UZHrD/Zo/KPDpn\n-----END PRIVATE KEY-----\n", + "x25519_public.pem": + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VuAyEAaSb8Q+RndwfNnPeOYGYPDUN3uhAPnMLzXyfi+mqfhig=\n-----END PUBLIC KEY-----\n", + "x448_private.pem": + "-----BEGIN PRIVATE KEY-----\nMEYCAQAwBQYDK2VvBDoEOLTDbazv6vHZWOmODQ3kk8TUOQgApB4j75rpInT5zSLl\n/xJHK8ixF7f+4uo+mGTCrK1sktI5UmCZ\n-----END PRIVATE KEY-----\n", + "x448_public.pem": + "-----BEGIN PUBLIC KEY-----\nMEIwBQYDK2VvAzkAioHSHVpTs6hMvghosEJDIR7ceFiE3+Xccxati64oOVJ7NWjf\nozE7ae31PXIUFq6cVYgvSKsDFPA=\n-----END PUBLIC KEY-----\n", + }; + const result = {}; + const attempt = (fn) => { + try { + return fn(); + } catch (e) { + return { name: e.name, message: e.message, code: e.code ?? null }; + } + }; + for (const [name, pem] of Object.entries(fixtures)) { + for (const kind of ["private", "public"]) { + result[name + "/" + kind] = attempt(() => { + const key = crypto[ + kind === "private" ? "createPrivateKey" : "createPublicKey" + ]({ key: pem, passphrase: "password" }); + const metadata = { + type: key.type, + algorithm: key.asymmetricKeyType, + details: Object.fromEntries( + Object.entries(key.asymmetricKeyDetails).map(([k, v]) => [ + k, + typeof v === "bigint" ? String(v) : v, + ]), + ), + }; + const types = + kind === "private" ? ["pkcs1", "pkcs8", "sec1"] : ["pkcs1", "spki"]; + const formats = {}; + for (const type of types) + for (const format of ["der", "pem"]) + formats[type + "/" + format] = attempt(() => { + const encoded = key.export({ type, format }); + const imported = crypto[ + kind === "private" ? "createPrivateKey" : "createPublicKey" + ]({ key: encoded, type, format }); + return key.equals(imported); + }); + return { ...metadata, formats }; + }); + } + } + const key = crypto.createPrivateKey(fixtures["rsa_private.pem"]), + pkcs8 = key.export({ format: "der", type: "pkcs8" }); + result.derTypes = {}; + for (const kind of ["private", "public"]) + for (const type of [undefined, "pkcs1", "pkcs8", "sec1", "spki"]) + result.derTypes[kind + "/" + type] = attempt( + () => + crypto[kind === "private" ? "createPrivateKey" : "createPublicKey"]( + { key: pkcs8, format: "der", type }, + ).asymmetricKeyType, + ); + return result; + }, + cryptoJwkInputs: async function ({ crypto }) { + const result = {}; + for (const kind of ["private", "public"]) + for (const kty of ["RSA", "EC", "OKP", "bad"]) + for (const value of [ + undefined, + null, + 0, + true, + {}, + "", + "A", + "AA", + "AA==", + "A A", + "++//", + "--__", + "@@", + "AAA=", + "AA===", + ]) { + const key = { + kty, + crv: kty === "EC" ? "P-256" : "Ed25519", + n: value, + e: "AQAB", + x: value, + y: "AA", + d: value, + }; + try { + const k = crypto[ + kind === "private" ? "createPrivateKey" : "createPublicKey" + ]({ key }); + result[`${kind}/${kty}/${JSON.stringify(value)}`] = { + ok: true, + type: k.asymmetricKeyType, + }; + } catch (e) { + result[`${kind}/${kty}/${JSON.stringify(value)}`] = { + name: e.name, + message: e.message, + code: e.code ?? null, + }; + } + } + const jwk = { + crv: "P-256", + d: "DxBsPQPIgMuMyQbxzbb9toew6Ev6e9O6ZhpxLNgmAEo", + kty: "EC", + x: "X0mMYR_uleZSIPjNztIkAS3_ud5LhNpbiIFp6fNf2Gs", + y: "UbJuPy2Xi0lW7UYTBxPK3yGgDu9EAKYIecjkHX5s2lI", + }; + for (const crv of [ + "P-256", + "prime256v1", + "secp256k1", + "bad", + "P-224", + undefined, + null, + ]) + for (const kind of ["private", "public"]) { + try { + result[`${kind}/curve/${crv}`] = crypto[ + kind === "private" ? "createPrivateKey" : "createPublicKey" + ]({ key: { ...jwk, crv } }).export({ format: "jwk" }); + } catch (e) { + result[`${kind}/curve/${crv}`] = { + name: e.name, + message: e.message, + code: e.code ?? null, + }; + } + } + return result; + }, + cryptoHostKeyMetadata: async function ({ crypto }) { + const result = {}; + for (const algorithm of [ + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 1024, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + { name: "ECDSA", namedCurve: "P-384" }, + { name: "ECDH", namedCurve: "P-256" }, + ]) { + const pair = await crypto.subtle.generateKey( + algorithm, + true, + algorithm.name === "ECDH" ? ["deriveBits"] : ["sign", "verify"], + ); + for (const kind of ["privateKey", "publicKey"]) { + const key = crypto.KeyObject.from(pair[kind]); + const hostAlgorithm = pair[kind].algorithm; + hostAlgorithm.name = "fake"; + if (hostAlgorithm.publicExponent) hostAlgorithm.publicExponent.fill(0); + if (hostAlgorithm.namedCurve) hostAlgorithm.namedCurve = "fake"; + Object.defineProperty(pair[kind], "algorithm", { + value: { + name: "RSA-PSS", + modulusLength: 3, + publicExponent: new Uint8Array([7]), + }, + configurable: true, + }); + + result[algorithm.name + "/" + kind] = { + type: key.type, + algorithm: key.asymmetricKeyType, + details: Object.fromEntries( + Object.entries(key.asymmetricKeyDetails).map(([k, v]) => [ + k, + typeof v === "bigint" ? String(v) : v, + ]), + ), + }; + } + } + return result; + }, + + fsFileHandleInvalidReceivers: async function ({ fs }) { + const h = await fs.promises.open("/tmp/fh-receivers", "w+"); + const methods = { + appendFile: ["x"], + chmod: [0], + chown: [0, 0], + datasync: [], + sync: [], + read: [new Uint8Array(1)], + readv: [[]], + readFile: [], + readLines: [], + stat: [], + truncate: [], + utimes: [0, 0], + write: [new Uint8Array(1)], + writev: [[]], + writeFile: ["x"], + close: [], + readableWebStream: [], + createReadStream: [], + createWriteStream: [], + }; + const attempt = async (fn) => { + try { + const value = await fn(); + if (value?.destroy) { + value.on("error", () => {}); + value.destroy(); + } + return "accepted"; + } catch (e) { + return { + name: e.name, + code: e.code ?? null, + ...(e.name === "TypeError" ? {} : { message: e.message }), + }; + } + }; + const result = { fake: {}, closed: {} }; + for (const [name, args] of Object.entries(methods)) + result.fake[name] = await attempt(() => h[name].call({}, ...args)); + await h.close(); + for (const [name, args] of Object.entries(methods)) + result.closed[name] = await attempt(() => h[name](...args)); + await fs.promises.unlink("/tmp/fh-receivers"); + return result; + }, + fsFileHandleLifetime: async function ({ fs }) { + const path = "/tmp/fh-lifetime"; + await fs.promises.writeFile(path, "original"); + const old = await fs.promises.open(path, "r+"), + fd = old.fd; + fs.closeSync(fd); + const current = await fs.promises.open(path, "r+"); + const error = async (fn) => { + try { + return await fn(); + } catch (e) { + return { name: e.name, code: e.code ?? null }; + } + }; + const result = { + reused: fd === current.fd, + oldStat: await error(() => old.stat()), + oldRead: await error(() => old.read(new Uint8Array(1))), + }; + await old.close(); + result.current = (await current.stat()).size; + await current.close(); + await fs.promises.writeFile(path, ""); + const empty = await fs.promises.open(path, "r"), + stream = empty.readableWebStream(); + empty.read = () => Promise.reject(new Error("replacement")); + const read = await stream.getReader().read(); + result.capturedRead = read.done; + await empty.close(); + await fs.promises.unlink(path); + return result; + }, + fsFileHandleSurface: async function ({ fs }) { + const error = async (fn) => { + try { + const value = await fn(); + return { accepted: true, value }; + } catch (e) { + return e instanceof TypeError + ? { name: e.name } + : { + name: e.name, + message: e.message, + code: e.code ?? null, + syscall: e.syscall ?? null, + }; + } + }; + await fs.promises.writeFile("/tmp/handle-surface-a", "one"); + await fs.promises.writeFile("/tmp/handle-surface-b", "second"); + const a = await fs.promises.open("/tmp/handle-surface-a", "r+"), + b = await fs.promises.open("/tmp/handle-surface-b", "r"); + const result = {}; + try { + const prototype = Object.getPrototypeOf(a); + result.own = Reflect.ownKeys(a).map(String); + result.prototype = Reflect.ownKeys(prototype).map(String); + result.descriptor = { + own: Object.hasOwn(a, "fd"), + get: typeof Object.getOwnPropertyDescriptor(prototype, "fd")?.get, + set: typeof Object.getOwnPropertyDescriptor(prototype, "fd")?.set, + }; + result.publicConstructor = + a.constructor === fs.promises.FileHandle && + a instanceof fs.promises.FileHandle; + result.constructor = { + name: a.constructor.name, + length: a.constructor.length, + }; + result.new = await error(() => new a.constructor()); + result.identity = a.read === a.read && a.read === prototype.read; + let receiver = false; + result.chain = + a.on("test", function () { + receiver = this === a; + }) === a; + a.emit("test"); + result.receiver = receiver; + result.borrowedStat = (await a.stat.call(b)).size; + const detached = a.stat; + result.detached = await error(() => detached()); + result.fake = await error(() => a.stat.call({})); + result.fakeReadLines = await error(() => a.readLines.call({ fd: a.fd })); + result.assign = await error(() => { + a.fd = 12345; + }); + const written = await a.writeFile("xy"); + result.writeFile = { + bytesWritten: written.bytesWritten, + buffer: Array.from(written.buffer), + plain: Object.getPrototypeOf(written) === Object.prototype, + }; + const read = await a.read(new Uint8Array(2), 0, 2, 0); + result.read = { + bytesRead: read.bytesRead, + buffer: Array.from(read.buffer), + plain: Object.getPrototypeOf(read) === Object.prototype, + }; + result.noBackend = [ + "fs", + "refs", + "closePromise", + "ref", + "unref", + "getAsyncId", + ].map((name) => name in a); + await a.close(); + result.closedFd = typeof a.fd; + result.closedStat = await error(() => a.stat()); + result.closedReadLines = await error(() => a.readLines()); + let closed = 0; + a.on("close", () => closed++); + await a.close(); + result.repeatClose = closed; + return result; + } finally { + await a.close(); + await b.close(); + await fs.promises.unlink("/tmp/handle-surface-a"); + await fs.promises.unlink("/tmp/handle-surface-b"); + } + }, + tlsCustomDuplexStates: async function ({ tls: t, classicStreams: s }) { + const out = {}; + const tick = () => new Promise((r) => setTimeout(r, 15)); + for (const mode of [ + "eof", + "writeError", + "parentObserved", + "directError", + "destroy", + "object", + "encoding", + ]) { + const events = []; + const custom = new s.Duplex({ + allowHalfOpen: true, + objectMode: mode === "object", + read() {}, + write(c, e, cb) { + cb( + ["writeError", "parentObserved"].includes(mode) + ? new Error("write fail") + : undefined, + ); + }, + }); + custom.on("error", (e) => events.push("customError:" + e.message)); + custom.on("close", () => events.push("customClose")); + if (mode === "encoding") custom.setEncoding("utf8"); + let wrapped; + try { + wrapped = new t.TLSSocket(custom); + custom.on("error", (e) => events.push("lateCustomError:" + e.message)); + if (mode === "parentObserved") + wrapped._parentWrap.on("error", (e) => + events.push("parentError:" + e.message), + ); + wrapped.on("error", (e) => events.push("error:" + e.message)); + wrapped.on("close", () => events.push("close")); + wrapped.on("end", () => events.push("end")); + wrapped.on("data", (c) => events.push("data:" + String(c))); + await tick(); + if (mode === "eof") custom.push(null); + if (["writeError", "parentObserved"].includes(mode)) + wrapped.write("x", (e) => events.push("write:" + e?.message)); + if (mode === "directError") custom.emit("error", new Error("manual")); + if (mode === "destroy") wrapped.destroy(); + if (mode === "object") custom.push({ x: 1 }); + if (mode === "encoding") custom.push("abc"); + await tick(); + out[mode] = { + events: [...events], + destroyed: wrapped.destroyed, + customDestroyed: custom.destroyed, + }; + } catch (e) { + out[mode] = { name: e.name, message: e.message, events: [...events] }; + } finally { + wrapped?.destroy(); + custom.destroy(); + await tick(); + } + } + return out; + }, + tlsCustomDuplex: async function ({ tls, classicStreams }) { + const writes = []; + const custom = new classicStreams.Duplex({ + read() {}, + write(chunk, encoding, callback) { + writes.push(Array.from(chunk)); + callback(); + }, + }); + const wrapped = new tls.TLSSocket(custom); + wrapped.on("error", () => {}); + const read = new Promise((resolve) => + wrapped.once("data", (chunk) => resolve(Array.from(chunk))), + ); + await new Promise((resolve) => setTimeout(resolve, 10)); + await new Promise((resolve, reject) => + wrapped.write(new Uint8Array([1, 2]), (error) => + error ? reject(error) : resolve(), + ), + ); + custom.push(new Uint8Array([3, 4])); + const data = await read; + const result = { + writes, + data, + encrypted: wrapped.encrypted, + authorized: wrapped.authorized, + secureConnecting: wrapped.secureConnecting, + }; + wrapped.destroy(); + custom.destroy(); + return result; + }, + cryptoWebExtensions: async function ({ crypto }) { + const attempt = async (fn) => { + try { + return await fn(); + } catch (e) { + return { name: e.name, message: e.message, code: e.code ?? null }; + } + }; + const result = {}; + for (const algorithm of [ + "SHA-1", + "SHA-256", + "SHA-384", + "SHA-512", + "MD5", + "crc32", + "crc32c", + "crc64nvme", + "sha-256", + "SHA-256\0suffix", + "sha256", + "MD4", + null, + {}, + 42, + ]) + result[String(algorithm)] = await attempt(async () => { + const d = new crypto.webcrypto.DigestStream(algorithm), + w = d.getWriter(); + await w.write("1234"); + await w.write(new Uint8Array([53, 54, 55, 56, 57])); + await w.close(); + return { + digest: Array.from(new Uint8Array(await d.digest)), + count: String(d.bytesWritten), + }; + }); + for (const well of [false, true]) + for (const chunks of [ + ["\ud800"], + ["\ud800", "\udc00"], + ["\ud800", new Uint8Array([65]), "\udc00"], + ]) + result[well + ":" + JSON.stringify(chunks)] = await attempt( + async () => { + const d = new crypto.webcrypto.DigestStream("SHA-256", { + toWellFormed: well, + }), + w = d.getWriter(), + counts = []; + for (const chunk of chunks) { + await w.write(chunk); + counts.push(String(d.bytesWritten)); + } + await w.close(); + return { + counts, + final: String(d.bytesWritten), + digest: Array.from(new Uint8Array(await d.digest)), + }; + }, + ); + const d = new crypto.webcrypto.DigestStream("SHA-256"); + const reason = { abort: true }, + rejected = d.digest.catch((e) => e); + await d.abort(reason); + result.abort = (await rejected) === reason; + const disposed = new crypto.webcrypto.DigestStream("SHA-256"), + writer = disposed.getWriter(); + const disposal = disposed.digest.catch((e) => ({ + name: e.name, + message: e.message, + })); + disposed[Symbol.dispose](); + disposed[Symbol.dispose](); + await writer.write(""); + result.disposed = await disposal; + result.afterDispose = await attempt(() => writer.write("a")); + for (const [i, value] of [ + null, + {}, + "a", + new ArrayBuffer(1), + new Uint8Array(1), + new DataView(new ArrayBuffer(1)), + ].entries()) + result["timing" + i] = await attempt(() => + crypto.subtle.timingSafeEqual(value, new Uint8Array(1)), + ); + result.timingLength = await attempt(() => + crypto.subtle.timingSafeEqual(new Uint8Array(1), new Uint8Array(2)), + ); + result.timingDifferent = crypto.subtle.timingSafeEqual( + new Uint8Array([1]), + new Uint8Array([2]), + ); + const named = function () {}; + Object.defineProperty(named, "name", { value: "SHA-256" }); + result.functionAlgorithm = await attempt(async () => { + const d = new crypto.webcrypto.DigestStream(named); + await d.getWriter().close(); + return Array.from(new Uint8Array(await d.digest)); + }); + for (const view of [ + new Uint8Array([1]), + new DataView(new Uint8Array([1]).buffer), + ]) { + Object.defineProperties(view, { + buffer: { value: new Uint8Array([9]).buffer }, + byteOffset: { value: 0 }, + byteLength: { value: 1 }, + }); + result["shadowCompare:" + view.constructor.name] = + crypto.subtle.timingSafeEqual(view, new Uint8Array([1])); + const d = new crypto.webcrypto.DigestStream("crc32"), + w = d.getWriter(); + await w.write(view); + await w.close(); + result["shadowDigest:" + view.constructor.name] = Array.from( + new Uint8Array(await d.digest), + ); + } + const detached = new ArrayBuffer(1), + detachedView = new DataView(detached); + structuredClone(detached, { transfer: [detached] }); + result.detached = [ + crypto.subtle.timingSafeEqual(detached, new ArrayBuffer(0)), + crypto.subtle.timingSafeEqual(detachedView, new ArrayBuffer(0)), + ]; + return result; + }, + cryptoSignatureParameters: function ({ crypto }) { + const attempt = (fn) => { + try { + return fn(); + } catch (e) { + return { name: e.name, message: e.message, code: e.code ?? null }; + } + }; + const data = new Uint8Array([1, 2]); + const rsa = crypto.generateKeyPairSync("rsa", { modulusLength: 1024 }); + const privatePem = rsa.privateKey.export({ format: "pem", type: "pkcs8" }); + const publicPem = rsa.publicKey.export({ format: "pem", type: "spki" }); + const signature = crypto.sign("sha256", data, rsa.privateKey); + const results = []; + for (const padding of [-1, 0, 1, 2, 3, 4, 5, 6, 99]) + for (const saltLength of [undefined, -3, -2, -1, 0, 32, 95]) { + const options = { padding, saltLength }; + results.push([ + attempt( + () => + crypto + .createSign("sha256") + .update(data) + .sign({ key: privatePem, ...options }).length, + ), + attempt(() => + crypto + .createVerify("sha256") + .update(data) + .verify({ key: publicPem, ...options }, signature), + ), + attempt( + () => + crypto.sign("sha256", data, { key: privatePem, ...options }) + .length, + ), + attempt(() => + crypto.verify( + "sha256", + data, + { key: publicPem, ...options }, + signature, + ), + ), + ]); + } + const ec = crypto.generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + const ecPrivate = ec.privateKey.export({ format: "pem", type: "pkcs8" }), + ecPublic = ec.publicKey.export({ format: "pem", type: "spki" }); + for (const padding of [undefined, -1, 3, 99]) + results.push([ + attempt( + () => + crypto + .createSign("sha256") + .update(data) + .sign({ key: ecPrivate, padding }).length > 0, + ), + attempt(() => + crypto + .createVerify("sha256") + .update(data) + .verify({ key: ecPublic, padding }, new Uint8Array([1])), + ), + ]); + for (const signature of [ + null, + {}, + [], + new ArrayBuffer(1), + new DataView(new ArrayBuffer(1)), + ]) + results.push( + attempt(() => + crypto + .createVerify("sha256") + .update(data) + .verify(ec.publicKey, signature), + ), + ); + for (const type of ["ed25519", "x25519"]) { + const pair = crypto.generateKeyPairSync(type); + const signer = crypto.createSign("sha256").update(data); + results.push( + attempt(() => signer.sign(pair.privateKey)), + attempt(() => { + signer.update(data); + return true; + }), + ); + results.push( + attempt(() => crypto.sign("sha256", data, pair.privateKey)), + attempt(() => + crypto.verify("sha256", data, pair.publicKey, new Uint8Array(64)), + ), + ); + } + return results; + }, + cryptoSignatureInventory: function ({ crypto }) { + const attempt = (fn) => { + try { + return fn(); + } catch (e) { + return { name: e.name, message: e.message, code: e.code ?? null }; + } + }; + const result = {}; + result.constants = { + nullPrototype: Object.getPrototypeOf(crypto.constants) === null, + descriptors: Object.getOwnPropertyDescriptors(crypto.constants), + }; + for (const algorithm of [ + "sha3-256", + "ripemd160", + "blake2b512", + "DSA-SHA", + "sha256", + "SHA256", + "sha256\0suffix", + ]) { + for (const operation of ["createSign", "createVerify"]) + result[operation + ":" + algorithm] = attempt(() => { + crypto[operation](algorithm); + return true; + }); + } + const pair = crypto.generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + const data = new Uint8Array([1, 2, 3]); + const signature = crypto.sign("sha256", data, { + key: pair.privateKey.export({ format: "pem", type: "pkcs8" }), + dsaEncoding: "ieee-p1363", + }); + for (const view of [ + signature, + new DataView( + signature.buffer, + signature.byteOffset, + signature.byteLength, + ), + new Uint16Array( + signature.buffer, + signature.byteOffset, + signature.byteLength / 2, + ), + ]) { + result[ + "verify:" + (view === signature ? "Buffer" : view.constructor.name) + ] = attempt(() => + crypto + .createVerify("sha256") + .update(data) + .verify( + { + key: pair.publicKey.export({ format: "pem", type: "spki" }), + dsaEncoding: "ieee-p1363", + }, + view, + ), + ); + } + for (const algorithm of ["sha3-256", "DSA-SHA", "sha256\0suffix"]) + result["oneShot:" + algorithm] = attempt( + () => crypto.sign(algorithm, data, pair.privateKey).length > 0, + ); + const ed = crypto.generateKeyPairSync("ed25519"); + const verifier = crypto.createVerify("sha256").update(data); + result.ed = attempt(() => + verifier.verify(ed.publicKey, new Uint8Array(64)), + ); + result.afterEd = attempt(() => { + verifier.update(data); + return true; + }); + for (const type of ["pkcs1", "sec1"]) + result["export:" + type] = attempt( + () => pair.privateKey.export({ format: "der", type }).length, + ); + result.nulCurve = attempt(() => { + crypto.createECDH("prime256v1\0suffix"); + return true; + }); + return result; + }, + fsReadLinesInventory: async function ({ fs }) { + const path = "/tmp/read-lines-inventory"; + const attempt = (fn) => { + try { + fn(); + return "accepted"; + } catch (e) { + return { name: e.name, message: e.message, code: e.code ?? null }; + } + }; + await fs.promises.writeFile(path, "a\nb\n"); + const handle = await fs.promises.open(path, "r"); + try { + const open = attempt(() => handle.readLines()); + await handle.close(); + return { open, closed: attempt(() => handle.readLines()) }; + } finally { + await handle.close(); + await fs.promises.unlink(path); + } + }, + zlibCrcSeedInventory: function ({ zlib }) { + return [ + -1, + 4294967296, + 1.5, + NaN, + Infinity, + "1", + null, + undefined, + 0, + 4294967295, + ].map((value) => { + try { + return zlib.crc32(new Uint8Array([1, 2]), value); + } catch (e) { + return { name: e.name, message: e.message, code: e.code ?? null }; + } + }); + }, + + cryptoDhContracts: function ({ crypto, withRequestScope = (fn) => fn() }) { + return withRequestScope(() => { + const attempt = (fn) => { + try { + return fn(); + } catch (e) { + return { + name: e.name, + code: e.code ?? null, + message: e.message.replace( + /internal error; reference = .*/, + "internal error", + ), + }; + } + }; + const groups = [ + "modp14", + "modp15", + "modp16", + "modp17", + "modp18", + "MODP14", + "modp14\0suffix", + "modp5", + "modp1", + "invalid", + ].map((name) => + attempt(() => { + const d = crypto.getDiffieHellman(name); + return { + length: d.getPrime().length, + generator: d.getGenerator("hex"), + verify: d.verifyError, + setters: [typeof d.setPublicKey, typeof d.setPrivateKey], + }; + }), + ); + const errors = [ + () => crypto.createDiffieHellman(10001), + () => crypto.createDiffieHellman(8, 100000), + () => crypto.createDiffieHellman(8, new Uint8Array([2])), + () => crypto.createDiffieHellman(new Uint8Array(1251)), + () => crypto.createDiffieHellman(new Uint8Array(0)), + () => crypto.createDiffieHellman(new Uint8Array([23]), 1), + () => + crypto.createDiffieHellman(new Uint8Array([23]), new Uint8Array([1])), + () => + crypto.createDiffieHellman( + new Uint8Array([23]), + new Uint8Array(1251), + ), + () => crypto.createDiffieHellman(new Uint8Array([22])), + () => crypto.createDiffieHellman(new Uint8Array([3])).generateKeys(), + ].map((fn) => + attempt(() => { + fn(); + return "accepted"; + }), + ); + const d = crypto.createDiffieHellman(new Uint8Array([23])); + const before = [ + attempt(() => d.getPublicKey()), + attempt(() => d.getPrivateKey()), + ]; + d.setPrivateKey(new Uint8Array([3])); + const pub = d.generateKeys("hex"); + const peer = crypto.createDiffieHellman(new Uint8Array([23])); + peer.setPrivateKey(new Uint8Array([4])); + peer.generateKeys(); + const secret = d.computeSecret(peer.getPublicKey(), undefined, "hex"); + const keyErrors = [ + new Uint8Array(0), + new Uint8Array([0]), + new Uint8Array([1]), + new Uint8Array([23]), + ].map((key) => [ + attempt(() => d.setPublicKey(key)), + attempt(() => d.computeSecret(key)), + ]); + const numeric = crypto.createDiffieHellman(64); + const numericResult = { + length: numeric.getPrime().length, + generator: numeric.getGenerator("hex"), + verify: numeric.verifyError, + }; + const properties = Object.getOwnPropertyNames( + crypto.DiffieHellman.prototype, + ).map((name) => { + const descriptor = Object.getOwnPropertyDescriptor( + crypto.DiffieHellman.prototype, + name, + ); + return [ + name, + descriptor.enumerable, + descriptor.configurable, + descriptor.writable ?? null, + typeof descriptor.value === "function" ? descriptor.value.name : null, + ]; + }); + const x = crypto.generateKeyPairSync("x25519"), + y = crypto.generateKeyPairSync("x25519"); + const xSecret = crypto.diffieHellman({ + publicKey: x.publicKey, + privateKey: y.privateKey, + }); + const ec = crypto.generateKeyPairSync("ec", { namedCurve: "P-224" }); + const ecResult = attempt(() => + crypto.diffieHellman({ + publicKey: ec.publicKey, + privateKey: ec.privateKey, + }), + ); + return { + groups, + errors, + before, + pub, + secret, + verify: d.verifyError, + keyErrors, + numericResult, + properties, + lengths: [ + crypto.DiffieHellman.length, + crypto.createDiffieHellman.length, + ], + x25519: { + length: xSecret.length, + equal: xSecret.equals( + crypto.diffieHellman({ + publicKey: y.publicKey, + privateKey: x.privateKey, + }), + ), + }, + ecResult, + }; + }); + }, + + cryptoRandomContracts: async function ({ crypto }) { + const attempt = (fn) => { + try { + return fn(); + } catch (e) { + return { name: e.name, code: e.code ?? null, message: e.message }; + } + }; + const fills = [ + () => new Uint8Array(16), + () => new Uint16Array(8), + () => new Int32Array(4), + () => new BigUint64Array(2), + () => new DataView(new ArrayBuffer(24), 4, 16), + () => new ArrayBuffer(16), + ].map((make) => { + const input = make(); + const result = crypto.randomFillSync(input, 1, 1); + const whole = new Uint8Array( + input instanceof ArrayBuffer ? input : input.buffer, + ); + const start = (input.byteOffset ?? 0) + (input.BYTES_PER_ELEMENT ?? 1); + const end = start + (input.BYTES_PER_ELEMENT ?? 1); + return { + length: result.byteLength, + offset: result.byteOffset, + brand: Object.prototype.toString.call(result), + same: result === input, + outsideZero: whole.every((v, i) => (i >= start && i < end) || v === 0), + }; + }); + const errors = [ + () => crypto.randomBytes(-1), + () => crypto.randomBytes(1.5), + () => crypto.randomBytes("1"), + () => crypto.randomFillSync("x"), + () => crypto.randomFillSync(new Uint8Array(2), -1), + () => crypto.randomFillSync(new Uint8Array(2), 0, 3), + () => crypto.randomFill(new Uint8Array(2), 3, () => {}), + () => crypto.randomFill(new Uint8Array(2), null), + () => crypto.randomBytes(0, null), + ].map((fn) => attempt(fn)); + const brandChecks = [ + { [Symbol.toStringTag]: "ArrayBuffer", byteLength: 1 }, + { + [Symbol.toStringTag]: "DataView", + byteLength: 1, + buffer: new ArrayBuffer(1), + byteOffset: 0, + }, + Object.defineProperty(new ArrayBuffer(0), Symbol.toStringTag, { + value: "Object", + }), + Object.defineProperty( + new DataView(new ArrayBuffer(0)), + Symbol.toStringTag, + { value: "Object" }, + ), + ].map((input) => attempt(() => crypto.randomFillSync(input).byteLength)); + const beyond = crypto.randomFillSync(new Uint8Array(2), 3); + const hostErrors = [ + () => crypto.randomFillSync(new Float32Array(2)), + () => crypto.randomBytes(65537), + ].map((fn) => { + try { + fn(); + return "accepted"; + } catch (e) { + return e.name; + } + }); + const order = [], + input = new DataView(new ArrayBuffer(32), 8, 16); + const callback = await new Promise((resolve) => { + const result = crypto.randomFill(input, 2, 4, (error, value) => { + order.push("callback"); + resolve({ error: error?.name ?? null, same: value === input }); + }); + order.push("sync"); + if (result !== undefined) + throw new Error("randomFill must return undefined"); + }); + const empty = await new Promise((resolve) => + crypto.randomBytes(0, (error, value) => + resolve({ error: error?.name ?? null, length: value.length }), + ), + ); + let detachedName; + try { + const get = crypto.webcrypto.getRandomValues; + get(new Uint8Array(1)); + } catch (e) { + detachedName = e.name; + } + const get = crypto.getRandomValues; + return { + fills, + brandChecks, + errors, + hostErrors, + beyond: beyond.byteLength, + callback, + order, + empty, + detachedName, + boundLength: get(new Uint8Array(1)).length, + aliases: [ + crypto.pseudoRandomBytes === crypto.randomBytes, + crypto.default.pseudoRandomBytes === crypto.randomBytes, + ], + extraAliases: ["prng", "rng", "listCiphers", "createCredentials"].map( + (name) => [name in crypto, name in crypto.default], + ), + aliasError: attempt(() => crypto.pseudoRandomBytes(-1)), + }; + }, + + cryptoWorkLimits: async function ({ crypto }) { + const attempt = (fn) => { + try { + return fn(); + } catch (error) { + return { + name: error.name, + code: error.code ?? null, + message: error.message, + }; + } + }; + const options = [ + { N: 1024, r: 1, p: 32768 }, + { N: 2, r: 1, p: 1048577 }, + { N: 65536, r: 1, p: 1 }, + { N: 16, r: 1, p: 1, maxmem: 1 }, + { N: 16, r: 1, p: 1, maxmem: 4294967296 }, + { N: 16, r: 1, p: 1, maxmem: 4294967295 }, + { N: 16, r: 1, p: 1 }, + ]; + const result = { + scrypt: options.map((options) => + attempt(() => crypto.scryptSync("p", "s", 8, options).toString("hex")), + ), + rsa: attempt(() => + crypto.generateKeyPairSync("rsa", { modulusLength: 16385 }), + ), + dh: attempt(() => + crypto.generateKeyPairSync("dh", { prime: new Uint8Array(1025) }), + ), + }; + const order = []; + result.callback = await new Promise((resolve) => { + crypto.scrypt("p", "s", 8, options[0], (error) => { + order.push("callback"); + resolve({ + name: error.name, + code: error.code ?? null, + message: error.message, + }); + }); + order.push("returned"); + }); + result.order = order; + return result; + }, + cryptoKeygenContracts: async function ({ crypto, util }) { + const attempt = (fn) => { + try { + return fn(); + } catch (error) { + return { + name: error.name, + code: error.code ?? null, + message: error.message, + }; + } + }; + const errors = [ + ["dh", {}], + ["dh", { prime: "p" }], + ["dh", { primeLength: -1 }], + ["dh", { primeLength: 512 }], + ["dh", { generator: -1 }], + ["dh", { group: "modp14", primeLength: 512 }], + ["dh", { group: "modp14", prime: new Uint8Array([3]) }], + ["dh", { group: "modp14", generator: 2 }], + ["dh", { prime: new Uint8Array([3]), primeLength: 512 }], + ["dh", { group: "modp1" }], + ["dh", { group: "unknown" }], + ["ed25519", { publicKeyEncoding: "pem" }], + ["x25519", { privateKeyEncoding: "pem" }], + ["ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs1" } }], + [ + "rsa", + { + modulusLength: 1024, + privateKeyEncoding: { + format: "der", + type: "pkcs1", + cipher: "aes-128-cbc", + passphrase: "pw", + }, + }, + ], + [ + "ec", + { + namedCurve: "prime256v1", + privateKeyEncoding: { + format: "pem", + type: "pkcs8", + cipher: "aes-128-cbc", + }, + }, + ], + ].map(([type, options]) => + attempt(() => { + crypto.generateKeyPairSync(type, options); + return "accepted"; + }), + ); + const rsaBoundaries = [0, 1, 16, 255, 256, 511].map((modulusLength) => + attempt(() => { + crypto.generateKeyPairSync("rsa", { modulusLength }); + return "accepted"; + }), + ); + const rsaRounded = [513, 520, 639, 640, 1023].map( + (modulusLength) => + crypto.generateKeyPairSync("rsa", { modulusLength }).privateKey + .asymmetricKeyDetails.modulusLength, + ); + const rsaExponents = [0, 1, 2, 4].map((publicExponent) => + attempt(() => { + crypto.generateKeyPairSync("rsa", { + modulusLength: 512, + publicExponent, + }); + return "accepted"; + }), + ); + const smallDh = [0, 1, 2, 3, 5, 23, 37, 255].map((p) => + attempt(() => { + const { publicKey, privateKey } = crypto.generateKeyPairSync("dh", { + prime: new Uint8Array([p]), + generator: 2, + }); + return [ + publicKey.asymmetricKeyType, + privateKey.asymmetricKeyType, + crypto.createPublicKey(privateKey).type, + attempt( + () => publicKey.export({ type: "spki", format: "der" }).length > 0, + ), + attempt( + () => + privateKey.export({ type: "pkcs8", format: "der" }).length > 0, + ), + ]; + }), + ); + const curves = [ + "P-224", + "P-256", + "P-384", + "P-521", + "prime256v1", + "prime256v1\0suffix", + "secp256r1", + "secp256k1", + "PRIME256V1", + ].map((namedCurve) => [ + namedCurve, + attempt( + () => + crypto.generateKeyPairSync("ec", { namedCurve }).publicKey + .asymmetricKeyDetails, + ), + ]); + const ecEncoding = ["named", "explicit"].map((paramEncoding) => { + const { privateKey } = crypto.generateKeyPairSync("ec", { + namedCurve: "P-256", + paramEncoding, + }); + const data = privateKey.export({ type: "sec1", format: "der" }); + return { + paramEncoding, + length: data.length, + curveParameter: data + .toString("hex") + .includes("a00a06082a8648ce3d030107"), + }; + }); + const alice = crypto.generateKeyPairSync("dh", { group: "MODP14" }); + const prime = crypto.getDiffieHellman("modp14").getPrime(), + padded = new Uint8Array(prime.length + 4); + padded.set(prime, 2); + const bob = crypto.generateKeyPairSync("dh", { + prime: new DataView(padded.buffer, 2, prime.length), + generator: 2, + }); + const a = crypto.diffieHellman({ + privateKey: alice.privateKey, + publicKey: bob.publicKey, + }), + b = crypto.diffieHellman({ + privateKey: bob.privateKey, + publicKey: alice.publicKey, + }); + const mismatchedDh = [ + crypto.generateKeyPairSync("dh", { group: "modp5" }), + crypto.generateKeyPairSync("dh", { prime, generator: 5 }), + ].map((peer) => + attempt( + () => + crypto.diffieHellman({ + publicKey: alice.publicKey, + privateKey: peer.privateKey, + }).length, + ), + ); + const clonedDh = crypto + .diffieHellman({ + publicKey: crypto.createPublicKey(bob.privateKey), + privateKey: alice.privateKey, + }) + .equals(a); + const pair = await util.promisify(crypto.generateKeyPair)("ed25519", {}); + return { + errors, + curves, + ecEncoding, + rsaBoundaries, + rsaExponents, + rsaRounded, + smallDh, + mismatchedDh, + clonedDh, + dh: { + type: alice.privateKey.asymmetricKeyType, + length: a.length, + equal: a.toString("hex") === b.toString("hex"), + }, + promisify: [pair.publicKey.type, pair.privateKey.type], + }; + }, + + cryptoCipherAlgorithms: async function ({ crypto }) { + const results = []; + for (const name of [ + ...new Set( + crypto.getCiphers().flatMap((name) => [name, name.toUpperCase()]), + ), + ].sort()) + for (const authLengthTag of [undefined, 16, 8, 4, 28]) { + const info = crypto.getCipherInfo(name), + result = { name, authLengthTag: authLengthTag ?? null }; + let stage = "construct"; + try { + const key = new Uint8Array(info.keyLength), + iv = new Uint8Array(info.ivLength); + const cipher = crypto.createCipheriv(name, key, iv, { + authLengthTag, + }); + stage = "aad"; + try { + cipher.setAAD("aad", { plaintextLength: 5 }); + result.aad = true; + } catch (error) { + result.aad = error.message; + } + stage = "update"; + const first = cipher.update("hello", "utf8"); + stage = "final"; + const last = cipher.final(); + stage = "tag"; + const tag = cipher.getAuthTag(); + const ciphertext = new Uint8Array(first.length + last.length); + ciphertext.set(first); + ciphertext.set(last, first.length); + const hex = (value) => + Array.from(value, (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + result.data = name.toLowerCase().includes("randnonce") + ? ciphertext.length + : hex(ciphertext); + result.tag = name.toLowerCase().includes("randnonce") + ? tag.length + : hex(tag); + result.secondTag = cipher.getAuthTag().length; + stage = "decrypt"; + const decipher = crypto.createDecipheriv(name, key, iv, { + authLengthTag, + }); + if (tag.length) decipher.setAuthTag(tag); + if (result.aad === true) + decipher.setAAD("aad", { plaintextLength: 5 }); + result.plain = + decipher.update(ciphertext).toString() + + decipher.final().toString(); + if (tag.length) { + const invalid = crypto.createDecipheriv(name, key, iv, { + authLengthTag, + }); + const damaged = new Uint8Array(tag); + damaged[0] ^= 1; + invalid.setAuthTag(damaged); + if (result.aad === true) + invalid.setAAD("aad", { plaintextLength: 5 }); + try { + invalid.update(ciphertext); + invalid.final(); + result.tamper = "accepted"; + } catch (error) { + result.tamper = { + name: error.name, + code: error.code ?? null, + message: error.message.replace( + /internal error; reference = .*/, + "internal error", + ), + }; + } + } + } catch (error) { + result.error = { + stage, + name: error.name, + code: error.code ?? null, + message: error.message.replace( + /internal error; reference = .*/, + "internal error", + ), + }; + } + results.push(result); + } + return results; + }, + cryptoCipherStates: async function ({ crypto }) { + const attempt = (operation) => { + try { + const result = operation(); + return result?.byteLength ?? typeof result; + } catch (error) { + return { + name: error.name, + code: error.code ?? null, + message: error.message.replace( + /internal error; reference = .*/, + "internal error", + ), + }; + } + }; + const result = []; + for (const name of [ + "aes-128-cbc", + "aes-128-gcm", + "chacha20-poly1305", + "aes-128-eax", + ]) { + const info = crypto.getCipherInfo(name), + key = new Uint8Array(info.keyLength), + iv = new Uint8Array(info.ivLength); + const cipher = crypto.createCipheriv(name, key, iv, { + authLengthTag: 16, + }); + const row = { + name, + before: attempt(() => cipher.getAuthTag()), + stringInput: attempt(() => cipher.update("abc")), + aad: attempt(() => cipher.setAAD(new Uint8Array([1, 2, 3]))), + }; + row.update = attempt(() => cipher.update(new Uint8Array([1, 2, 3]))); + row.secondUpdate = attempt(() => cipher.update(new Uint8Array([4]))); + row.final = attempt(() => cipher.final()); + row.tag = attempt(() => cipher.getAuthTag()); + row.tagAgain = attempt(() => cipher.getAuthTag()); + row.finalAgain = attempt(() => cipher.final()); + row.updateAfterFinal = attempt(() => cipher.update(new Uint8Array())); + row.aadAfterFinal = attempt(() => cipher.setAAD(new Uint8Array())); + row.paddingAfterFinal = attempt(() => cipher.setAutoPadding()); + const decipher = crypto.createDecipheriv(name, key, iv, { + authLengthTag: 16, + }); + row.stringTag = attempt(() => decipher.setAuthTag("00", "hex")); + row.missingTag = attempt(() => + decipher.update(new Uint8Array([1, 2, 3])), + ); + row.setTag = attempt(() => decipher.setAuthTag(new Uint8Array(16))); + row.setTagAgain = attempt(() => decipher.setAuthTag(new Uint8Array(16))); + row.decryptFinal = attempt(() => decipher.final()); + result.push(row); + } + result.push({ + invalidNames: [ + "aes_128_eax", + "aes-128-eax\0suffix", + "AES-128-EAX", + "aes-128-cbc\0suffix", + "unknown", + ].map((name) => [ + name, + attempt(() => + crypto.createCipheriv(name, new Uint8Array(16), new Uint8Array(16), { + authLengthTag: 16, + }), + ), + ]), + }); + const changingKey = new Uint8Array(16); + const keyCopy = crypto.createCipheriv( + "aes-128-cbc", + changingKey, + new Uint8Array(16), + { + get authLengthTag() { + changingKey[0] = 1; + return 16; + }, + }, + ); + result.push({ + keyCopiedBeforeOptions: + keyCopy.update("hello", "utf8", "hex") + keyCopy.final("hex"), + }); + result.push({ + deprecated: ["Cipher", "Decipher", "createCipher", "createDecipher"].map( + (name) => [name, attempt(() => crypto[name]())], + ), + }); + return result; + }, + + cryptoPbkdfContracts: async function ({ crypto }) { + const attempt = (fn) => { + try { + return fn(); + } catch (error) { + return { name: error.name, code: error.code ?? null }; + } + }; + const result = { algorithms: [], typed: [], errors: [] }; + for (const name of crypto.getHashes()) + result.algorithms.push([ + name, + crypto.pbkdf2Sync("pass", "salt", 3, 20, name).toString("hex"), + ]); + for (const Constructor of [ + Uint8Array, + Uint16Array, + Uint32Array, + Float32Array, + Float64Array, + ]) { + const value = new Constructor(new ArrayBuffer(16)); + value.set([1, 2]); + result.typed.push([ + Constructor.name, + crypto + .pbkdf2Sync(value, new DataView(value.buffer, 2, 6), 2, 8, "sha256") + .toString("hex"), + ]); + } + // Half-float bytes are portable; the Float16Array input brand is tested + // only where the engine provides it, and native evidence records availability. + const halfBytes = new Uint8Array(16); + new DataView(halfBytes.buffer).setUint16(0, 0x3c00, true); + new DataView(halfBytes.buffer).setUint16(2, 0x4000, true); + result.halfFloatBytes = crypto + .pbkdf2Sync( + halfBytes, + new DataView(halfBytes.buffer, 2, 6), + 2, + 8, + "sha256", + ) + .toString("hex"); + if (typeof globalThis.Float16Array === "function") { + const half = new globalThis.Float16Array(8); + half.set([1, 2]); + const actual = crypto + .pbkdf2Sync(half, new DataView(half.buffer, 2, 6), 2, 8, "sha256") + .toString("hex"); + if (actual !== result.halfFloatBytes) + throw new Error( + "Float16Array PBKDF2 differs from its byte representation", + ); + } + for (const args of [ + ["p", "s", 0, 1, "sha1"], + ["p", "s", 1, 5101, "sha1"], + ["p", "s", 1, 8161, "sha256"], + ["p", "s", 1, 0, "sha256"], + ["p", "s", 1, 1, "unknown"], + ]) + result.errors.push( + attempt(() => crypto.pbkdf2Sync(...args).toString("hex")), + ); + const order = []; + result.callback = await new Promise((resolve) => { + crypto.pbkdf2("p", "s", 2, 8, "sha256", (error, value) => { + order.push("callback"); + resolve(error ? error.name : value.toString("hex")); + }); + order.push("returned"); + }); + result.order = order; + result.callbackError = await new Promise((resolve) => + crypto.pbkdf2("p", "s", 1, 1, "unknown", (error) => + resolve([error.name, error.code ?? null]), + ), + ); + const hex = (buffer) => + Array.from(new Uint8Array(buffer), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + result.hkdf = crypto + .getHashes() + .map((name) => [ + name, + hex( + crypto.hkdfSync( + name, + crypto.createSecretKey(new Uint8Array([1, 2, 3])), + "salt", + "info", + 10, + ), + ), + ]); + result.hkdfErrors = [ + attempt(() => crypto.hkdfSync("unknown", "p", "s", "i", 1)), + attempt(() => crypto.hkdfSync("sha1", "p", "s", "i", 5101)), + attempt(() => crypto.hkdfSync("sha1", "p", "s", new Uint8Array(1025), 1)), + ]; + return result; + }, + cryptoDigestContracts: async function ({ crypto }) { + const attempt = (fn) => { + try { + return fn(); + } catch (error) { + return { name: error.name, code: error.code ?? null }; + } + }; + const result = { names: crypto.getHashes(), algorithms: [], errors: [] }; + for (const name of result.names) { + const hash = crypto.createHash(name).update("a"); + const copy = hash.copy(); + result.algorithms.push({ + name, + hash: hash.update("bc").digest("hex"), + copy: copy.update("d").digest("hex"), + hmac: attempt(() => + crypto.createHmac(name, "key").update("abc").digest("hex"), + ), + }); + } + for (const name of [ + "ripemd160", + "sha-256", + "SHA", + "ssl3-md5", + "ssl3-sha1", + "SHA256", + ]) + result.errors.push([ + name, + attempt(() => crypto.createHash(name).digest("hex")), + ]); + for (const outputLength of [0, 28, 32, null, -1, "32"]) + result.errors.push( + attempt(() => + crypto.createHash("sha256", { outputLength }).digest("hex"), + ), + ); + const hash = crypto.createHash("sha256"); + hash.end("abc"); + result.stream = [hash.read().toString("hex"), hash.digest("hex")]; + result.final = [ + attempt(() => hash.copy()), + attempt(() => hash.update("x")), + attempt(() => hash.digest()), + ]; + const hmac = crypto.Hmac( + "sha256", + crypto.createSecretKey(new Uint8Array([1, 2, 3])), + ); + hmac.end("abc"); + result.hmacStream = [ + hmac.read().toString("hex"), + hmac.digest("hex"), + hmac.digest("hex"), + attempt(() => hmac.update("x")), + ]; + return result; + }, + fsIoContracts: async function ({ fs, process }) { + const path = "/tmp/io-contract", + result = {}; + fs.writeFileSync(path, "abc"); + const fd = fs.openSync(path, "a+"); + fs.writeFileSync(fd, "def"); + result.full = fs.readFileSync(fd, "utf8"); + result.after = fs.readSync(fd, new Uint8Array(1)); + fs.closeSync(fd); + fs.closeSync(fd); + fs.closeSync(123456); + const callbackFd = await new Promise((resolve, reject) => + fs.open(path, fs.constants.O_TRUNC, (error, fd) => + error ? reject(error) : resolve(fd), + ), + ); + fs.closeSync(callbackFd); + const promiseHandle = await fs.promises.open(path, fs.constants.O_TRUNC); + await promiseHandle.close(); + result.readonlyTruncate = fs.readFileSync(path, "utf8"); + process.chdir("/tmp"); + try { + result.cwd = process.cwd(); + fs.writeFileSync("relative-io", "data"); + result.relative = fs.readFileSync("/tmp/relative-io", "utf8"); + } finally { + process.chdir("/bundle"); + } + result.errors = []; + for (const call of [ + () => fs.writeSync(1, "x", ""), + () => fs.writeSync(1, new Uint8Array(1), { offset: 2 }), + () => fs.ftruncateSync(1, -1), + () => fs.appendFileSync(path, []), + () => fs.copyFile(path, path, 123), + () => fs.readFile(path), + ]) { + try { + call(); + result.errors.push(null); + } catch (error) { + result.errors.push(error.code); + } + } + result.constants = [ + Object.getPrototypeOf(fs.constants) === null, + fs.constants.UV_FS_COPYFILE_EXCL, + ]; + return result; + }, + fsStreamsAndLinks: async function ({ fs }) { + const root = "/tmp/streams-links"; + fs.mkdirSync(root); + const path = root + "/data"; + fs.writeFileSync(path, "abcdef"); + fs.symlinkSync(path, root + "/link"); + fs.cpSync(root + "/link", root + "/copy"); + const result = { + link: fs.readlinkSync(root + "/copy"), + text: fs.readFileSync(root + "/copy", "utf8"), + }; + fs.symlinkSync(root + "/missing", root + "/broken"); + fs.cpSync(root + "/broken", root + "/broken-copy"); + result.broken = fs.realpathSync(root + "/broken-copy"); + const handle = await fs.promises.open(path, "r"); + const stream = handle.createReadStream({ + start: 1, + end: 3, + encoding: "utf8", + }); + let text = ""; + for await (const chunk of stream) text += chunk; + result.read = [ + text, + handle.fd === undefined, + stream.bytesRead, + stream.closed, + ]; + const output = fs.createWriteStream(root + "/output"); + const finished = new Promise((resolve, reject) => { + output.on("close", resolve); + output.on("error", reject); + }); + output.cork(); + output.write("one"); + output.write("two"); + output.uncork(); + output.end("three"); + await finished; + result.write = [ + fs.readFileSync(root + "/output", "utf8"), + output.bytesWritten, + output.closed, + ]; + const webHandle = await fs.promises.open(path, "r"); + let webText = ""; + for await (const chunk of webHandle.readableWebStream()) + webText += new TextDecoder().decode(chunk); + result.web = [webText, webHandle.fd === undefined]; + const aborted = await fs.promises.open(path, "r"); + const closed = new Promise((resolve) => aborted.once("close", resolve)); + const abortedStream = aborted.createReadStream({ + signal: AbortSignal.abort(), + }); + result.abort = await new Promise((resolve) => + abortedStream.once("error", (error) => resolve(error.name)), + ); + await closed; + result.closed = aborted.fd === undefined; + fs.writeFileSync(root + "/%64ata%3f", "encoded"); + result.normalized = fs.readFileSync(root + "/data%3F", "utf8"); + return result; + }, + fsDevicesAndBounds: async function ({ fs }) { + const attempt = (fn) => { + try { + return fn() ?? null; + } catch (error) { + return { name: error.name, code: error.code ?? null }; + } + }; + const result = { devices: [], limits: [], times: [] }; + for (const name of ["null", "zero", "full", "random"]) { + const path = "/dev/" + name, + fd = fs.openSync(path, "r+"), + buffer = new Uint8Array(4).fill(7); + const read = fs.readSync(fd, buffer, 0, 4, null), + stat = fs.fstatSync(fd); + result.devices.push({ + name, + read, + buffer: name === "random" ? "entropy" : Array.from(buffer), + write: attempt(() => fs.writeSync(fd, "abc")), + truncate: attempt(() => fs.ftruncateSync(fd, 0)), + stat: [ + stat.mode, + stat.dev, + stat.size, + stat.isCharacterDevice(), + stat.isFile(), + stat.mtimeMs, + ], + all: fs.readFileSync(path).length, + }); + fs.closeSync(fd); + } + const path = "/tmp/bounds", + fd = fs.openSync(path, "w+"); + result.times.push(fs.fstatSync(fd).mtimeMs); + fs.writeSync(fd, "x"); + result.times.push(fs.fstatSync(fd).mtimeMs); + fs.futimesSync(fd, 123, 456); + fs.writeSync(fd, "y"); + result.times.push(fs.fstatSync(fd).mtimeMs); + result.limits.push( + attempt(() => fs.writeSync(fd, new Uint8Array(1), 0, 1, 134217728)), + ); + fs.closeSync(fd); + result.limits.push( + attempt(() => fs.openSync("/tmp/" + "a".repeat(4097), "r")), + ); + result.limits.push(attempt(() => fs.openSync("/a".repeat(49), "r"))); + const blob = fs.openAsBlob(path, { type: "text/plain" }); + result.blob = [ + blob instanceof Blob, + blob.size, + blob.type, + await blob.text(), + ]; + return result; + }, + fsDirectoryNavigation: async function ({ fs }) { + const root = "/tmp/navigation"; + const created = fs.mkdirSync(root + "/a/nested", { recursive: true }); + fs.mkdirSync(root + "/a/nested"); + for (const name of ["a/one", "a/.hidden", "a/nested/two"]) + fs.writeFileSync(root + "/" + name, "data"); + const describe = (entry) => ({ + name: + typeof entry.name === "string" ? entry.name : Array.from(entry.name), + parent: entry.parentPath, + file: entry.isFile(), + dir: entry.isDirectory(), + link: entry.isSymbolicLink(), + instance: entry instanceof fs.Dirent, + }); + const result = { + created, + listing: fs.readdirSync(root, { recursive: true }), + entries: fs + .readdirSync(root, { recursive: true, withFileTypes: true }) + .map(describe), + patterns: [], + }; + for (const pattern of [ + "**", + "a/*", + "a/**", + "a/{one,nested/**}", + "a/+(one|nested)", + "**/.hidden", + ["a/one", "a/nested/*"], + ]) + result.patterns.push(fs.globSync(pattern, { cwd: root })); + result.exclude = fs.globSync("**", { + cwd: root, + exclude: ["**/nested/**"], + }); + result.typed = fs + .globSync("a/*", { cwd: root, withFileTypes: true }) + .map(describe); + result.callback = await new Promise((resolve, reject) => + fs.glob("a/**", { cwd: root }, (e, value) => + e ? reject(e) : resolve(value), + ), + ); + result.promise = []; + for await (const value of fs.promises.glob("a/**", { cwd: root })) + result.promise.push(value); + const dir = await fs.promises.opendir(root, { recursive: true }); + result.iterated = []; + for await (const entry of dir) result.iterated.push(describe(entry)); + result.afterIteration = dir.readSync(); + await dir.close(); + try { + dir.readSync(); + } catch (error) { + result.closed = error.code; + } + result.encoded = fs.readdirSync(root, "hex"); + result.bundle = fs + .readdirSync("/bundle/nested", { withFileTypes: true }) + .map(describe); + for (const options of [ + { encoding: null }, + { recursive: 1 }, + { withFileTypes: 1 }, + ]) { + try { + fs.readdirSync(root, options); + result.patterns.push(null); + } catch (error) { + result.patterns.push(error.code); + } + } + return result; + }, + fsTimes: async function ({ fs }) { + const readonly = "/bundle/readonly.txt", + writable = "/tmp/time-file"; + fs.writeFileSync(writable, "x"); + const result = []; + for (const path of [readonly, writable]) { + fs.utimesSync(path, 1000, 2000); + result.push(fs.statSync(path).mtimeMs); + const fd = fs.openSync(path, "r"); + fs.futimesSync(fd, 3000n, new Date(4000)); + result.push(fs.fstatSync(fd).mtimeMs); + fs.closeSync(fd); + const handle = await fs.promises.open(path, "r"); + await handle.utimes(new Date(5000), 6000); + result.push((await handle.stat()).mtimeMs); + await handle.close(); + for (const method of ["writeFile", "truncate"]) { + try { + await handle[method](method === "writeFile" ? "x" : 0); + result.push(null); + } catch (error) { + result.push(error.code); + } + } + for (const value of [null, {}, true, undefined]) { + try { + fs.utimesSync(path, value, 0); + result.push(null); + } catch (e) { + result.push(e.code); + } + } + } + return result; + }, + cipherMetadata: async function ({ crypto: c }) { + const results = []; + for (const name of [ + "aes-128-cbc", + "AES-192-CTR", + "aes-256-gcm", + "des-ede3-cbc", + "rc2-cbc", + "rc4", + "chacha20-poly1305", + "aes-128-ccm-matter", + "unknown", + 419, + 419.8, + 950, + 0, + NaN, + ]) { + for (const options of [ + {}, + { ivLength: 0 }, + { ivLength: 1 }, + { ivLength: 16 }, + { ivLength: -1 }, + { ivLength: NaN }, + { ivLength: "12" }, + { keyLength: 0 }, + { keyLength: 1 }, + { keyLength: 16 }, + { keyLength: 32 }, + null, + 3, + ]) { + try { + results.push(c.getCipherInfo(name, options) ?? null); + } catch (e) { + results.push({ error: e.name, code: e.code ?? null }); + } + } + } + for (const input of [null, {}, true, [], undefined]) { + try { + results.push(c.getCipherInfo(input) ?? null); + } catch (e) { + results.push({ error: e.name, code: e.code ?? null }); + } + } + const first = c.getCipherInfo("aes-128-cbc"); + first.keyLength = 999; + results.push(c.getCipherInfo("aes-128-cbc")); + return results; + }, + codecLifecycle: async function ({ zlib: z }) { + const failure = (error) => ({ name: error.name, code: error.code ?? null }); + const result = { options: [], callbacks: [], lifecycle: [] }; + for (const name of ["brotli", "zstd"]) { + const Constructor = + z[name === "brotli" ? "BrotliCompress" : "ZstdCompress"]; + const compress = z[name + "CompressSync"]; + for (const options of [ + { chunkSize: null }, + { chunkSize: NaN }, + { chunkSize: 63 }, + { chunkSize: 64.5 }, + { flush: -1 }, + { flush: 6 }, + { finishFlush: -1 }, + { maxOutputLength: 0 }, + { maxOutputLength: 1 }, + { params: { 999: 1 } }, + { params: { 1: "bad" } }, + { params: { 1: 2, "01": 1 } }, + { params: null }, + null, + 3, + ]) { + for (const mode of ["fast", "info", "stream"]) { + try { + const value = + mode === "stream" + ? new Constructor(options) + : compress( + "hello", + mode === "info" ? { ...options, info: true } : options, + ); + value.destroy?.(); + result.options.push(null); + } catch (error) { + result.options.push(failure(error)); + } + } + } + for (const options of [ + { chunkSize: 63 }, + { chunkSize: NaN }, + { params: null }, + { params: { 999: 1 } }, + {}, + ]) { + let returned = false; + const pending = new Promise((resolve) => { + try { + z[name + "Compress"]("hello", options, (error) => + resolve({ + when: "callback", + returned, + error: error ? failure(error) : null, + }), + ); + } catch (error) { + resolve({ when: "throw", returned, error: failure(error) }); + } + }); + returned = true; + result.callbacks.push(await pending); + } + } + for (const name of [ + "Deflate", + "Inflate", + "Gzip", + "Gunzip", + "DeflateRaw", + "InflateRaw", + "Unzip", + "BrotliCompress", + "BrotliDecompress", + "ZstdCompress", + "ZstdDecompress", + ]) { + const stream = new z[name](); + stream.bytesRead = 7; + const before = [stream.bytesWritten, stream.bytesRead, stream._closed]; + stream.reset(); + const afterReset = [stream.bytesWritten, stream.bytesRead]; + let closes = 0; + stream.on("close", () => closes++); + const closed = new Promise((resolve) => + stream.close((error) => resolve(error?.code ?? null)), + ); + stream.destroy(); + const closeCode = await closed; + let resetError; + try { + stream.reset(); + } catch (error) { + resetError = failure(error); + } + result.lifecycle.push({ + name, + before, + afterReset, + closed: stream._closed, + destroyed: stream.destroyed, + closes, + closeCode, + resetError, + }); + } + const unzip = new z.Unzip(); + const decoded = []; + unzip.on("data", (chunk) => decoded.push(chunk.toString())); + const write = (input) => + new Promise((resolve, reject) => + unzip.write(input, (error) => (error ? reject(error) : resolve())), + ); + await write(z.deflateSync("first")); + unzip.reset(); + await write(z.gzipSync("second")); + await write(z.gzipSync("third")); + unzip.destroy(); + result.resetFormats = decoded.join(""); + return result; + }, + + fsEmbedded: async function ({ fs }) { + const path = "/bundle/readonly.txt"; + const attempt = (fn) => { + try { + return fn() ?? null; + } catch (error) { + return { code: error.code }; + } + }; + const fd = fs.openSync(path, "r"); + const buffer = new Uint8Array(3); + const read = fs.readSync(fd, buffer, 0, 3, 1); + const result = { + read, + buffer: Array.from(buffer), + mode: fs.fstatSync(fd).mode, + write: attempt(() => fs.writeSync(fd, "x")), + truncate: attempt(() => fs.ftruncateSync(fd, 0)), + }; + fs.closeSync(fd); + result.openWrite = attempt(() => fs.openSync(path, "r+")); + const handle = await fs.promises.open(path, "r"); + result.promiseRead = await handle.readFile("utf8"); + try { + await handle.truncate(0); + } catch (error) { + result.promiseTruncate = error.code; + } + await handle.close(); + fs.symlinkSync(path, "/tmp/readonly-link"); + result.link = fs.readlinkSync("/tmp/readonly-link"); + result.linkRead = fs.readFileSync("/tmp/readonly-link", "utf8"); + result.linkMode = fs.statSync("/tmp/readonly-link").mode; + result.linkAccess = attempt(() => + fs.accessSync("/tmp/readonly-link", fs.constants.W_OK), + ); + result.linkWrite = attempt(() => + fs.writeFileSync("/tmp/readonly-link", "changed"), + ); + let text = ""; + for await (const chunk of fs.createReadStream(path, { start: 1, end: 3 })) + text += chunk.toString(); + result.stream = text; + result.content = fs.readFileSync(path, "hex"); + fs.unlinkSync("/tmp/readonly-link"); + fs.linkSync(path, "/tmp/hard-link"); + result.hardRead = fs.readFileSync("/tmp/hard-link", "utf8"); + result.hardWrite = attempt(() => + fs.writeFileSync("/tmp/hard-link", "changed"), + ); + result.hardMetadata = [ + fs.statSync("/tmp/hard-link").nlink, + fs.statSync("/tmp/hard-link").ino, + fs.statSync("/tmp/hard-link").mode, + ]; + await fs.promises.cp("/bundle/nested", "/tmp/copied", { recursive: true }); + result.copied = fs.readFileSync("/tmp/copied/child.txt", "utf8"); + fs.writeFileSync("/tmp/copied/child.txt", "changed copy"); + result.unchanged = fs.readFileSync("/bundle/nested/child.txt", "utf8"); + result.filter = attempt(() => + fs.cpSync(path, "/tmp/filtered", { filter: () => true }), + ); + result.readonlyMutation = attempt(() => fs.writeFileSync(path, "changed")); + const big = fs.statSync(path, { bigint: true }); + result.bigintMetadata = [ + big.size, + big.mode, + big.ino, + big.nlink, + big.atimeNs, + ].map(String); + return result; + }, + + classicCodecs: async function ({ zlib: z }) { + const result = { roundtrips: [], validation: [], streams: [] }; + const failure = (error) => ({ name: error.name, code: error.code ?? null }); + const source = new Uint8Array([0, 1, 2, 3, 254, 255]); + for (const [encode, decode] of [ + ["gzip", "gunzip"], + ["deflate", "inflate"], + ["deflateRaw", "inflateRaw"], + ]) { + for (const input of [ + source, + new DataView(source.buffer, 1, 4), + source.buffer, + "hello", + ]) { + const compressed = z[encode + "Sync"](input); + result.roundtrips.push(Array.from(z[decode + "Sync"](compressed))); + const info = z[encode + "Sync"](input, { info: true }); + result.roundtrips.push({ + decoded: Array.from(z[decode + "Sync"](info.buffer)), + written: info.engine.bytesWritten, + closed: info.engine._closed, + }); + let returned = false; + const done = new Promise((resolve, reject) => + z[encode](input, (error, output) => + error + ? reject(error) + : resolve({ + async: returned, + decoded: Array.from(z[decode + "Sync"](output)), + }), + ), + ); + returned = true; + result.roundtrips.push(await done); + } + for (const info of [false, true]) { + try { + z[decode + "Sync"](z[encode + "Sync"]("output exceeds limit"), { + info, + maxOutputLength: 3, + }); + result.validation.push("unexpected success"); + } catch (error) { + result.validation.push(failure(error)); + } + } + const ctor = encode[0].toUpperCase() + encode.slice(1); + const engine = z["create" + ctor]({ chunkSize: 64 }); + const chunks = []; + const end = new Promise((resolve, reject) => { + engine.on("data", (chunk) => chunks.push(chunk)); + engine.on("error", reject); + engine.on("end", resolve); + }); + engine.write("first"); + await new Promise((resolve) => engine.flush(resolve)); + await new Promise((resolve) => + engine.params(1, z.constants.Z_DEFAULT_STRATEGY, resolve), + ); + engine.end("second"); + await end; + const compressed = new Uint8Array( + chunks.reduce((n, c) => n + c.length, 0), + ); + let offset = 0; + for (const chunk of chunks) { + compressed.set(chunk, offset); + offset += chunk.length; + } + result.streams.push({ + plain: new TextDecoder().decode(z[decode + "Sync"](compressed)), + written: engine.bytesWritten, + }); + } + for (const options of [ + { chunkSize: 63 }, + { chunkSize: NaN }, + { windowBits: 8 }, + { windowBits: 0 }, + { level: 10 }, + { level: null }, + { flush: 6 }, + { maxOutputLength: 0 }, + { maxOutputLength: 3 }, + ]) { + for (const kind of ["fast", "info", "stream"]) { + try { + const value = + kind === "stream" + ? z.createGzip(options) + : z.gzipSync("hello", { ...options, info: kind === "info" }); + value.destroy?.(); + result.validation.push(null); + } catch (error) { + result.validation.push(failure(error)); + } + } + } + const compressed = z.deflateSync("dictionary words", { + dictionary: new Uint8Array([1, 2, 3]), + }); + result.dictionary = Array.from( + z.inflateSync(compressed, { + dictionary: new DataView(new Uint8Array([1, 2, 3]).buffer), + }), + ); + result.unzip = Array.from(z.unzipSync(z.gzipSync(source))); + return result; + }, + + fsAccess: async function ({ fs }) { + fs.writeFileSync("/tmp/access-contract", "value"); + const results = []; + for (const path of ["/tmp/access-contract", "/tmp/access-missing"]) { + for (let mode = 0; mode < 8; mode++) { + let sync = null; + try { + fs.accessSync(path, mode); + } catch (error) { + sync = error.code; + } + let promise = null; + try { + await fs.promises.access(path, mode); + } catch (error) { + promise = error.code; + } + let returned = false; + const pending = new Promise((resolve) => + fs.access(path, mode, (error) => + resolve([error?.code ?? null, returned]), + ), + ); + returned = true; + results.push({ path, mode, sync, promise, callback: await pending }); + } + } + const invalid = []; + for (const mode of [-1, 8, 1.5, "4"]) { + let callbackCalled = false; + try { + fs.access("/tmp/access-contract", mode, () => { + callbackCalled = true; + }); + } catch (error) { + invalid.push([error.name, error.code, callbackCalled]); + } + } + const beforeDeletion = new Promise((resolve) => + fs.access("/tmp/access-contract", (error) => + resolve(error?.code ?? null), + ), + ); + fs.unlinkSync("/tmp/access-contract"); + const beforeCreation = new Promise((resolve) => + fs.access("/tmp/access-contract", (error) => + resolve(error?.code ?? null), + ), + ); + fs.writeFileSync("/tmp/access-contract", "created"); + return { + results, + invalid, + beforeDeletion: await beforeDeletion, + beforeCreation: await beforeCreation, + }; + }, x509: function ({ crypto: c }) { const pem = "-----BEGIN CERTIFICATE-----\nMIIDhDCCAmygAwIBAgIUdN1my4bhw3mAm+rrvYEZBzi4nuAwDQYJKoZIhvcNAQEL\nBQAwJjESMBAGA1UEAwwJbG9jYWxob3N0MRAwDgYDVQQKDAdXb3JrZXJzMB4XDTI2\nMTAyMjIzMTEyNVoXDTI2MTAyNDIzMTEyNVowJjESMBAGA1UEAwwJbG9jYWxob3N0\nMRAwDgYDVQQKDAdXb3JrZXJzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC\nAQEAmdA4QcozdOsYQJXG5oDzOJEKHIoyM1HDVXgH2ZWTL0Qm9yJJ02uV55IhW2AY\nshAs1ZROFCae5891g4ohl1H4sFDE5HYuD4BLkh9RY8QcZ7170nA1fnxNfO0K0GZs\nJyCOerlt5fLJjcEQiiyjbs8/2yukxHJraHQIZ1cO/PbWJx9O7iZ5Ru7u2ompDrsz\nzTbVgjI/u4g+sHEf+11me+7WW13Lj4FvpDHZp1K9zoMtOj/ruURVs/5wO3gygNnO\nVVrtNc/0nOU0Qqlqyr0QC9ZkHpn374hL2m0KC9BNixbLdQ+Oy4O406UhQXUGOSPg\ndfN3MBgPJrs1yZ1Io6pL41LWXwIDAQABo4GpMIGmMB0GA1UdDgQWBBSlprvE05R3\nZRC3gulykkeMzbOIZzAPBgNVHRMBAf8EBTADAQH/MFUGA1UdEQROMEyCCWxvY2Fs\naG9zdIIOKi5leGFtcGxlLnRlc3SHBH8AAAGBEXRlc3RAZXhhbXBsZS50ZXN0hhZo\ndHRwczovL2V4YW1wbGUudGVzdC9hMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEF\nBQcDAjANBgkqhkiG9w0BAQsFAAOCAQEAWO4DydLOPOBNyKYw50dBT/UgF9l7zvk+\nl2C7v1dMVCtpKW4egHUjINf+tOHC7PLjMMSKDvM/NGszAePPRSAJzfvoIEF5nahz\nLAzcM3zIdp7vviP1yeSSgJH5QRvo4pMV13K4FHARR5L/ShZqEKvKbVwDCrzymkND\nV5SGa11p5xf0Fm6061vfgBQ85vNFANXByqznGhn9tBRf5pJGBl4cjTJ9WCl8zVCs\n++24wsetgXwxL9O1p+NvfM8SdTVGBSevAA/OAc2Fjoye4oJHH4n3sCsIxoEeRnb4\nZYhC9Q4UwNucHDFJASAMWC5AfX7P+HnaguZXGHsfBrw/39sZFdncwg==\n-----END CERTIFICATE-----\n"; @@ -124,6 +5613,26 @@ export const eventCases = { }; return { nulNames, + receiverChecks: [ + () => + Object.getOwnPropertyDescriptor( + c.X509Certificate.prototype, + "subject", + ).get.call({}), + () => + c.X509Certificate.prototype.checkHost.call({}, "valid.example.test"), + ].map((fn) => { + try { + return fn() ?? null; + } catch (error) { + return { name: error.name, code: error.code ?? null }; + } + }), + cache: { + raw: cert.raw === cert.raw, + publicKey: cert.publicKey === cert.publicKey, + legacy: cert.toLegacyObject() === cert.toLegacyObject(), + }, subject: cert.subject, san: cert.subjectAltName, info: cert.infoAccess, @@ -134,6 +5643,13 @@ export const eventCases = { attempt(() => cert.checkHost("café.example.test", o)), ), invalid: [ + () => cert.checkEmail("test@example.test", { subject: "invalid" }), + () => + cert.checkHost("valid.example.test", { + get subject() { + throw new Error("option-getter"); + }, + }), () => cert.checkEmail("a\0b"), () => cert.checkIP("127.0.0.1\0evil"), () => cert.checkHost("valid.example.test", { partialWildcards: 1 }), @@ -1219,12 +6735,39 @@ export const eventCases = { dir = "/tmp/cf-promises"; await p.mkdir(dir, { recursive: true }); try { + const writes = []; + for (const data of [ + "abc", + new Uint8Array([1, 2]), + new DataView(new Uint8Array([3, 4]).buffer), + ]) { + for (const name of ["writeFile", "appendFile"]) { + const path = dir + "/writes"; + writes.push([name, typeof (await p[name](path, data))]); + const fd = fs.openSync(path, "w+"); + try { + writes.push([name + "Fd", typeof (await p[name](fd, data))]); + } finally { + fs.closeSync(fd); + } + } + } + const syncWrites = [ + fs.writeFileSync(dir + "/sync", "abc"), + fs.appendFileSync(dir + "/sync", "d"), + ]; const handle = await p.open(dir + "/file", "w+"); try { - await handle.writeFile("abc"); + const handleWrite = await handle.writeFile("abc"); const buffer = new Uint8Array(3); const read = await handle.read(buffer, 0, 3, 0); return { + writes, + syncWrites, + handleWrite: { + bytesWritten: handleWrite.bytesWritten, + buffer: Array.from(handleWrite.buffer), + }, read: read.bytesRead, buffer: Array.from(buffer), size: (await handle.stat()).size, diff --git a/src/compat/fs/LICENSE-3RD-PARTY.md b/src/compat/fs/LICENSE-3RD-PARTY.md new file mode 100644 index 0000000..5af4647 --- /dev/null +++ b/src/compat/fs/LICENSE-3RD-PARTY.md @@ -0,0 +1,11 @@ +# Upstream notices + +This module ports Cloudflare workerd tag `v1.20261012.1`, revision +`5358e3d337336eaf696a48bc1563d808b10feba5`: + +- `src/node/_tls_common.ts` +- `src/node/internal/internal_tls_common.ts` + +Copyright Cloudflare, Inc.; Apache License 2.0. Node.js/Joyent MIT notices +are preserved in the source files. Imports, resource-management symbols and +host constructor references are adapted for the Capacitor runtime. diff --git a/src/compat/fs/LICENSE-APACHE b/src/compat/fs/LICENSE-APACHE new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/src/compat/fs/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/src/compat/fs/directory-backend.ts b/src/compat/fs/directory-backend.ts new file mode 100644 index 0000000..6edd0dc --- /dev/null +++ b/src/compat/fs/directory-backend.ts @@ -0,0 +1,65 @@ +import { temporary, embeddedStat, pathOf } from "./writable.js"; +export interface DirEntryHandle { + name: string; + parentPath: string; + type: number; +} +export function readDirectory( + path: unknown, + recursive: boolean, +): DirEntryHandle[] { + const normalized = pathOf(path); + const list = (directory: string): DirEntryHandle[] => { + if (directory === "/") + return ["bundle", "tmp", "dev"].map((name) => ({ + name, + parentPath: "/", + type: 2, + })); + if (directory === "/dev") + return ["null", "zero", "full", "random"].map((name) => ({ + name, + parentPath: "/dev", + type: 6, + })); + embeddedStat(directory); + return temporary + .readdirSync(directory, { withFileTypes: true, encoding: "buffer" }) + .map((entry: any) => ({ + name: String(entry.name), + parentPath: directory, + type: entry.isDirectory() + ? 2 + : entry.isFile() + ? 1 + : entry.isSymbolicLink() + ? 3 + : entry.isFIFO() + ? 4 + : entry.isSocket() + ? 5 + : entry.isCharacterDevice() + ? 6 + : entry.isBlockDevice() + ? 7 + : 0, + })); + }; + const entries: DirEntryHandle[] = []; + const visit = (directory: string, prefix: string) => { + for (const entry of list(directory)) { + const name = prefix + entry.name; + entries.push({ ...entry, name }); + if (recursive && entry.type === 2) + visit( + (directory === "/" ? "" : directory) + "/" + entry.name, + name + "/", + ); + } + }; + visit( + normalized, + recursive && normalized !== "/" ? normalized.slice(1) + "/" : "", + ); + return entries; +} diff --git a/src/compat/fs/directory.spec.ts b/src/compat/fs/directory.spec.ts new file mode 100644 index 0000000..0541b4f --- /dev/null +++ b/src/compat/fs/directory.spec.ts @@ -0,0 +1,69 @@ +import { test, beforeEach } from "vitest"; +import { clearVirtualFiles, registerVirtualFiles } from "./index.js"; +beforeEach(() => { + clearVirtualFiles(); + registerVirtualFiles({ "/bundle/worker": "fixture module" }); +}); +import { runWithTemporaryFiles } from "./writable.js"; +// @ts-expect-error Upstream JavaScript fixture has no declaration file. +import * as cases from "./upstream-directory.fixture.mjs"; +for (const [name, fixture] of Object.entries(cases)) + test(`pinned Workers directories: ${name}`, () => + runWithTemporaryFiles(() => (fixture as { test: () => unknown }).test())); + +// @ts-expect-error Upstream JavaScript fixture has no declaration file. +import * as miscellaneous from "./upstream-misc.fixture.mjs"; +// @ts-expect-error Upstream JavaScript fixture has no declaration file. +import * as fileHandles from "./upstream-filehandle.fixture.mjs"; +for (const [group, cases] of Object.entries({ miscellaneous, fileHandles })) + for (const [name, fixture] of Object.entries(cases)) + test(`pinned Workers ${group}: ${name}`, () => + runWithTemporaryFiles(() => (fixture as { test: () => unknown }).test())); + +// @ts-expect-error Upstream JavaScript fixture has no declaration file. +import * as access from "./upstream-access.fixture.mjs"; +// @ts-expect-error Upstream JavaScript fixture has no declaration file. +import * as chown_chmod from "./upstream-chown-chmod.fixture.mjs"; +// @ts-expect-error Upstream JavaScript fixture has no declaration file. +import * as link from "./upstream-link.fixture.mjs"; +// @ts-expect-error Upstream JavaScript fixture has no declaration file. +import * as stat from "./upstream-stat.fixture.mjs"; +// @ts-expect-error Upstream JavaScript fixture has no declaration file. +import * as utimes from "./upstream-utimes.fixture.mjs"; +// @ts-expect-error Upstream JavaScript fixture has no declaration file. +import * as readsync_byteoffset from "./upstream-readsync-byteoffset.fixture.mjs"; +for (const [group, cases] of Object.entries({ + access, + chown_chmod, + link, + stat, + utimes, + readsync_byteoffset, +})) + for (const [name, fixture] of Object.entries(cases)) + test(`pinned Workers ${group}: ${name}`, () => + runWithTemporaryFiles(() => (fixture as { test: () => unknown }).test())); + +// @ts-expect-error Upstream JavaScript fixture has no declaration file. +import * as cp from "./upstream-cp.fixture.mjs"; +// @ts-expect-error Upstream JavaScript fixture has no declaration file. +import * as cp_into_subdirectory from "./upstream-cp-into-subdirectory.fixture.mjs"; +// @ts-expect-error Upstream JavaScript fixture has no declaration file. +import * as readstream from "./upstream-readstream.fixture.mjs"; +// @ts-expect-error Upstream JavaScript fixture has no declaration file. +import * as writestream from "./upstream-writestream.fixture.mjs"; +for (const [group, cases] of Object.entries({ + cp, + cp_into_subdirectory, + readstream, + writestream, +})) + for (const [name, fixture] of Object.entries(cases)) + test(`pinned Workers ${group}: ${name}`, () => + runWithTemporaryFiles(() => (fixture as { test: () => unknown }).test())); + +// @ts-expect-error Upstream JavaScript fixture has no declaration file. +import * as nodejs from "./upstream-nodejs.fixture.mjs"; +for (const [name, fixture] of Object.entries(nodejs)) + test(`pinned Workers nodejs: ${name}`, () => + runWithTemporaryFiles(() => (fixture as { test: () => unknown }).test())); diff --git a/src/compat/fs/directory.ts b/src/compat/fs/directory.ts new file mode 100644 index 0000000..0c84462 --- /dev/null +++ b/src/compat/fs/directory.ts @@ -0,0 +1,186 @@ +// Copyright (c) 2026 Cloudflare, Inc. Licensed under Apache-2.0. +// Ported from workerd@5358e3d337336eaf696a48bc1563d808b10feba5 internal_fs.ts. +import { Buffer } from "node:buffer"; +import { + validateFunction, + validateObject, + validateString, +} from "../net/validators.js"; +import { ERR_MISSING_ARGS } from "../net/errors.js"; +import type { DirEntryHandle } from "./directory-backend.js"; +type FilePath = string | Buffer | URL; +type ValidEncoding = BufferEncoding | "buffer"; +const UV_DIRENT_FILE = 1, + UV_DIRENT_DIR = 2, + UV_DIRENT_LINK = 3, + UV_DIRENT_FIFO = 4, + UV_DIRENT_SOCKET = 5, + UV_DIRENT_CHAR = 6, + UV_DIRENT_BLOCK = 7; +class ERR_DIR_CLOSED extends Error { + code = "ERR_DIR_CLOSED"; + constructor() { + super("Directory handle was closed"); + } +} +const kType = Symbol("type"); + +export class Dirent { + name: string | Buffer; + parentPath: string | Buffer; + private [kType]: number; + + constructor(name: string | Buffer, type: number, path: string | Buffer) { + this.name = name; + this.parentPath = path; + this[kType] = type; + } + + isDirectory(): boolean { + return this[kType] === UV_DIRENT_DIR; + } + + isFile(): boolean { + return this[kType] === UV_DIRENT_FILE; + } + + isBlockDevice(): boolean { + return this[kType] === UV_DIRENT_BLOCK; + } + + isCharacterDevice(): boolean { + return this[kType] === UV_DIRENT_CHAR; + } + + isSymbolicLink(): boolean { + return this[kType] === UV_DIRENT_LINK; + } + + isFIFO(): boolean { + return this[kType] === UV_DIRENT_FIFO; + } + + isSocket(): boolean { + return this[kType] === UV_DIRENT_SOCKET; + } +} + +export interface DirOptions { + encoding?: ValidEncoding | undefined; +} + +export type DirentReadCallback = ( + err: Error | null, + dirent: Dirent | null, +) => void; + +export class Dir { + // Unlike our Node.js counterpart, we do not not use an underlying + // native handle for the directory. Instead we use a simple array + // of DirEntryHandle objects just like the one used in the readdir + // API. + #handle: DirEntryHandle[] | undefined; + #encoding: ValidEncoding | undefined; + #path: FilePath; + + constructor( + handle: DirEntryHandle[] | undefined, + path: FilePath, + options: DirOptions, + ) { + if (handle == null) { + throw new ERR_MISSING_ARGS("handle"); + } + this.#handle = handle; + this.#path = path; + + validateObject(options, "options"); + const { encoding = "utf8" } = options; + validateString(encoding, "options.encoding"); + this.#encoding = encoding as BufferEncoding; + } + + get path(): FilePath { + return this.#path; + } + + read(): Promise; + read(callback: DirentReadCallback): void; + read(callback?: DirentReadCallback): Promise | undefined { + if (typeof callback === "function") { + validateFunction(callback, "callback"); + try { + const ent = this.readSync(); + queueMicrotask(() => { + callback(null, ent); + }); + } catch (err) { + queueMicrotask(() => { + callback(err as Error, null); + }); + } + return; + } + + try { + const ent = this.readSync(); + return Promise.resolve(ent); + } catch (err: unknown) { + return Promise.reject(err as Error); + } + } + + readSync(): Dirent | null { + if (this.#handle === undefined) throw new ERR_DIR_CLOSED(); + const ent = this.#handle.shift(); + if (ent == null) return null; + const buf = Buffer.from(ent.name); + if (this.#encoding === "buffer") { + return new Dirent(buf, ent.type, ent.parentPath); + } + return new Dirent( + buf.toString(this.#encoding != null ? this.#encoding : undefined), + ent.type, + ent.parentPath, + ); + } + + close(callback?: (err: unknown) => void): Promise | undefined { + this.closeSync(); + if (typeof callback === "function") { + validateFunction(callback, "callback"); + queueMicrotask(() => { + callback(null); + }); + return undefined; + } + return Promise.resolve(); + } + + closeSync(): void { + if (this.#handle === undefined) { + throw new ERR_DIR_CLOSED(); + } + this.#handle = undefined; + } + + async *entries(): AsyncGenerator { + for (;;) { + const ent = await this.read(); + if (ent == null) return; + yield ent; + } + } + + [Symbol.asyncIterator](): AsyncGenerator { + return this.entries(); + } + + async [Symbol.asyncDispose](): Promise { + await this.close(); + } + + [Symbol.dispose](): void { + this.closeSync(); + } +} diff --git a/src/compat/fs/embedded.ts b/src/compat/fs/embedded.ts index d813619..d6f0800 100644 --- a/src/compat/fs/embedded.ts +++ b/src/compat/fs/embedded.ts @@ -4,6 +4,7 @@ // surface. Mutating calls and real device filesystem access are not // provided. +import { Buffer } from "node:buffer"; const encoder = new TextEncoder(); const files = new Map(); @@ -136,7 +137,7 @@ export class Stats { return this.#mtime.getTime(); } get mode() { - return this.#directory ? 0o40755 : 0o100644; + return this.#directory ? 0o40444 : 0o100444; } get dev() { return 0; @@ -174,6 +175,7 @@ function statFor(path: string, syscall: string): Stats { function toPath(input: unknown): string { if (input instanceof URL) return normalize(input.href); + if (Buffer.isBuffer(input)) return normalize(input.toString()); if (typeof input !== "string" && !(input instanceof String)) throw new TypeError("path must be a string or URL"); return normalize(String(input)); @@ -193,7 +195,9 @@ export function readFileSync(path: unknown, options?: unknown): unknown { typeof options === "string" ? options : (options as { encoding?: string } | undefined)?.encoding; - return encoding ? decode(data, encoding) : new Uint8Array(data); + return encoding + ? Buffer.from(data).toString(encoding as BufferEncoding) + : Buffer.from(data); } export function readFile( diff --git a/src/compat/fs/file-handle.spec.ts b/src/compat/fs/file-handle.spec.ts new file mode 100644 index 0000000..604bdd4 --- /dev/null +++ b/src/compat/fs/file-handle.spec.ts @@ -0,0 +1,86 @@ +import { expect, test } from "vitest"; +import * as fs from "./index.js"; +import { runWithTemporaryFiles } from "./writable.js"; + +test("FileHandle owns its events, methods and readonly descriptor", async () => { + await runWithTemporaryFiles(async () => { + fs.writeFileSync("/tmp/handle-one", "one"); + fs.writeFileSync("/tmp/handle-two", "second"); + const a = await fs.promises.open("/tmp/handle-one", "r"), + b = await fs.promises.open("/tmp/handle-two", "r"); + try { + expect(a.read).toBe(a.read); + expect(a.read).toBe(Object.getPrototypeOf(a).read); + expect( + a.on("sample", function (this: unknown) { + expect(this).toBe(a); + }), + ).toBe(a); + a.emit("sample"); + expect((await a.stat.call(b)).size).toBe(6); + await expect(a.stat.call({})).rejects.toBeInstanceOf(TypeError); + const detached = a.stat; + await expect(detached()).rejects.toBeInstanceOf(TypeError); + expect(() => { + // @ts-expect-error The descriptor is readonly in the public API. + a.fd = 12345; + }).toThrow(TypeError); + expect(() => Reflect.construct(a.constructor, [])).toThrow( + "Illegal constructor", + ); + expect(() => a.readLines.call({ fd: a.fd })).toThrow(TypeError); + for (const key of ["fd", "fs", "refs", "closePromise", "read", "stat"]) + expect(Object.hasOwn(a, key)).toBe(false); + expect(Reflect.get(a, "ref")).toBeUndefined(); + expect(Reflect.get(a, "unref")).toBeUndefined(); + await a.close(); + expect(a.fd).toBeUndefined(); + await expect(a.stat()).rejects.toMatchObject({ + code: "EBADF", + message: "Bad file descriptor", + }); + let count = 0; + a.on("close", () => count++); + await a.close(); + expect(count).toBe(1); + } finally { + await a.close(); + await b.close(); + } + }); +}); +test("FileHandle operations retain the originating volume and result object contract", async () => { + const handle = await runWithTemporaryFiles(async () => { + fs.writeFileSync("/tmp/owned", "abc"); + return fs.promises.open("/tmp/owned", "r+"); + }); + try { + const bytes = new Uint8Array(3), + read = await handle.read(bytes, 0, 3, 0); + expect(Object.getPrototypeOf(read)).toBe(Object.prototype); + expect(read.buffer).toBe(bytes); + expect(Array.from(bytes)).toEqual([97, 98, 99]); + const written = await handle.writeFile("xy"); + expect(written.bytesWritten).toBe(2); + expect(Buffer.from(written.buffer).toString()).toBe("xy"); + expect(await handle.readFile("utf8")).toBe("xy"); + } finally { + await handle.close(); + } +}); + +test("external close cannot let an old handle close a new descriptor", async () => { + await runWithTemporaryFiles(async () => { + fs.writeFileSync("/tmp/lease", "abc"); + const old = await fs.promises.open("/tmp/lease", "r+"), + fd = old.fd; + fs.closeSync(fd); + const current = await fs.promises.open("/tmp/lease", "r+"); + expect(current.fd).not.toBe(fd); + expect(current.constructor).toBe(fs.promises.FileHandle); + await expect(old.stat()).rejects.toMatchObject({ code: "EBADF" }); + await old.close(); + expect((await current.stat()).size).toBe(3); + await current.close(); + }); +}); diff --git a/src/compat/fs/file-handle.ts b/src/compat/fs/file-handle.ts new file mode 100644 index 0000000..4e7aee5 --- /dev/null +++ b/src/compat/fs/file-handle.ts @@ -0,0 +1,329 @@ +import type { ReadStreamOptions, WriteStreamOptions } from "./streams.js"; +import type { + ReadOptions, + ReadResult, + WriteResult, + ReadFileOptions, + WriteFileOptions, +} from "./public-types.js"; +import type { Stats, BigIntStats, ReadStream, WriteStream } from "node:fs"; +// FileHandle's public contract follows workerd 5358e3d337336eaf696a48bc1563d808b10feba5. +// The filesystem backend and its request scope remain private. +import { Buffer } from "node:buffer"; +import { EventEmitter } from "node:events"; +import { ReadableStream } from "../stream-web/runtime.js"; +import { + asyncDispose, + installResourceSymbols, +} from "../../shared/globals/symbols.js"; +import { + validateObject, + validateBoolean, + validateUint32, +} from "../net/validators.js"; +import { validatePosition } from "./io-validation.js"; +import { createReadStream, createWriteStream } from "./streams.js"; +import { registerFileHandle } from "./stream-utils.js"; +installResourceSymbols(globalThis.Symbol); +const badge = Symbol("FileHandle constructor"); +const kFileHandle = Symbol("kFileHandle"); +type Scope = (operation: () => T) => T; +type Backend = { + fd: number; + run: Scope; + api: Record any>; +}; +const handleScopes = new WeakMap(); +const released = new FinalizationRegistry<() => void>((close) => close()); +function badDescriptor() { + return Object.assign(new Error("Bad file descriptor"), { + code: "EBADF", + syscall: "stat", + }); +} +export class FileHandle extends EventEmitter { + #fd: number | undefined; + #backend: Backend | undefined; + [kFileHandle] = true; + constructor(token: symbol, backend: Backend) { + if (token !== badge) throw new TypeError("Illegal constructor"); + super(); + this.#fd = backend.fd; + this.#backend = backend; + released.register( + this, + () => backend.run(() => backend.api.closeSync(backend.fd)), + this, + ); + registerFileHandle(this); + handleScopes.set(this, backend.run); + } + get fd() { + return this.#fd; + } + #invoke(name: string, ...args: any[]) { + if (this.#fd === undefined) throw badDescriptor(); + const fd = this.#fd; + const backend = this.#backend!; + return backend.run(() => backend.api[name](fd, ...args)); + } + async appendFile( + data: string | ArrayBufferView, + options: WriteFileOptions | BufferEncoding = {}, + ) { + await this.#invoke("appendFileSync", data, options); + } + async chmod(mode: string | number) { + await this.#invoke("fchmodSync", mode); + } + async chown(uid: number, gid: number) { + await this.#invoke("fchownSync", uid, gid); + } + async datasync() { + await this.#invoke("fdatasyncSync"); + } + async sync() { + await this.#invoke("fsyncSync"); + } + read(options?: ReadOptions): Promise>; + read( + options: ReadOptions & { buffer: T }, + ): Promise>; + read( + buffer: T, + offsetOrOptions?: number | ReadOptions, + length?: number, + position?: number | bigint | null, + ): Promise>; + read( + bufferOrOptions: any = {}, + offsetOrOptions: any = {}, + length?: number, + position: number | bigint | null = null, + ) { + try { + if (this.#fd === undefined) throw badDescriptor(); + let options: any; + if (ArrayBuffer.isView(bufferOrOptions)) + options = + typeof offsetOrOptions === "number" + ? { + buffer: bufferOrOptions, + offset: offsetOrOptions, + length: length ?? bufferOrOptions.byteLength - offsetOrOptions, + position: position ?? null, + } + : { buffer: bufferOrOptions, ...offsetOrOptions }; + else options = bufferOrOptions; + const { + buffer = Buffer.alloc(16384), + offset = 0, + length: count = buffer.byteLength - offset, + position: at = null, + } = options; + validateUint32(offset, "offset"); + validateUint32(count, "length"); + validatePosition(at, "position"); + const bytesRead = this.#invoke("readSync", buffer, offset, count, at); + return Promise.resolve({ bytesRead, buffer }); + } catch (error) { + return Promise.reject(error); + } + } + readv( + buffers: T, + position: number | bigint | null = null, + ): Promise<{ bytesRead: number; buffers: T }> { + try { + return Promise.resolve({ + bytesRead: this.#invoke("readvSync", buffers, position), + buffers, + }); + } catch (error) { + return Promise.reject(error); + } + } + readFile( + options: BufferEncoding | (ReadFileOptions & { encoding: BufferEncoding }), + ): Promise; + readFile( + options?: + | (ReadFileOptions & { encoding?: "buffer" | null }) + | "buffer" + | null, + ): Promise; + readFile( + options?: ReadFileOptions | BufferEncoding | "buffer" | null, + ): Promise; + async readFile(options: any = {}) { + return await this.#invoke("readFileSync", options); + } + readLines(_options: unknown = {}): never { + if (this.#fd === undefined) throw badDescriptor(); + throw new Error("not implemented"); + } + stat(options: { bigint: true }): Promise; + stat(options?: { bigint?: false }): Promise; + stat(options?: { bigint?: boolean }): Promise; + async stat(options: any = {}) { + return await this.#invoke("fstatSync", options); + } + async truncate(length = 0) { + await this.#invoke("ftruncateSync", length); + } + async utimes(atime: string | number | Date, mtime: string | number | Date) { + await this.#invoke("futimesSync", atime, mtime); + } + write( + buffer: T, + offsetPositionOrOptions?: + | number + | { offset?: number; length?: number; position?: number | bigint | null } + | null, + length?: number, + position?: number | bigint | null, + ): Promise>; + write( + buffer: string, + position?: number | bigint | null, + encoding?: BufferEncoding, + ): Promise>; + write( + buffer: T, + position?: number | null, + ): Promise>; + write( + buffer: string | ArrayBufferView, + offsetPositionOrOptions: any = null, + lengthOrEncoding?: any, + position: number | bigint | null = null, + ) { + try { + if (this.#fd === undefined) throw badDescriptor(); + if (typeof buffer === "string") + buffer = Buffer.from(buffer, lengthOrEncoding); + const bytesWritten = this.#invoke( + "writeSync", + buffer, + offsetPositionOrOptions, + lengthOrEncoding, + position, + ); + return Promise.resolve({ bytesWritten, buffer }); + } catch (error) { + return Promise.reject(error); + } + } + writev( + buffers: T, + position: number | bigint | null = null, + ): Promise<{ bytesWritten: number; buffers: T }> { + try { + return Promise.resolve({ + bytesWritten: this.#invoke("writevSync", buffers, position), + buffers, + }); + } catch (error) { + return Promise.reject(error); + } + } + writeFile( + data: T, + options?: WriteFileOptions | BufferEncoding, + ): Promise>; + writeFile( + data: string, + options?: WriteFileOptions | BufferEncoding, + ): Promise>; + writeFile( + data: T, + options?: WriteFileOptions | BufferEncoding, + ): Promise>; + writeFile(data: string | ArrayBufferView, options: any = {}) { + try { + const bytesWritten = this.#invoke("writeFileSync", data, options); + return Promise.resolve({ + bytesWritten, + buffer: ArrayBuffer.isView(data) ? data : Buffer.from(data, options), + }); + } catch (error) { + return Promise.reject(error); + } + } + close(): Promise { + try { + if (this.#fd !== undefined) this.#invoke("closeSync"); + this.#fd = undefined; + this.#backend = undefined; + handleScopes.delete(this); + released.unregister(this); + this.emit("close"); + return Promise.resolve(); + } catch (error) { + return Promise.reject(error); + } + } + async [asyncDispose]() { + await this.close(); + } + readableWebStream( + options: { autoClose?: boolean } = {}, + ): ReadableStream { + if (this.#fd === undefined) throw badDescriptor(); + validateObject(options, "options"); + const { autoClose = true } = options; + validateBoolean(autoClose, "options.autoClose"); + const handle = this; + const read = this.read.bind(this); + let controller: ReadableByteStreamController, + ended = false; + const finish = () => { + if (!ended) { + ended = true; + controller.close(); + } + }; + const stream = new ReadableStream({ + type: "bytes", + autoAllocateChunkSize: 16384, + start(c) { + controller = c; + }, + async pull(c) { + const request = c.byobRequest!, + view = request.view!; + const { bytesRead } = await read( + view, + view.byteOffset, + view.byteLength, + ); + if (bytesRead === 0) { + finish(); + if (autoClose) await handle.close(); + } + request.respond(bytesRead); + }, + async cancel() { + ended = true; + if (autoClose) await handle.close(); + }, + }); + this.once("close", finish); + return stream; + } + createReadStream(options: Omit = {}): ReadStream { + const run = handleScopes.get(this) ?? ((fn: () => any) => fn()); + return run(() => createReadStream("", { ...options, fd: this })); + } + createWriteStream(options: Omit = {}): WriteStream { + const run = handleScopes.get(this) ?? ((fn: () => any) => fn()); + return run(() => createWriteStream("", { ...options, fd: this })); + } +} +Object.defineProperty(FileHandle, "name", { + value: "FileHandle", + configurable: true, +}); +export function createFileHandle(backend: Backend) { + return new FileHandle(badge, backend); +} diff --git a/src/compat/fs/glob-internal.ts b/src/compat/fs/glob-internal.ts new file mode 100644 index 0000000..37d564c --- /dev/null +++ b/src/compat/fs/glob-internal.ts @@ -0,0 +1,556 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Ported from workerd@5358e3d337336eaf696a48bc1563d808b10feba5 internal_fs_glob.ts. +// Adapted filesystem boundary; pattern matching and traversal retain the pinned upstream implementation. +import { readDirectory } from "./directory-backend.js"; +import type { DirEntryHandle } from "./directory-backend.js"; +// UV_DIRENT_DIR constant from internal_fs_constants +const UV_DIRENT_DIR = 2; + +// Maximum recursion depth to prevent stack overflow on deeply nested VFS +const MAX_WALK_DEPTH = 256; + +// ============================================================================ +// Brace Expansion +// ============================================================================ + +// Splits a string by top-level occurrences of a separator character, +// respecting nested braces/parens and backslash escapes. +function splitTopLevel(str: string, sep: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ""; + + for (let i = 0; i < str.length; i++) { + const c = str.charAt(i); + if (c === "\\" && i + 1 < str.length) { + current += c + str.charAt(i + 1); + i++; + } else if (c === "{" || c === "(") { + depth++; + current += c; + } else if (c === "}" || c === ")") { + depth--; + current += c; + } else if (c === sep && depth === 0) { + parts.push(current); + current = ""; + } else { + current += c; + } + } + + parts.push(current); + return parts; +} + +// Expands brace expressions in a glob pattern into multiple patterns. +export function expandBraces(pattern: string): string[] { + let depth = 0; + let braceStart = -1; + + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i]; + if (c === "\\" && i + 1 < pattern.length) { + i++; + continue; + } + if (c === "{") { + if (depth === 0) braceStart = i; + depth++; + } else if (c === "}") { + depth--; + if (depth === 0 && braceStart !== -1) { + const prefix = pattern.slice(0, braceStart); + const body = pattern.slice(braceStart + 1, i); + const suffix = pattern.slice(i + 1); + + const alternatives = splitTopLevel(body, ","); + + if (alternatives.length === 1) { + return [pattern]; + } + + const results: string[] = []; + for (const alt of alternatives) { + for (const expanded of expandBraces(prefix + alt + suffix)) { + results.push(expanded); + } + } + return results; + } + } + } + + return [pattern]; +} + +// ============================================================================ +// Pattern Normalization +// ============================================================================ + +export function normalizePattern(pattern: string): string { + // Strip leading ./ + if (pattern.startsWith("./")) { + pattern = pattern.slice(2); + } + // Collapse multiple slashes to single + pattern = pattern.replace(/\/\/+/g, "/"); + // Strip trailing slash + if (pattern.endsWith("/") && pattern.length > 1) { + pattern = pattern.slice(0, -1); + } + return pattern; +} + +// ============================================================================ +// Segment-Level Regex (with extglob support) +// ============================================================================ + +// Converts a single path segment pattern to a RegExp. +// Supports: *, ?, [...], [!...], @(...), *(...), +(...), ?(...), !(...) +export function segmentToRegex(segment: string): RegExp { + const regex = segmentToRegexStr(segment); + return new RegExp("^" + regex + "$"); +} + +function segmentToRegexStr(segment: string): string { + let regex = ""; + let i = 0; + let inCharClass = false; + + while (i < segment.length) { + const c = segment[i] ?? ""; + + // Handle escape sequences + if (c === "\\" && i + 1 < segment.length) { + regex += "\\" + escapeRegexChar(segment[i + 1] ?? ""); + i += 2; + continue; + } + + // Inside character classes, most chars are literal + if (inCharClass) { + if (c === "]") { + regex += "]"; + inCharClass = false; + } else { + regex += c; + } + i++; + continue; + } + + // Check for extglob: @(...), *(...), +(...), ?(...), !(...) + if ( + (c === "@" || c === "*" || c === "+" || c === "?" || c === "!") && + i + 1 < segment.length && + segment[i + 1] === "(" + ) { + const closeIdx = findMatchingParen(segment, i + 1); + if (closeIdx !== -1) { + const inner = segment.slice(i + 2, closeIdx); + // Convert pipe-separated alternatives, each may contain glob chars + const alts = splitTopLevel(inner, "|"); + const altRegexes = alts.map((a) => segmentToRegexStr(a)); + const group = altRegexes.join("|"); + + switch (c) { + case "@": // exactly one + regex += "(?:" + group + ")"; + break; + case "*": // zero or more + regex += "(?:" + group + ")*"; + break; + case "+": // one or more + regex += "(?:" + group + ")+"; + break; + case "?": // zero or one + regex += "(?:" + group + ")?"; + break; + case "!": // none of (negative lookahead) + regex += "(?!(?:" + group + ")$)[^/]*"; + break; + } + + i = closeIdx + 1; + continue; + } + } + + switch (c) { + case "[": + inCharClass = true; + regex += "["; + if (i + 1 < segment.length && segment[i + 1] === "!") { + regex += "^"; + i++; + } + break; + + case "*": + regex += "[^/]*"; + break; + + case "?": + regex += "[^/]"; + break; + + case ".": + case "+": + case "^": + case "$": + case "|": + case "(": + case ")": + case "{": + case "}": + regex += "\\" + c; + break; + + default: + regex += c; + break; + } + i++; + } + + return regex; +} + +function findMatchingParen(str: string, openIdx: number): number { + let depth = 0; + for (let i = openIdx; i < str.length; i++) { + if (str[i] === "\\" && i + 1 < str.length) { + i++; + continue; + } + if (str[i] === "(") depth++; + else if (str[i] === ")") { + depth--; + if (depth === 0) return i; + } + } + return -1; +} + +function escapeRegexChar(c: string): string { + if (".+*?^$|()[]{}\\".includes(c)) { + return "\\" + c; + } + return c; +} + +// ============================================================================ +// Full-Path Regex (used for exclude pattern matching) +// ============================================================================ + +// Converts a full glob pattern (with /) to a single RegExp for exclude matching. +export function globToRegex(pattern: string): RegExp { + const normalized = normalizePattern(pattern); + const segments = normalized.split("/").filter((s) => s !== ""); + const parts: string[] = []; + + for (const seg of segments) { + if (seg === "**") { + // ** matches zero or more path segments + // We handle this by inserting a special marker + parts.push("**"); + } else if (seg === ".") { + // skip + } else if (seg === "..") { + parts.pop(); + } else { + parts.push(segmentToRegexStr(seg)); + } + } + + // Now build regex from parts, handling ** markers + let regex = ""; + for (let i = 0; i < parts.length; i++) { + const part = parts[i] ?? ""; + const prevPart = i > 0 ? (parts[i - 1] ?? "") : ""; + if (part === "**") { + if (parts.length === 1) { + // ** alone: match everything + regex = ".*"; + } else if (i === 0) { + // ** at start: match zero or more leading segments + regex += "(?:.*\\/)?"; + } else if (i === parts.length - 1) { + // ** at end: match zero or more trailing segments + regex += "(?:\\/.*)?"; + } else { + // ** in middle: match zero or more middle segments + regex += "(?:\\/[^/]+)*(?:\\/)?"; + } + } else { + if (i > 0 && prevPart !== "**") { + regex += "\\/"; + } + regex += part; + } + } + + return new RegExp("^" + regex + "$"); +} + +// ============================================================================ +// Exclude Compiler +// ============================================================================ + +// Compiles an array of glob patterns into an exclude function. +// Only accepts string arrays — user-provided functions are handled +// separately in globSync to preserve their (string | Dirent) signature. +export function compileExcludePatterns( + exclude: readonly string[], +): (path: string) => boolean { + const regexes: RegExp[] = []; + for (const pat of exclude) { + for (const expanded of expandBraces(pat)) { + regexes.push(globToRegex(expanded)); + } + } + + return (path: string): boolean => { + for (const re of regexes) { + if (re.test(path)) return true; + } + return false; + }; +} + +// ============================================================================ +// Directory Entry Cache +// ============================================================================ + +type EntryCache = Map; + +function getDirectoryEntries( + absPath: string, + cache: EntryCache, +): DirEntryHandle[] { + const cached = cache.get(absPath); + if (cached !== undefined) return cached; + + try { + const entries = readDirectory(absPath, false); + cache.set(absPath, entries); + return entries; + } catch { + const empty: DirEntryHandle[] = []; + cache.set(absPath, empty); + return empty; + } +} + +function isDirectory(entry: DirEntryHandle): boolean { + return entry.type === UV_DIRENT_DIR; +} + +// ============================================================================ +// Pattern-Driven Directory Walk +// ============================================================================ + +export interface GlobResult { + relativePath: string; + handle: DirEntryHandle | null; +} + +// Collapses consecutive '**' segments to prevent exponential blowup. +export function collapseGlobstars(segments: string[]): string[] { + const result: string[] = []; + for (const seg of segments) { + if (seg === "**" && result[result.length - 1] === "**") continue; + result.push(seg); + } + return result; +} + +// Precompiles segment regexes for all non-special segments. +export function precompileSegmentRegexes( + segments: string[], +): (RegExp | null)[] { + return segments.map((seg) => { + if (seg === "**" || seg === "." || seg === "..") return null; + return segmentToRegex(seg); + }); +} + +export function walkGlob( + cwd: string, + segments: string[], + segIdx: number, + currentAbsPath: string, + relativePath: string, + results: Map, + cache: EntryCache, + segmentRegexes: (RegExp | null)[], + visitedGlobstar?: Set, + depth: number = 0, +): void { + // Guard against excessive recursion depth + if (depth >= MAX_WALK_DEPTH) return; + + // All segments consumed: this path is a match + if (segIdx >= segments.length) { + if (!results.has(relativePath)) { + // Resolve handle for the matched path by reading the parent directory + const lastSlash = currentAbsPath.lastIndexOf("/"); + const parentDir = + lastSlash > 0 ? currentAbsPath.slice(0, lastSlash) : currentAbsPath; + const basename = relativePath.split("/").pop() ?? ""; + const entries = getDirectoryEntries(parentDir, cache); + const handle = entries.find((e) => e.name === basename) ?? null; + results.set(relativePath, { relativePath, handle }); + } + return; + } + + const seg = segments[segIdx] ?? ""; + + // Handle '.' — stay in current directory + if (seg === ".") { + walkGlob( + cwd, + segments, + segIdx + 1, + currentAbsPath, + relativePath, + results, + cache, + segmentRegexes, + visitedGlobstar, + depth, + ); + return; + } + + // Handle '..' — go up one directory (but don't escape cwd) + if (seg === "..") { + if (currentAbsPath === cwd || !currentAbsPath.startsWith(cwd + "/")) { + return; + } + + const lastSlash = currentAbsPath.lastIndexOf("/"); + const newAbs = lastSlash > 0 ? currentAbsPath.slice(0, lastSlash) : "/"; + + const relParts = relativePath.split("/").filter(Boolean); + relParts.pop(); + const newRel = relParts.join("/"); + + walkGlob( + cwd, + segments, + segIdx + 1, + newAbs, + newRel, + results, + cache, + segmentRegexes, + visitedGlobstar, + depth, + ); + return; + } + + // Handle '**' — match zero or more directory levels + if (seg === "**") { + const gsKey = `${currentAbsPath}:${String(segIdx)}`; + if (visitedGlobstar === undefined) { + visitedGlobstar = new Set(); + } + if (visitedGlobstar.has(gsKey)) return; + visitedGlobstar.add(gsKey); + + // Zero levels: advance to next segment at current path + walkGlob( + cwd, + segments, + segIdx + 1, + currentAbsPath, + relativePath, + results, + cache, + segmentRegexes, + visitedGlobstar, + depth, + ); + + // One or more levels: enumerate children + const entries = getDirectoryEntries(currentAbsPath, cache); + for (const entry of entries) { + const childAbs = currentAbsPath + "/" + entry.name; + const childRel = relativePath + ? relativePath + "/" + entry.name + : entry.name; + + // Try matching next segment against this child + walkGlob( + cwd, + segments, + segIdx + 1, + childAbs, + childRel, + results, + cache, + segmentRegexes, + visitedGlobstar, + depth + 1, + ); + + // If directory, recurse ** deeper + if (isDirectory(entry)) { + walkGlob( + cwd, + segments, + segIdx, + childAbs, + childRel, + results, + cache, + segmentRegexes, + visitedGlobstar, + depth + 1, + ); + } + } + return; + } + + // Regular segment: match against precompiled regex + const segRegex = segmentRegexes[segIdx] ?? segmentToRegex(seg); + const entries = getDirectoryEntries(currentAbsPath, cache); + + for (const entry of entries) { + if (segRegex.test(entry.name)) { + const childAbs = currentAbsPath + "/" + entry.name; + const childRel = relativePath + ? relativePath + "/" + entry.name + : entry.name; + + if (segIdx + 1 >= segments.length) { + // This is the last segment — record match + if (!results.has(childRel)) { + results.set(childRel, { relativePath: childRel, handle: entry }); + } + } else { + // More segments to match — recurse + walkGlob( + cwd, + segments, + segIdx + 1, + childAbs, + childRel, + results, + cache, + segmentRegexes, + visitedGlobstar, + depth + 1, + ); + } + } + } +} diff --git a/src/compat/fs/glob.spec.ts b/src/compat/fs/glob.spec.ts new file mode 100644 index 0000000..cc584d2 --- /dev/null +++ b/src/compat/fs/glob.spec.ts @@ -0,0 +1,7 @@ +import { test } from "vitest"; +import { runWithTemporaryFiles } from "./writable.js"; +// @ts-expect-error Upstream JavaScript fixture has no declaration file. +import * as cases from "./upstream-glob.fixture.mjs"; +for (const [name, fixture] of Object.entries(cases)) + test(`pinned Workers glob: ${name}`, () => + runWithTemporaryFiles(() => (fixture as { test: () => unknown }).test())); diff --git a/src/compat/fs/glob.ts b/src/compat/fs/glob.ts new file mode 100644 index 0000000..6840dad --- /dev/null +++ b/src/compat/fs/glob.ts @@ -0,0 +1,102 @@ +import { currentDirectory } from "./writable.js"; +// Copyright (c) 2026 Cloudflare, Inc. Licensed under Apache-2.0. +// Ported from workerd@5358e3d337336eaf696a48bc1563d808b10feba5 internal_fs_sync.ts. +import { validateString, validateObject } from "../net/validators.js"; +import { Dirent } from "./directory.js"; +import type { DirEntryHandle } from "./directory-backend.js"; +import { + expandBraces, + normalizePattern, + walkGlob, + compileExcludePatterns, + collapseGlobstars, + precompileSegmentRegexes, + type GlobResult, +} from "./glob-internal.js"; +type GlobOptions = { + cwd?: string | URL; + withFileTypes?: boolean; + exclude?: string[] | ((path: any) => boolean); +}; +type GlobOptionsWithFileTypes = GlobOptions; +type GlobOptionsWithoutFileTypes = GlobOptions; +export function globSync( + pattern: string | readonly string[], + options: + | GlobOptions + | GlobOptionsWithFileTypes + | GlobOptionsWithoutFileTypes = {}, +): string[] | Dirent[] { + // Normalize pattern to array + const patterns: string[] = + typeof pattern === "string" ? [pattern] : [...pattern]; + for (const p of patterns) { + validateString(p, "pattern"); + } + + if (typeof options !== "object" || options === null) { + validateObject(options, "options"); + } + + const cwdOption = (options as GlobOptions).cwd; + const cwd: string = + cwdOption instanceof URL + ? cwdOption.pathname + : ((cwdOption as string | undefined) ?? currentDirectory()); + validateString(cwd, "options.cwd"); + + const withFileTypes: boolean = + (options as GlobOptionsWithFileTypes).withFileTypes ?? false; + + // Exclude can be a user function or an array of glob patterns. + // Keep them as separate variables to preserve proper type signatures. + const excludeOption = (options as GlobOptions).exclude; + let excludeUserFn: ((path: string | Dirent) => boolean) | undefined; + let excludePatternFn: ((path: string) => boolean) | undefined; + if (typeof excludeOption === "function") { + excludeUserFn = excludeOption as (path: string | Dirent) => boolean; + } else if (Array.isArray(excludeOption)) { + excludePatternFn = compileExcludePatterns(excludeOption as string[]); + } + + // Pattern-driven directory walk + const results = new Map(); + const dirCache = new Map(); + + for (const p of patterns) { + for (const expanded of expandBraces(p)) { + const normalized = normalizePattern(expanded); + const segments = collapseGlobstars( + normalized.split("/").filter((s) => s !== ""), + ); + if (segments.length === 0) continue; + const segmentRegexes = precompileSegmentRegexes(segments); + walkGlob(cwd, segments, 0, cwd, "", results, dirCache, segmentRegexes); + } + } + + // Build final results, applying exclude filter + const stringResults: string[] = []; + const direntResults: Dirent[] = []; + + for (const [relPath, entry] of results) { + if (!relPath) continue; // skip empty paths + + if (excludePatternFn && excludePatternFn(relPath)) continue; + + if (withFileTypes) { + const parts = relPath.split("/"); + const name = parts.pop() ?? ""; + const parentPath = cwd + (parts.length ? "/" + parts.join("/") : ""); + const type = entry.handle?.type ?? 0; + const dirent = new Dirent(name, type, parentPath); + if (excludeUserFn && excludeUserFn(dirent)) continue; + direntResults.push(dirent); + } else { + if (excludeUserFn && excludeUserFn(relPath)) continue; + stringResults.push(relPath); + } + } + + return withFileTypes ? direntResults : stringResults; +} diff --git a/src/compat/fs/index.ts b/src/compat/fs/index.ts index 0b02148..20e62a6 100644 --- a/src/compat/fs/index.ts +++ b/src/compat/fs/index.ts @@ -1,19 +1,31 @@ +import type { FsPromises } from "./public-types.js"; +import { createFileHandle, FileHandle } from "./file-handle.js"; +import { installIo } from "./io.js"; +import * as fileStreams from "./streams.js"; +import { Dir as WorkersDir, Dirent as WorkersDirent } from "./directory.js"; +import { readDirectory } from "./directory-backend.js"; +import { globSync as workersGlob } from "./glob.js"; import { fileMode } from "./mode.js"; import { validateInteger } from "../crypto/validation.js"; import * as embedded from "./embedded.js"; import { temporary, + retainTemporaryFiles, temporaryPath, clearTemporaryFiles, writablePath, pathOf, + readonlyFile, + embeddedStat, } from "./writable.js"; import { Buffer } from "node:buffer"; import { validateFunction, validateObject, validateBoolean, + validateUint32, } from "../net/validators.js"; +import { ERR_INVALID_ARG_VALUE, ERR_INVALID_ARG_TYPE } from "../net/errors.js"; export const registerVirtualFiles = embedded.registerVirtualFiles; export function clearVirtualFiles() { embedded.clearVirtualFiles(); @@ -42,8 +54,10 @@ for (const name of [ ]) { api[name] = new Proxy(api[name], { construct(_target, args) { + if (name === "ReadStream" || name === "FileReadStream") + return temporary.createReadStream(...args); args[0] = writablePath(args[0], name); - return Reflect.construct(temporary[name], args); + return temporary.createWriteStream(...args); }, }); } @@ -67,10 +81,10 @@ for (const name of ["writeFileSync", "appendFileSync"]) ? Buffer.byteLength(args[1], encoding) : args[1].byteLength; }; -const promises: any = { ...temporary.promises }; -for (const name of Object.keys(promises)) - if (typeof promises[name] === "function" && /^[a-z]/.test(name)) - promises[name] = (...args: any[]) => temporary.promises[name](...args); +const promiseImpl: any = { ...temporary.promises }; +for (const name of Object.keys(promiseImpl)) + if (typeof promiseImpl[name] === "function" && /^[a-z]/.test(name)) + promiseImpl[name] = (...args: any[]) => temporary.promises[name](...args); for (const name of [ "readFile", "readdir", @@ -80,7 +94,7 @@ for (const name of [ "access", ]) { const old = (embedded.promises as any)[name]; - promises[name] = (...args: any[]) => + promiseImpl[name] = async (...args: any[]) => temporaryPath(args[0]) ? temporary.promises[name](...args) : old(...args); } // Keep all writes inside the memory volume, including FileHandle operations. @@ -99,14 +113,11 @@ for (const name of [ "chown", "lchmod", "lchown", - "open", "rename", "copyFile", - "cp", - "link", "symlink", ]) { - promises[name] = async (...args: any[]) => { + promiseImpl[name] = async (...args: any[]) => { if (name === "symlink") { return new Promise((resolve, reject) => temporary.symlink(...args, (error: unknown) => @@ -124,15 +135,15 @@ for (const name of [ Buffer.isBuffer(args[i]) ) { if (!temporaryPath(args[i])) - throw Object.assign(new Error("EROFS: read-only file system"), { - code: "EROFS", + throw Object.assign(new Error("operation not permitted"), { + code: "EPERM", }); } return temporary.promises[name](...args); }; } -promises.watch = async function* () { - api.watch(); +promiseImpl.watch = function () { + throw new Error("Not implemented"); }; // Workers validates ownership/permission requests but has no mutable Unix // ownership or permissions. These operations must not change the memory VFS. @@ -152,45 +163,147 @@ function installOperation( (error) => callback(error), ); }; - if (promise) promises[name] = async (...args: any[]) => handler(...args); + if (promise) promiseImpl[name] = async (...args: any[]) => handler(...args); } -function temporaryStats(stat: any) { +function temporaryStats(stat: any, readonly = false) { if (stat == null) return stat; const bigint = typeof stat.mode === "bigint"; - const mode = (Number(stat.mode) & ~0o777) | 0o666; + const mode = + (Number(stat.mode) & ~0o777) | + (readonly || stat.isSymbolicLink() ? 0o444 : 0o666); stat.mode = bigint ? BigInt(mode) : mode; - stat.uid = stat.gid = bigint ? 0n : 0; + for (const name of [ + "dev", + "ino", + "uid", + "gid", + "rdev", + "blksize", + "blocks", + "atimeMs", + ]) + stat[name] = bigint ? 0n : 0; + stat.dev = bigint + ? BigInt(stat.isCharacterDevice()) + : Number(stat.isCharacterDevice()); + stat.nlink = bigint ? 1n : 1; + stat.atime = new Date(0); + stat.ctime = stat.mtime; + stat.ctimeMs = stat.mtimeMs; + if (bigint) { + stat.atimeNs = 0n; + stat.ctimeNs = stat.mtimeNs; + } + if (readonly) + for (const name of ["mtime", "ctime", "birthtime"]) { + stat[name] = new Date(0); + stat[name + "Ms"] = bigint ? 0n : 0; + if (bigint) stat[name + "Ns"] = 0n; + } return stat; } +// Workers has no executable files. Read-only bundle files also report ENOENT +// for W_OK rather than exposing host-style permission bits. +function accessArgs(path: unknown, mode: unknown = 0) { + const normalized = pathOf(path); + mode ??= 0; + validateInteger(mode, "mode", 0, 7); + return { path: normalized, mode: mode as number }; +} +function checkAccess({ path, mode }: ReturnType) { + if (mode & 1 || (mode & 2 && readonlyFile(path)) || !api.existsSync(path)) + throw Object.assign( + new Error(`ENOENT: no such file or directory, access '${path}'`), + { + code: "ENOENT", + errno: -2, + syscall: "access", + path, + }, + ); +} +api.accessSync = (path: unknown, mode?: unknown) => + checkAccess(accessArgs(path, mode)); +api.access = (path: unknown, mode: unknown, callback?: any) => { + if (typeof mode === "function") { + callback = mode; + mode = undefined; + } + const args = accessArgs(path, mode); + validateFunction(callback, "callback"); + let failure: unknown = null; + try { + checkAccess(args); + } catch (error) { + failure = error; + } + queueMicrotask(() => callback(failure)); +}; +promiseImpl.access = async (path: unknown, mode?: unknown) => + checkAccess(accessArgs(path, mode)); +function statOptions(options: any = {}) { + validateObject(options, "options"); + validateBoolean(options.bigint ?? false, "options.bigint"); + validateBoolean(options.throwIfNoEntry ?? true, "options.throwIfNoEntry"); + return options; +} for (const name of ["stat", "lstat"]) - installOperation(name, (path: any, options?: any) => - temporaryPath(path) - ? temporaryStats(temporary[name + "Sync"](path, options)) - : embedded.statSync(path), - ); + installOperation(name, (path: any, options: any = {}) => { + statOptions(options); + return temporaryPath(path) + ? temporaryStats( + temporary[name + "Sync"](path, options), + readonlyFile(path, name !== "lstat"), + ) + : temporaryStats(embeddedStat(path, options), true); + }); installOperation( "fstat", - (...args: any[]) => temporaryStats(temporary.fstatSync(...args)), + (fd: any, options: any = {}) => { + statOptions(options); + validateInteger(fd, "fd", 0, 2147483647); + return temporaryStats(temporary.fstatSync(fd, options), readonlyFile(fd)); + }, false, ); for (const name of ["chmod", "lchmod", "fchmod", "chown", "lchown", "fchown"]) { - installOperation( - name, - (path: any, value: any, gid: any) => { - if (name.endsWith("chmod")) fileMode(value); - else { - validateInteger(value, "uid", -1, 0xffffffff); - validateInteger(gid, "gid", -1, 0xffffffff); - } - if (name.startsWith("f")) { - validateInteger(path, "fd", 0, 2147483647); - temporary.fstatSync(path); - } else if (name.startsWith("l") && temporaryPath(path)) - temporary.lstatSync(path); - else api.statSync(path); - }, - !name.startsWith("f"), - ); + const count = name.endsWith("chmod") ? 2 : 3; + const prepare = (args: any[]) => { + const [path, value, gid] = args; + if (name.endsWith("chmod")) fileMode(value); + else { + validateInteger(value, "uid", -1, 0xffffffff); + validateInteger(gid, "gid", -1, 0xffffffff); + } + if (typeof path === "number" || name.startsWith("f")) + validateInteger(path, "fd", 0, 2147483647); + else pathOf(path); + return path; + }; + const operation = (path: any) => { + try { + if (typeof path === "number") temporary.fstatSync(path); + else api[name.startsWith("l") ? "lstatSync" : "statSync"](path); + } catch (error: any) { + if (error.code === "ENOENT") error.syscall = name; + throw error; + } + }; + api[name + "Sync"] = (...args: any[]) => operation(prepare(args)); + api[name] = (...args: any[]) => { + const path = prepare(args), + callback = args[count]; + validateFunction(callback, "callback"); + let error: any = null; + try { + operation(path); + } catch (failure) { + error = failure; + } + queueMicrotask(() => callback(error)); + }; + if (!name.startsWith("f")) + promiseImpl[name] = async (...args: any[]) => operation(prepare(args)); } installOperation("statfs", (path: any, options: any = {}) => { pathOf(path); @@ -208,24 +321,47 @@ installOperation("statfs", (path: any, options: any = {}) => { ffree: value, }; }); -const openPromise = promises.open; -promises.open = async (...args: any[]) => { - fileMode(args[2], 0o666); - args[2] = 0o666; - const handle = await openPromise(...args); - const stat = handle.stat.bind(handle); - handle.stat = async (...options: any[]) => - temporaryStats(await stat(...options)); - handle.chmod = async (mode: unknown) => { - fileMode(mode); - await stat(); - }; - handle.chown = async (uid: number, gid: number) => { - validateInteger(uid, "uid", -1, 0xffffffff); - validateInteger(gid, "gid", -1, 0xffffffff); - await stat(); - }; - return handle; +// Workers interprets numeric timestamps as milliseconds and does not change +// metadata belonging to embedded files. Convert before the memfs boundary. +function fileTime(value: any): Date { + let date: Date; + if (value instanceof Date) date = value; + else if (typeof value === "number" || typeof value === "string") + date = new Date(value); + else if (typeof value === "bigint") date = new Date(Number(value)); + else + throw new ERR_INVALID_ARG_TYPE( + "time", + ["string", "number", "bigint", "Date"], + value, + ); + if (!Number.isFinite(date.getTime())) + throw new TypeError( + "The value cannot be converted because it is not an integer.", + ); + return date; +} +for (const name of ["utimes", "lutimes", "futimes"]) { + installOperation( + name, + (path: any, atime: any, mtime: any) => { + const access = fileTime(atime), + modified = fileTime(mtime); + if (name === "futimes") { + validateInteger(path, "fd", 0, 2147483647); + api.fstatSync(path); + } else api[name === "lutimes" ? "lstatSync" : "statSync"](path); + if (!readonlyFile(path, name !== "lutimes")) + temporary[name + "Sync"](path, access, modified); + }, + name !== "futimes", + ); +} +promiseImpl.FileHandle = FileHandle; +promiseImpl.open = async (path: any, flags: any, mode: any) => { + const run = retainTemporaryFiles(); + const fd = api.openSync(path, flags, mode); + return createFileHandle({ fd, run, api }); }; // Copy an embedded bundle file into request-local /tmp without granting any // write access to its source volume. @@ -246,8 +382,470 @@ installOperation("copyFile", (source: any, destination: any, mode = 0) => { throw Object.assign(new Error("File already exists"), { code: "EEXIST" }); temporary.writeFileSync(destination, embedded.readFileSync(pathOf(source))); }); -api.promises = promises; +const copyTree = (source: string, destination: string, options: any) => { + const fail = (code: string): never => { + throw Object.assign(new Error(code), { code, syscall: "cp" }); + }; + source = pathOf(source); + destination = pathOf(destination); + if (source === destination) + throw new Error("Source and destination paths must not be the same"); + if (destination.startsWith(source + "/")) fail("ERR_FS_CP_EINVAL"); + const lookup = (path: string, follow: boolean) => { + try { + return api[follow ? "statSync" : "lstatSync"](path); + } catch (error: any) { + if (error.code !== "ENOENT") throw error; + } + }; + const visit = (src: string, dst: string, nested = false) => { + const stat = lookup(src, nested ? false : options.dereference); + if (!stat) fail("ENOENT"); + const target = lookup(dst, options.dereference); + if (stat.isDirectory()) { + if (!options.recursive) fail("ERR_FS_EISDIR"); + if (target && !target.isDirectory()) + fail( + target.isSymbolicLink() + ? nested + ? "EISDIR" + : "ENOTDIR" + : nested + ? "ENOTDIR" + : "ERR_FS_CP_DIR_TO_NON_DIR", + ); + const entries = api.readdirSync(src); + api.mkdirSync(dst, { recursive: true }); + for (const name of entries) + visit(src + "/" + name, dst + "/" + name, true); + return; + } + if (target?.isDirectory()) + fail( + nested + ? "EISDIR" + : stat.isSymbolicLink() + ? "ENOTDIR" + : "ERR_FS_CP_NON_DIR_TO_DIR", + ); + if (target && !options.force) { + if (options.errorOnExist) fail(nested ? "EEXIST" : "ERR_FS_CP_EEXIST"); + return; + } + const data = stat.isSymbolicLink() + ? api.readlinkSync(src) + : api.readFileSync(src); + api.mkdirSync(dst.slice(0, dst.lastIndexOf("/")) || "/", { + recursive: true, + }); + if (lookup(dst, false)) api.unlinkSync(dst); + if (stat.isSymbolicLink()) api.symlinkSync(data, dst); + else api.writeFileSync(dst, data); + }; + visit(source, destination); +}; +installOperation("cp", (source: any, destination: any, options: any = {}) => { + validateObject(options, "options"); + const { + dereference = false, + errorOnExist = false, + force = true, + preserveTimestamps = false, + recursive = false, + verbatimSymlinks = false, + mode = 0, + } = options; + for (const [key, value] of Object.entries({ + dereference, + errorOnExist, + force, + preserveTimestamps, + recursive, + verbatimSymlinks, + })) + validateBoolean(value, "options." + key); + validateUint32(mode, "options.mode"); + if (mode & 4) + throw new ERR_INVALID_ARG_VALUE( + "options.mode", + "COPYFILE_FICLONE_FORCE is not supported", + ); + if (options.filter !== undefined) { + if (typeof options.filter !== "function") + throw new ERR_INVALID_ARG_TYPE( + "options.filter", + "function", + options.filter, + ); + throw Object.assign(new Error("The requested operation is unsupported"), { + code: "ERR_UNSUPPORTED_OPERATION", + }); + } + return copyTree(source, destination, { + dereference, + errorOnExist, + force, + recursive, + verbatimSymlinks: false, + preserveTimestamps: false, + mode: 0, + }); +}); +api.cp = (source: any, destination: any, options: any, callback?: any) => { + if (typeof options === "function") { + callback = options; + options = {}; + } + validateObject(options ?? {}, "options"); + for (const key of [ + "dereference", + "errorOnExist", + "force", + "preserveTimestamps", + "recursive", + "verbatimSymlinks", + ]) + if (options?.[key] !== undefined) + validateBoolean(options[key], "options." + key); + if (options?.mode !== undefined) validateUint32(options.mode, "options.mode"); + if (options?.filter !== undefined && typeof options.filter !== "function") + throw new ERR_INVALID_ARG_TYPE( + "options.filter", + "function", + options.filter, + ); + if (options?.filter !== undefined) + throw Object.assign(new Error("The requested operation is unsupported"), { + code: "ERR_UNSUPPORTED_OPERATION", + }); + if (options?.mode & 4) + throw new ERR_INVALID_ARG_VALUE( + "options.mode", + "COPYFILE_FICLONE_FORCE is not supported", + ); + pathOf(source); + pathOf(destination); + validateFunction(callback, "callback"); + let error: unknown; + try { + api.cpSync(source, destination, options); + } catch (cause) { + error = cause; + } + queueMicrotask(() => callback(error ?? null)); +}; +api.promises = promiseImpl; +const promises = promiseImpl as FsPromises; export { promises }; +// Validate public directory arguments before scheduling their callbacks. +function directoryOperation( + name: string, + prepare: (path: any, options: any) => any, + operation: (args: any) => any, +) { + api[name + "Sync"] = (path: any, options: any = {}) => + operation(prepare(path, options)); + api[name] = (path: any, options: any, callback?: any) => { + if (typeof options === "function") { + callback = options; + options = {}; + } + const args = prepare(path, options === undefined ? {} : options); + validateFunction(callback, "callback"); + let value: any, + error: any = null; + try { + value = operation(args); + } catch (failure) { + error = failure; + } + queueMicrotask(() => callback(error, value)); + }; + promiseImpl[name] = async (path: any, options: any = {}) => + operation(prepare(path, options)); +} +directoryOperation( + "mkdir", + (path, options) => { + if (typeof options === "number") options = { mode: options }; + validateObject(options, "options"); + const { recursive = false, mode = 0o777 } = options; + validateBoolean(recursive, "options.recursive"); + fileMode(mode); + return { path: pathOf(path), recursive }; + }, + ({ path, recursive }) => { + writablePath(path, "mkdir"); + if (temporary.existsSync(path) && temporary.statSync(path).isDirectory()) + return; + let created = path; + if (recursive) { + let parent = created.slice(0, created.lastIndexOf("/")) || "/"; + while (!temporary.existsSync(parent)) { + created = parent; + parent = created.slice(0, created.lastIndexOf("/")) || "/"; + } + } + temporary.mkdirSync(path, { recursive, mode: 0o777 }); + return recursive ? created : undefined; + }, +); +directoryOperation( + "mkdtemp", + (path, options) => { + if (typeof options === "string" || options == null) + options = { encoding: options }; + validateObject(options, "options"); + const { encoding = "utf8" } = options; + if (encoding !== "buffer" && !Buffer.isEncoding(encoding)) + throw new ERR_INVALID_ARG_VALUE("options.encoding", encoding); + return { path: pathOf(path) }; + }, + ({ path }) => { + writablePath(path, "mkdtemp"); + for (;;) { + const name = + path + globalThis.crypto.getRandomValues(new Uint32Array(1))[0]; + try { + temporary.mkdirSync(name); + return name; + } catch (error: any) { + if (error.code !== "EEXIST") throw error; + } + } + }, +); +for (const name of ["rm", "rmdir"]) + directoryOperation( + name, + (path, options) => { + validateObject(options, "options"); + const { + recursive = false, + force = false, + maxRetries = 0, + retryDelay = 0, + } = options; + if (name === "rm") validateBoolean(force, "options.force"); + validateUint32(maxRetries, "options.maxRetries"); + validateBoolean(recursive, "options.recursive"); + validateUint32(retryDelay, "options.retryDelay"); + return { path: pathOf(path), recursive, force }; + }, + ({ path, recursive, force }) => { + if ( + name === "rm" && + !recursive && + temporary.existsSync(path) && + temporary.lstatSync(path).isDirectory() + ) + return temporary.rmdirSync(path); + return temporary[name + "Sync"](path, { recursive, force }); + }, + ); +const statFilesystem = api.statfsSync; +directoryOperation( + "statfs", + (path, options) => { + pathOf(path); + validateObject(options, "options"); + validateBoolean(options.bigint ?? false, "options.bigint"); + return { path, options }; + }, + ({ path, options }) => statFilesystem(path, options), +); +api.exists = (path: any, callback: any) => { + validateFunction(callback, "callback"); + let exists = false; + try { + exists = api.existsSync(path); + } catch {} + queueMicrotask(() => callback(exists)); +}; +for (const name of ["link", "symlink"]) { + const prepare = (source: any, destination: any, type: any = null) => { + if (name === "symlink" && !["dir", "file", "junction", null].includes(type)) + throw new ERR_INVALID_ARG_VALUE("type", type); + const src = pathOf(source), + dest = pathOf(destination); + const raw = + destination instanceof URL + ? destination.pathname + : Buffer.isBuffer(destination) + ? destination.toString() + : destination; + return { src, dest, type, trailing: raw.endsWith("/") }; + }; + const operation = ({ src, dest, type, trailing }: any) => { + if (trailing) + throw Object.assign(new Error("Invalid path"), { code: "EINVAL" }); + if (name === "link") { + let stat; + try { + stat = api.statSync(src); + } catch (error: any) { + if (error.code === "ENOTDIR") error.code = "ENOENT"; + throw error; + } + if (stat.isDirectory()) + throw Object.assign(new Error("operation not permitted"), { + code: "EPERM", + }); + } + const parent = dest.slice(0, dest.lastIndexOf("/")) || "/"; + try { + const parentStat = api.statSync(parent); + if (!parentStat.isDirectory()) + throw Object.assign(new Error("Invalid path"), { code: "EINVAL" }); + } catch (error: any) { + if (error.code === "ENOTDIR") error.code = "EINVAL"; + throw error; + } + try { + return temporary[name + "Sync"](src, dest, type ?? undefined); + } catch (error: any) { + if (error.code === "ENOTDIR") + error.code = api.existsSync(parent) ? "EINVAL" : "ENOENT"; + throw error; + } + }; + api[name + "Sync"] = (source: any, destination: any, type?: any) => + operation(prepare(source, destination, type)); + api[name] = (source: any, destination: any, type: any, callback?: any) => { + if (typeof type === "function") { + callback = type; + type = null; + } + const args = prepare(source, destination, type); + validateFunction(callback, "callback"); + let error: any = null; + try { + operation(args); + } catch (failure) { + error = failure; + } + queueMicrotask(() => callback(error)); + }; + promiseImpl[name] = async (source: any, destination: any, type?: any) => + operation(prepare(source, destination, type)); +} +for (const name of ["readlink", "realpath"]) + directoryOperation( + name, + (path, options) => { + if (typeof options === "string" || options == null) + options = { encoding: options }; + validateObject(options, "options"); + const { encoding = "utf8" } = options; + if (encoding !== "buffer" && !Buffer.isEncoding(encoding)) + throw new ERR_INVALID_ARG_VALUE("options.encoding", encoding); + return { path: pathOf(path), encoding }; + }, + ({ path, encoding }) => { + const stat = api.lstatSync(path); + let target = path; + if (stat.isSymbolicLink()) target = temporary.readlinkSync(path); + else if (name === "readlink") + throw Object.assign(new Error("invalid argument"), { code: "EINVAL" }); + return Buffer.from(target).toString(encoding); + }, + ); +api.realpathSync.native = api.realpathSync; +api.realpath.native = api.realpath; +const unlinkFile = (path: any) => temporary.unlinkSync(path); +api.unlinkSync = (path: any) => { + path = pathOf(path); + if (api.lstatSync(path).isDirectory()) + throw Object.assign(new Error("illegal operation on a directory"), { + code: "EISDIR", + }); + return unlinkFile(path); +}; +api.unlink = (path: any, callback: any) => { + path = pathOf(path); + validateFunction(callback, "callback"); + let error: any = null; + try { + api.unlinkSync(path); + } catch (failure) { + error = failure; + } + queueMicrotask(() => callback(error)); +}; +promiseImpl.unlink = async (path: any) => api.unlinkSync(path); +api.ReadStream = api.FileReadStream = fileStreams.ReadStream; +api.WriteStream = api.FileWriteStream = fileStreams.WriteStream; +api.createReadStream = fileStreams.createReadStream; +api.createWriteStream = fileStreams.createWriteStream; +api.openAsBlob = (path: any, options: any = {}) => { + const normalized = pathOf(path); + validateObject(options, "options"); + const { type = "" } = options; + if (typeof type !== "string") + throw new ERR_INVALID_ARG_TYPE("options.type", "string", type); + if (api.statSync(normalized).isDirectory()) + throw Object.assign(new Error("illegal operation on a directory"), { + code: "EISDIR", + }); + const data = api.readFileSync(normalized); + return new Blob([data], { type }); +}; +api.Dir = WorkersDir; +api.Dirent = WorkersDirent; +installOperation("glob", workersGlob, false); +promiseImpl.glob = async function* (pattern: any, options?: any) { + yield* workersGlob(pattern, options); +}; +function directoryOptions(options: any, open: boolean) { + if (!open && (typeof options === "string" || options == null)) + options = { encoding: options }; + validateObject(options, "options"); + const { + encoding = "utf8", + recursive = false, + withFileTypes = false, + bufferSize = 32, + } = options; + if (encoding !== "buffer" && !Buffer.isEncoding(encoding)) + throw new ERR_INVALID_ARG_VALUE("options.encoding", encoding); + validateBoolean(recursive, "options.recursive"); + if (open) validateUint32(bufferSize, "options.bufferSize"); + else validateBoolean(withFileTypes, "options.withFileTypes"); + return { encoding, recursive, withFileTypes }; +} +directoryOperation( + "readdir", + (path, options) => ({ + ...directoryOptions(options, false), + path: pathOf(path), + }), + ({ path, encoding, recursive, withFileTypes }) => { + const entries = readDirectory(path, recursive); + return entries.map((entry) => + withFileTypes + ? new WorkersDirent( + encoding === "buffer" ? Buffer.from(entry.name) : entry.name, + entry.type, + entry.parentPath, + ) + : encoding === "buffer" + ? Buffer.from(entry.name) + : Buffer.from(entry.name).toString(encoding), + ); + }, +); +directoryOperation( + "opendir", + (path, options) => { + const opts = directoryOptions(options, true); + pathOf(path); + return { ...opts, path }; + }, + ({ path, encoding, recursive }) => { + return new WorkersDir(readDirectory(path, recursive), path, { encoding }); + }, +); +installIo(api, promiseImpl); export const { Dir, Dirent, @@ -354,4 +952,4 @@ export const { writev, writevSync, } = api; -export default api; +export default api as Record & { promises: FsPromises }; diff --git a/src/compat/fs/io-validation.ts b/src/compat/fs/io-validation.ts new file mode 100644 index 0000000..ee0e314 --- /dev/null +++ b/src/compat/fs/io-validation.ts @@ -0,0 +1,186 @@ +// @ts-nocheck +// Adapted from cloudflare/workerd@5358e3d337336eaf696a48bc1563d808b10feba5. +// Copyright Cloudflare, Inc. Licensed under Apache-2.0; see LICENSE-APACHE. +import { Buffer } from "node:buffer"; +import { validateInteger } from "../crypto/validation.js"; +import { validateUint32 } from "../net/validators.js"; +import { + ERR_INVALID_ARG_TYPE, + ERR_INVALID_ARG_VALUE, + ERR_OUT_OF_RANGE, +} from "../net/errors.js"; +const isArrayBufferView = ArrayBuffer.isView; +class ERR_BUFFER_OUT_OF_BOUNDS extends RangeError { + code = "ERR_BUFFER_OUT_OF_BOUNDS"; + constructor(name) { + super(`${name} is outside of buffer bounds`); + } +} +function validateEncoding(value, encoding) { + if (encoding !== undefined && !Buffer.isEncoding(encoding)) + throw new ERR_INVALID_ARG_VALUE("encoding", encoding); +} +export function getValidatedFd(fd) { + validateInteger(fd, "fd", 0, 2147483647); + return fd; +} +export function validatePosition(position, name) { + if (typeof position === "number") validateUint32(position, name); + else if (typeof position !== "bigint" && position !== null) + throw new ERR_INVALID_ARG_TYPE( + name, + ["integer", "bigint", "null"], + position, + ); +} +export function validateWriteArgs( + fd: number, + buffer: NodeJS.ArrayBufferView | string, + offsetOrOptions: WriteSyncOptions | Position | undefined, + length: number | ValidEncoding | undefined, + position: Position | undefined, +): { fd: number; buffer: Buffer[]; position: Position } { + fd = getValidatedFd(fd); + + let offset: number | undefined | null = offsetOrOptions as number; + if (isArrayBufferView(buffer)) { + if (typeof offsetOrOptions === "object" && offsetOrOptions != null) { + ({ + offset = 0, + length, + position = null, + } = offsetOrOptions as WriteSyncOptions); + offset ??= 0; + validateInteger(offset, "offset", 0); + } else { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (offset != null) { + validateInteger(offset, "offset", 0); + } + offset ??= 0; + position ??= null; + } + + validatePosition(position, "position"); + if (offset > buffer.byteLength) { + throw new ERR_BUFFER_OUT_OF_BOUNDS("offset"); + } + length ??= buffer.byteLength - offset; + validateInteger(length, "length", 0); + if (length > buffer.byteLength - offset) { + throw new ERR_BUFFER_OUT_OF_BOUNDS("length"); + } + + return { + fd, + buffer: [Buffer.from(buffer.buffer, buffer.byteOffset + offset, length)], + position, + }; + } + + if (typeof buffer !== "string") { + throw new ERR_INVALID_ARG_TYPE( + "buffer", + ["string", "Buffer", "TypedArray", "DataView"], + buffer, + ); + } + + // In this case, offsetOrOptions must either be a number, bigint, or null. + validatePosition(offsetOrOptions, "position"); + position = offsetOrOptions; + + // In this instance, buffer is a string and the length arg specifies + // the encoding to use. + validateEncoding(buffer, length as string); + return { + fd, + buffer: [Buffer.from(buffer, length as string /* encoding */)], + position, + }; +} + +export function validateReadArgs( + fd: number, + buffer: NodeJS.ArrayBufferView, + offsetOrOptions: ReadOptions | number | null, + length: number | undefined, + position: Position | undefined, +): { fd: number; buffer: Buffer[]; length: number; position: Position } { + fd = getValidatedFd(fd); + + // Great fun with polymorphism here. We're going to normalize the arguments + // to match the first signature (fd, buffer, offset, length, position). + // + // If the third argument is an object, then we will pull the offset, length, + // and position from it, ignoring the remaining arguments. If the third + // argument is a number, then we will use it as the offset and pull the + // length and position from the fourth and fifth arguments. If the third + // position is any other type, then we will throw an error. + + if (!isArrayBufferView(buffer)) { + throw new ERR_INVALID_ARG_TYPE( + "buffer", + ["Buffer", "TypedArray", "DataView"], + buffer, + ); + } + + let actualOffset: number; + let actualLength: number; + let actualPosition = position; + + // Handle the case where the third argument is an options object + if (offsetOrOptions != null && typeof offsetOrOptions === "object") { + const { + offset = 0, + length = buffer.byteLength - offset, + position = null, + } = offsetOrOptions; + actualOffset = offset; + actualLength = length; + actualPosition = position; + } + // Handle the case where the third argument is a number (offset) + else if (typeof offsetOrOptions === "number") { + actualOffset = offsetOrOptions; + actualLength = length ?? buffer.byteLength - offsetOrOptions; + actualPosition = position; + } else { + throw new ERR_INVALID_ARG_TYPE( + "offset", + ["number", "object"], + offsetOrOptions, + ); + } + + validateUint32(actualOffset, "offset"); + validateUint32(actualLength, "length"); + validatePosition(actualPosition, "position"); + + // As in Node, a zero-length read is a no-op regardless of offset. + if (actualLength === 0) { + return { fd, buffer: [], length: 0, position: actualPosition }; + } + + if (actualOffset + actualLength > buffer.byteLength) { + throw new ERR_OUT_OF_RANGE( + "length", + `<= ${buffer.byteLength - actualOffset}`, + actualLength, + ); + } + + return { + fd, + buffer: [ + Buffer.from( + buffer.buffer, + buffer.byteOffset + actualOffset, + actualLength, + ), + ], + length: actualLength, + position: actualPosition, + }; +} diff --git a/src/compat/fs/io.ts b/src/compat/fs/io.ts new file mode 100644 index 0000000..8b7d066 --- /dev/null +++ b/src/compat/fs/io.ts @@ -0,0 +1,359 @@ +import { viewBytes } from "../../shared/buffer-source.js"; +import { Buffer } from "node:buffer"; +import { pathOf, descriptorFlags, readDescriptor } from "./writable.js"; +import { fileMode } from "./mode.js"; +import { + validateWriteArgs, + validateReadArgs, + getValidatedFd, + validatePosition, +} from "./io-validation.js"; +import { + validateBoolean, + validateObject, + validateFunction, + validateUint32, +} from "../net/validators.js"; +import { ERR_INVALID_ARG_TYPE, ERR_INVALID_ARG_VALUE } from "../net/errors.js"; +export function installIo(api: any, promises: any) { + const raw = { ...api }; + const fail = (code: string, message = code): never => { + throw Object.assign(new Error(message), { code }); + }; + const perform = (fn: () => any) => { + try { + return fn(); + } catch (e: any) { + if (e.code === "EBADF") e.message = "bad file descriptor"; + throw e; + } + }; + const install = ( + name: string, + prepare: (...args: any[]) => any[], + action: (...args: any[]) => any, + extra?: (args: any[]) => any[], + ) => { + api[name + "Sync"] = (...args: any[]) => + perform(() => action(...prepare(...args))); + api[name] = (...args: any[]) => { + const callback = + typeof args.at(-1) === "function" ? args.pop() : undefined; + if (name === "read") validateFunction(callback, "callback"); + const prepared = prepare(...args); + validateFunction(callback, "callback"); + let error: unknown, value: any; + try { + value = perform(() => action(...prepared)); + } catch (cause) { + error = cause; + } + queueMicrotask(() => + callback(error ?? null, value, ...(extra?.(args) ?? [])), + ); + }; + if (Object.hasOwn(promises, name)) + promises[name] = async (...args: any[]) => { + const result = api[name + "Sync"](...args); + // Workers' whole-file promise writers resolve without a value, unlike + // their synchronous counterparts and FileHandle.writeFile. + return name === "writeFile" || name === "appendFile" + ? undefined + : result; + }; + }; + const flags = (flag: any) => { + if (typeof flag === "number") { + validateUint32(flag, "flags"); + return flag; + } + const value: Record = { + r: 0, + "r+": 2, + rs: 1052672, + sr: 1052672, + "rs+": 1052674, + "sr+": 1052674, + w: 577, + wx: 705, + xw: 705, + "w+": 578, + "wx+": 706, + "xw+": 706, + a: 1089, + ax: 1217, + xa: 1217, + "a+": 1090, + "ax+": 1218, + "xa+": 1218, + as: 1053761, + sa: 1053761, + "as+": 1053762, + "sa+": 1053762, + }; + if (!Object.hasOwn(value, flag)) + throw new ERR_INVALID_ARG_VALUE("flags", flag); + return value[flag]; + }; + install( + "close", + (fd: any) => [getValidatedFd(fd)], + (fd) => { + try { + raw.closeSync(fd); + } catch (e: any) { + if (e.code !== "EBADF") throw e; + } + }, + ); + for (const name of ["truncate", "ftruncate"]) + install( + name, + (path: any, length = 0) => { + validateUint32(length, "len"); + return [ + name === "ftruncate" ? getValidatedFd(path) : pathOf(path), + length, + ]; + }, + (...args: any[]) => raw[name + "Sync"](...args), + ); + for (const name of ["write", "read"]) + install( + name, + ( + fd: any, + buffer: any, + offset: any = name === "read" ? 0 : null, + length?: any, + position: any = null, + ) => { + const result = ( + name === "write" ? validateWriteArgs : validateReadArgs + )(fd, buffer, offset, length, position); + return [result.fd, result.buffer, result.position]; + }, + (fd: number, buffers: any[], position: any) => { + if (typeof position === "bigint") { + if (position < 0n || position > 0xffffffffn) fail("EINVAL"); + position = Number(position); + } + if (!buffers.length) return 0; + return perform(() => + raw[name + "Sync"]( + fd, + buffers[0], + 0, + buffers[0].byteLength, + position, + ), + ); + }, + (args) => [args[1]], + ); + const writeCallback = api.write; + api.write = (...args: any[]) => { + if ( + typeof args[1] === "string" && + args[2] !== null && + typeof args[2] === "object" + ) + args[2] = args[2].position ?? null; + return writeCallback(...args); + }; + for (const name of ["readv", "writev"]) + install( + name, + (fd: any, buffers: any, position: any = null) => { + getValidatedFd(fd); + validatePosition(position, "position"); + if ( + !Array.isArray(buffers) || + buffers.some((b) => !ArrayBuffer.isView(b)) + ) + throw new ERR_INVALID_ARG_TYPE( + "buffers", + "ArrayBufferView[]", + buffers, + ); + return [fd, buffers, position]; + }, + (fd: number, buffers: any[], position: any) => { + let count = 0; + for (const buffer of buffers) { + const size = api[name === "readv" ? "readSync" : "writeSync"]( + fd, + buffer, + 0, + buffer.byteLength, + position === null ? null : Number(position) + count, + ); + count += size; + } + return count; + }, + (args) => [args[1]], + ); + const optionsObject = (options: any) => + typeof options === "string" || options == null + ? { encoding: options } + : options; + install( + "readFile", + (path: any, options: any = {}) => { + options = optionsObject(options); + validateObject(options, "options"); + const { encoding = null, flag = "r" } = options; + flags(flag); + if ( + encoding !== null && + encoding !== "buffer" && + !Buffer.isEncoding(encoding) + ) + throw new ERR_INVALID_ARG_VALUE("options.encoding", encoding); + return [ + typeof path === "number" ? getValidatedFd(path) : pathOf(path), + encoding, + ]; + }, + (path: any, encoding: any) => { + const data = + typeof path === "number" + ? readDescriptor(path) + : raw.readFileSync(path); + return typeof encoding === "string" ? data.toString(encoding) : data; + }, + ); + for (const name of ["writeFile", "appendFile"]) + install( + name, + (path: any, data: any, options: any = {}) => { + path = typeof path === "number" ? getValidatedFd(path) : pathOf(path); + options = optionsObject(options); + validateObject(options, "options"); + const { + encoding = "utf8", + mode = 0o666, + flag = name === "appendFile" ? "a" : "w", + flush = false, + } = options; + if (encoding !== "buffer" && !Buffer.isEncoding(encoding)) + throw new ERR_INVALID_ARG_VALUE("options.encoding", encoding); + validateBoolean(flush, "options.flush"); + fileMode(mode); + const value = flags(flag); + if (!(value & 3) && !(value & 1024)) + throw new ERR_INVALID_ARG_VALUE( + "flag", + flag, + "must indicate write or append", + ); + if (typeof data === "string") data = Buffer.from(data, encoding); + if (!ArrayBuffer.isView(data)) + throw new ERR_INVALID_ARG_TYPE( + "data", + ["string", "Buffer", "TypedArray", "DataView"], + data, + ); + return [ + path, + Buffer.from(viewBytes(data)), + Boolean(value & 1024), + Boolean(value & 128), + ]; + }, + (path: any, data: Buffer, append: boolean, exclusive: boolean) => { + if (typeof path !== "number") { + if (exclusive && api.existsSync(path)) fail("EEXIST"); + raw[append ? "appendFileSync" : "writeFileSync"](path, data); + return data.length; + } + append ||= Boolean(descriptorFlags(path) & 1024); + if (!append) api.ftruncateSync(path, 0); + return api.writeSync( + path, + data, + 0, + data.length, + append ? api.fstatSync(path).size : 0, + ); + }, + ); + // The callback frontends have their own pinned validation/default order. + api.readFile = (path: any, options: any, callback?: any) => { + if (typeof options === "function") { + callback = options; + options = {}; + } + options = optionsObject(options); + validateObject(options, "options"); + const { encoding = null } = options; + if ( + encoding !== null && + encoding !== "buffer" && + !Buffer.isEncoding(encoding) + ) + throw new ERR_INVALID_ARG_VALUE("options.encoding", encoding); + validateFunction(callback, "callback"); + try { + const value = api.readFileSync(path, options); + queueMicrotask(() => callback(null, value)); + } catch (error) { + queueMicrotask(() => callback(error)); + } + }; + api.appendFile = (path: any, data: any, options: any, callback?: any) => { + if (typeof options === "function") { + callback = options; + options = { encoding: "utf8", mode: 0o666, flag: "a", flush: false }; + } + return api.writeFile(path, data, options, callback); + }; + const open = api.openSync; + api.openSync = (path: any, flag: any = "r", mode?: any) => { + let value = flags(flag); + if (!(value & 3)) value &= ~512; + return open(path, value, mode); + }; + api.constants = Object.assign(Object.create(null), api.constants); + for (const name of [ + "UV_FS_COPYFILE_EXCL", + "UV_FS_COPYFILE_FICLONE", + "UV_FS_COPYFILE_FICLONE_FORCE", + ]) + api.constants[name] = api.constants[name.slice(6)]; + promises.constants = api.constants; + const rename = api.renameSync; + install( + "rename", + (src: any, dst: any) => [pathOf(src), pathOf(dst)], + (src: string, dst: string) => { + if (src === dst) return; + const stat = api.lstatSync(src); + const target = api.lstatSync(dst, { throwIfNoEntry: false }); + if (stat.isDirectory()) { + if (dst.startsWith(src + "/")) fail("EINVAL"); + if (target && !target.isDirectory()) fail("ENOTDIR"); + if (target && api.readdirSync(dst).length) fail("ENOTEMPTY"); + } else if (target?.isDirectory()) fail("EISDIR"); + return rename(src, dst); + }, + ); + const copy = api.copyFileSync; + install( + "copyFile", + (src: any, dst: any, mode: any = 0) => { + src = pathOf(src); + dst = pathOf(dst); + if (![0, 1, 2, 4].includes(mode)) + throw new ERR_INVALID_ARG_VALUE("mode", mode); + if (mode === 4) + fail( + "ERR_UNSUPPORTED_OPERATION", + "The requested operation is unsupported", + ); + return [src, dst, mode]; + }, + copy, + ); +} diff --git a/src/compat/fs/public-types.ts b/src/compat/fs/public-types.ts new file mode 100644 index 0000000..6a3257a --- /dev/null +++ b/src/compat/fs/public-types.ts @@ -0,0 +1,204 @@ +import type { Buffer } from "node:buffer"; +import type { + BigIntStats, + Stats, + CopySyncOptions, + Mode, + MakeDirectoryOptions, + OpenDirOptions, +} from "node:fs"; +import type { FileHandle } from "./file-handle.js"; +import type { Dir, Dirent } from "./directory.js"; +import type { FilePath, Position } from "./stream-utils.js"; +export type ReadOptions = object & { + buffer?: T; + offset?: number; + length?: number; + position?: Position; +}; +export type ReadResult = { + bytesRead: number; + buffer: T; +}; +export type WriteResult = { + bytesWritten: number; + buffer: T; +}; +export interface ReadFileOptions { + encoding?: BufferEncoding | "buffer" | null; + flag?: string | number; + signal?: AbortSignal; +} +export interface WriteFileOptions { + encoding?: BufferEncoding | null; + mode?: Mode; + flag?: string | number; + signal?: AbortSignal; + flush?: boolean; +} +export type FileData = string | ArrayBufferView; +export type FileLocation = FilePath | number; +export interface ReadFile { + ( + path: FileLocation, + options: BufferEncoding | (ReadFileOptions & { encoding: BufferEncoding }), + ): Promise; + ( + path: FileLocation, + options?: + | (ReadFileOptions & { encoding?: "buffer" | null }) + | "buffer" + | null, + ): Promise; + ( + path: FileLocation, + options?: ReadFileOptions | BufferEncoding | "buffer" | null, + ): Promise; +} +export interface Stat { + ( + path: FilePath, + options: { bigint: true; throwIfNoEntry?: true }, + ): Promise; + ( + path: FilePath, + options?: { bigint?: false; throwIfNoEntry?: true }, + ): Promise; + ( + path: FilePath, + options: { bigint?: boolean; throwIfNoEntry: false }, + ): Promise; + ( + path: FilePath, + options?: { bigint?: boolean; throwIfNoEntry?: boolean }, + ): Promise; +} +export interface ReadDirectory { + ( + path: FilePath, + options: { + withFileTypes: true; + encoding?: BufferEncoding | "buffer" | null; + recursive?: boolean; + }, + ): Promise; + ( + path: FilePath, + options: + | "buffer" + | { encoding: "buffer"; withFileTypes?: false; recursive?: boolean }, + ): Promise; + ( + path: FilePath, + options?: + | BufferEncoding + | null + | { + encoding?: BufferEncoding | null; + withFileTypes?: false; + recursive?: boolean; + }, + ): Promise; + ( + path: FilePath, + options?: { + encoding?: BufferEncoding | "buffer" | null; + withFileTypes?: boolean; + recursive?: boolean; + }, + ): Promise; +} +export interface Glob { + ( + pattern: string | readonly string[], + options: { + cwd?: string | URL; + withFileTypes: true; + exclude?: string[] | ((entry: Dirent) => boolean); + }, + ): AsyncGenerator; + ( + pattern: string | readonly string[], + options?: { + cwd?: string | URL; + withFileTypes?: false; + exclude?: string[] | ((path: string) => boolean); + }, + ): AsyncGenerator; + ( + pattern: string | readonly string[], + options?: { + cwd?: string | URL; + withFileTypes?: boolean; + exclude?: string[] | ((path: string | Dirent) => boolean); + }, + ): AsyncGenerator; +} +// Only methods present in the pinned Workers namespace are exposed here. +type SharedPromises = Pick< + typeof import("node:fs/promises"), + | "access" + | "chmod" + | "chown" + | "constants" + | "copyFile" + | "lchmod" + | "lchown" + | "link" + | "lutimes" + | "readlink" + | "realpath" + | "rename" + | "rm" + | "rmdir" + | "statfs" + | "truncate" + | "unlink" + | "utimes" +>; +export interface FsPromises extends SharedPromises { + FileHandle: typeof FileHandle; + open( + path: FilePath, + flags?: string | number, + mode?: Mode, + ): Promise; + readFile: ReadFile; + writeFile( + path: FileLocation, + data: FileData, + options?: BufferEncoding | WriteFileOptions, + ): Promise; + appendFile( + path: FileLocation, + data: FileData, + options?: BufferEncoding | WriteFileOptions, + ): Promise; + cp( + source: FilePath, + destination: FilePath, + options?: CopySyncOptions, + ): Promise; + opendir(path: FilePath, options?: OpenDirOptions): Promise; + readdir: ReadDirectory; + stat: Stat; + lstat: Stat; + mkdtemp( + prefix: FilePath, + options?: + | BufferEncoding + | "buffer" + | { encoding?: BufferEncoding | "buffer" }, + ): Promise; + mkdir( + path: FilePath, + options?: number | MakeDirectoryOptions, + ): Promise; + symlink( + target: FilePath, + path: FilePath, + type?: "dir" | "file" | "junction" | null, + ): Promise; + glob: Glob; + watch(...args: unknown[]): never; +} diff --git a/src/compat/fs/request-lifetime.spec.mjs b/src/compat/fs/request-lifetime.spec.mjs new file mode 100644 index 0000000..5ae6699 --- /dev/null +++ b/src/compat/fs/request-lifetime.spec.mjs @@ -0,0 +1,65 @@ +// The host calls first and second in distinct request scopes. +export function createFsLifetimeContract(fs) { + let held; + return async (step, kind) => { + if (step === "first") { + fs.writeFileSync("/tmp/lifetime-read", "A"); + fs.writeFileSync("/tmp/lifetime-write", "A"); + const read = + kind === "handle" + ? ( + await fs.promises.open("/tmp/lifetime-read", "r") + ).createReadStream() + : fs.createReadStream("/tmp/lifetime-read"); + const write = + kind === "handle" + ? ( + await fs.promises.open("/tmp/lifetime-write", "w") + ).createWriteStream() + : fs.createWriteStream("/tmp/lifetime-write"); + const errors = []; + read.on("error", (e) => + errors.push(["read", e.name, e.code ?? null, e.message]), + ); + write.on("error", (e) => + errors.push(["write", e.name, e.code ?? null, e.message]), + ); + if (kind !== "handle") + await Promise.all([ + new Promise((resolve) => read.once("open", resolve)), + new Promise((resolve) => write.once("open", resolve)), + ]); + held = { read, write, errors }; + return "ready"; + } + fs.writeFileSync("/tmp/lifetime-read", "B"); + fs.writeFileSync("/tmp/lifetime-write", "B"); + const { read, write, errors } = held; + let body = ""; + try { + const values = await Promise.all([ + new Promise((resolve, reject) => { + read.on("data", (chunk) => (body += chunk.toString())); + read.once("end", () => resolve(body)); + read.once("error", reject); + read.resume(); + }), + new Promise((resolve, reject) => + write.end("changed", (error) => + error ? reject(error) : resolve(null), + ), + ), + ]); + return { + values, + errors, + otherRead: fs.readFileSync("/tmp/lifetime-read", "utf8"), + otherWrite: fs.readFileSync("/tmp/lifetime-write", "utf8"), + }; + } finally { + read.destroy(); + write.destroy(); + held = undefined; + } + }; +} diff --git a/src/compat/fs/stream-errors.ts b/src/compat/fs/stream-errors.ts new file mode 100644 index 0000000..cb63aa7 --- /dev/null +++ b/src/compat/fs/stream-errors.ts @@ -0,0 +1,15 @@ +export { + ERR_INVALID_ARG_VALUE, + ERR_OUT_OF_RANGE, + ERR_MISSING_ARGS, +} from "../net/errors.js"; +export { + ERR_METHOD_NOT_IMPLEMENTED, + ERR_STREAM_DESTROYED, +} from "../stream/errors.js"; +import { NodeError } from "../net/errors.js"; +export class ERR_SYSTEM_ERROR extends NodeError { + constructor(message: string) { + super("ERR_SYSTEM_ERROR", message); + } +} diff --git a/src/compat/fs/stream-utils.ts b/src/compat/fs/stream-utils.ts new file mode 100644 index 0000000..acf1708 --- /dev/null +++ b/src/compat/fs/stream-utils.ts @@ -0,0 +1,55 @@ +import { pathOf } from "./writable.js"; +import { fileMode } from "./mode.js"; +import { validateInteger } from "../crypto/validation.js"; +import { NodeTypeError } from "../net/errors.js"; +export { + validateAbortSignal, + validateBoolean, + validateFunction, + validateObject, + validateString, + validateUint32, +} from "../net/validators.js"; +export type FilePath = string | import("node:buffer").Buffer | URL; +export type Position = number | bigint | null; +export type WriteSyncOptions = { + offset?: number; + length?: number; + position?: Position; +}; +export type ValidEncoding = BufferEncoding | "buffer" | null; +const handles = new WeakSet(); +export function registerFileHandle(handle: object) { + handles.add(handle); +} +export function isFileHandle(handle: unknown) { + return typeof handle === "object" && handle !== null && handles.has(handle); +} +export function normalizePath(path: unknown) { + return new URL("file://" + pathOf(path)); +} +export function toPathIfFileURL(path: URL) { + return path.pathname; +} +export function getValidatedFd(fd: unknown) { + validateInteger(fd, "fd", 0, 2147483647); + return fd as number; +} +export function parseFileMode( + value: unknown, + _name: string, + fallback?: number, +) { + return fileMode(value, fallback); +} +export function validateThisInternalField( + value: any, + key: symbol, + name: string, +) { + if (!value || !Reflect.has(value, key)) + throw new NodeTypeError( + "ERR_INVALID_THIS", + `Value of "this" must be of type ${name}`, + ); +} diff --git a/src/compat/fs/streams.ts b/src/compat/fs/streams.ts new file mode 100644 index 0000000..8d3b69d --- /dev/null +++ b/src/compat/fs/streams.ts @@ -0,0 +1,1193 @@ +// @ts-nocheck +// Ported from workerd@5358e3d337336eaf696a48bc1563d808b10feba5 internal_fs_streams.ts. +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import { AsyncLocalStorage } from "../async-hooks/index.js"; +function bindFs(operations) { + const run = AsyncLocalStorage.snapshot(); + return Object.fromEntries( + Object.entries(operations).map(([name, fn]) => [ + name, + (...args) => run(() => Reflect.apply(fn, operations, args)), + ]), + ); +} +import type { + Readable as DeclaredReadable, + Writable as DeclaredWritable, +} from "node:stream"; +import { Readable } from "../stream/readable.js"; +import { Writable } from "../stream/writable.js"; +import { Buffer } from "node:buffer"; +import { type EventEmitter } from "../events/index.js"; +import { + normalizePath, + getValidatedFd, + isFileHandle, + type FilePath, + type Position, + type WriteSyncOptions, + type ValidEncoding, +} from "./stream-utils.js"; +import { toPathIfFileURL } from "./stream-utils.js"; + +import * as fs from "./index.js"; + +import { + parseFileMode, + validateAbortSignal, + validateBoolean, + validateFunction, + validateObject, + validateString, + validateUint32, + validateThisInternalField, +} from "./stream-utils.js"; + +type DoubleArgCallback = (error: any, a?: A, b?: B) => void; +type SingleArgCallback = (error: any, value?: A) => void; +type ErrorOnlyCallback = (error?: any) => void; +type OpenType = typeof import("node:fs").open; +type CloseType = typeof import("node:fs").close; +type FsyncType = typeof import("node:fs").fsync; +type ReadType = typeof import("node:fs").read; +type WriteType = typeof import("node:fs").write; +type WritevType = typeof import("node:fs").writev; +import type { FileHandle } from "./file-handle.js"; + +import { errorOrDestroy } from "../stream/destroy.js"; +import { eos } from "../stream/end-of-stream.js"; + +import { + ERR_INVALID_ARG_VALUE, + ERR_OUT_OF_RANGE, + ERR_MISSING_ARGS, + ERR_METHOD_NOT_IMPLEMENTED, + ERR_STREAM_DESTROYED, + ERR_SYSTEM_ERROR, +} from "./stream-errors.js"; + +import type { ReadAsyncOptions } from "node:fs"; +const reportError = (error: unknown) => { + if (typeof globalThis.reportError === "function") + globalThis.reportError(error); + else + queueMicrotask(() => { + throw error; + }); +}; + +export interface FsOperations { + open?: OpenType | undefined; + close?: CloseType | undefined; + fsync?: FsyncType | undefined; + read?: ReadType | undefined; + write?: WriteType | undefined; + writev?: WritevType | undefined; +} + +export interface RealizedFsOperations { + open: OpenType; + close: CloseType; + fsync: FsyncType; + read: ReadType; + write: WriteType; + writev: WritevType; +} + +// Temporary while developing... +/* eslint-disable */ + +let lazyFs: RealizedFsOperations | undefined; +async function getLazyFs(): Promise { + if (lazyFs == undefined) { + lazyFs = { + open: (...args): void => { + fs.open(...args); + }, + close: (...args): void => { + fs.close(...args); + }, + fsync: (...args): void => { + fs.fsync(...args); + }, + read: (...args): void => { + fs.read(...args); + }, + write: (...args): void => { + fs.write(...args); + }, + writev: (...args): void => { + fs.writev(...args); + }, + }; + } + return lazyFs; +} + +const kDefaultFsOperations: RealizedFsOperations = { + open( + path: FilePath, + flags: string | number | SingleArgCallback = "r", + mode: string | number | SingleArgCallback = 0o666, + cb?: SingleArgCallback, + ): void { + let callback: SingleArgCallback; + if (typeof flags === "function") { + callback = flags; + } else if (typeof mode === "function") { + callback = mode; + } else if (typeof cb === "function") { + callback = cb; + } else { + throw new ERR_MISSING_ARGS("fs.open callback"); + } + validateFunction(callback, "fs.open callback"); + const openFlags = typeof flags === "function" ? "r" : flags; + const openMode = typeof mode === "function" ? 0o666 : mode; + const done = (err: unknown, fd?: number): void => { + try { + if (err) callback(err); + else callback(null, fd); + } catch (e: unknown) { + reportError(e); + } + }; + + getLazyFs().then((fs: RealizedFsOperations) => { + // fs.open validates flags synchronously. + try { + fs.open(path, openFlags, openMode, done); + } catch (err: unknown) { + done(err); + } + }, done); + }, + close(fd: number, cb: ErrorOnlyCallback = () => {}): void { + getLazyFs().then( + (fs: RealizedFsOperations) => { + fs.close(fd, (err: unknown) => { + if (err) { + try { + cb(err); + } catch (e: unknown) { + reportError(e); + } + return; + } + try { + cb(null); + } catch (e: unknown) { + reportError(e); + } + }); + }, + (err: unknown) => { + try { + cb(err); + } catch (e: unknown) { + reportError(e); + } + }, + ); + }, + fsync(fd: number, cb: ErrorOnlyCallback = () => {}): void { + getLazyFs().then( + (fs: RealizedFsOperations) => { + fs.fsync(fd, (err: unknown) => { + if (err) { + try { + cb(err); + } catch (e: unknown) { + reportError(e); + } + return; + } + try { + cb(null); + } catch (e: unknown) { + reportError(e); + } + }); + }, + (err: unknown) => { + try { + cb(err); + } catch (e: unknown) { + reportError(e); + } + }, + ); + }, + read( + fd: number, + buffer: T | ReadAsyncOptions | DoubleArgCallback, + offset?: ReadAsyncOptions | number | DoubleArgCallback, + length?: null | number | DoubleArgCallback, + position?: Position, + cb?: DoubleArgCallback, + ): void { + getLazyFs().then( + (fs: RealizedFsOperations) => { + fs.read( + fd, + buffer, + offset, + length, + position, + ( + err: unknown, + bytesRead: number | undefined, + buffer: T | undefined, + ) => { + if (err) { + try { + cb?.(err); + } catch (e: unknown) { + reportError(e); + } + return; + } + try { + cb?.(null, bytesRead, buffer); + } catch (e: unknown) { + reportError(e); + } + }, + ); + }, + (err: unknown) => { + try { + cb?.(err); + } catch (e: unknown) { + reportError(e); + } + }, + ); + }, + write( + fd: number, + buffer: T | string, + offset?: WriteSyncOptions | Position | DoubleArgCallback, + length?: number | ValidEncoding | DoubleArgCallback, + position?: Position | DoubleArgCallback, + cb?: DoubleArgCallback, + ): void { + getLazyFs().then( + (fs: RealizedFsOperations) => { + fs.write( + fd, + buffer, + offset, + length, + position, + ( + err: unknown, + bytesWritten: number | undefined, + buffer: T | undefined, + ) => { + if (err) { + try { + cb?.(err); + } catch (e: unknown) { + reportError(e); + } + return; + } + try { + cb?.(null, bytesWritten, buffer); + } catch (e: unknown) { + reportError(e); + } + }, + ); + }, + (err: unknown) => { + try { + cb?.(err); + } catch (e: unknown) { + reportError(e); + } + }, + ); + }, + writev( + fd: number, + buffers: T[], + position?: Position | SingleArgCallback, + cb?: DoubleArgCallback, + ): void { + getLazyFs().then( + (fs: RealizedFsOperations) => { + fs.writev( + fd, + buffers, + position, + (err: unknown, bytesWritten?: number, buffers?: T[]) => { + if (err) { + try { + cb?.(err); + } catch (e: unknown) { + reportError(e); + } + return; + } + try { + cb?.(null, bytesWritten, buffers); + } catch (e: unknown) { + reportError(e); + } + }, + ); + }, + (err: unknown) => { + try { + cb?.(err); + } catch (e: unknown) { + reportError(e); + } + }, + ); + }, +}; + +export type ReadStreamOptions = { + encoding?: string | undefined; + autoClose?: boolean | undefined; + autoDestroy?: boolean | undefined; + emitClose?: boolean | undefined; + start?: number | undefined; + end?: number | undefined; + highWaterMark?: number | undefined; + signal?: AbortSignal | undefined; + flags?: string | undefined; + fd?: number | FileHandle | undefined; + mode?: number | undefined; + fs?: FsOperations | undefined; +}; + +const kFs = Symbol("kFs"); +const kIsPerformingIO = Symbol("kIsPerformingIO"); +const kIoDone = Symbol("kIoDone"); +const kHandle = Symbol("kHandle"); + +declare class ReadStreamInstance extends DeclaredReadable { + fd: number | null; + flags: string; + path: string; + mode: number; + start: number; + end: number; + pos: number | undefined; + bytesRead: number; + flush: boolean; + [kFs]: RealizedFsOperations; + [kIsPerformingIO]: boolean; + [kHandle]: FileHandle | undefined; + constructor(path: FilePath | null, options?: ReadStreamOptions); + push(chunk: NodeJS.ArrayBufferView | null): boolean; + close(callback?: ErrorOnlyCallback): void; +} +export type ReadStream = ReadStreamInstance; + +function construct( + this: ReadStream | WriteStream, + callback: (err: unknown) => void, +): void { + const stream = this; + if (typeof stream.fd === "number") { + callback(null); + return; + } + + const ee = stream as unknown as EventEmitter; + + if (typeof (stream as any).open === "function") { + // Backwards compat for monkey patching open(). + const orgEmit = ee.emit; + ee.emit = function (...args): boolean { + if (args[0] === "open") { + this.emit = orgEmit; + callback(null); + Reflect.apply(orgEmit, this, args); + } else if (args[0] === "error") { + this.emit = orgEmit; + callback(args[1]); + } else { + Reflect.apply(orgEmit, this, args); + } + return true; + }; + (stream as any).open(); + return; + } + stream[kFs].open( + stream.path, + stream.flags, + stream.mode, + (er: unknown, fd: number | undefined) => { + if (er) { + callback(er); + return; + } + if (fd === undefined) { + callback(new ERR_INVALID_ARG_VALUE("fd", "undefined")); + return; + } + stream.fd = fd; + callback(null); + ee.emit("open", stream.fd); + ee.emit("ready"); + }, + ); +} + +function getValidatedFsOptions(fs: FsOperations): RealizedFsOperations { + validateObject(fs, "options.fs"); + + if (isFileHandle(fs)) { + const handle = fs as unknown as FileHandle; + const open = function (..._args: unknown[]): void { + throw new ERR_METHOD_NOT_IMPLEMENTED("open()"); + }; + const close = function (...args: unknown[]): void { + const cb = args[args.length - 1] as ErrorOnlyCallback; + handle.close().then( + () => cb(null), + (err: unknown) => cb(err), + ); + }; + const fsync = function (...args: unknown[]): void { + const cb = args[args.length - 1] as ErrorOnlyCallback; + handle.sync().then( + () => cb(null), + (err: unknown) => cb(err), + ); + }; + const read = function (...args: unknown[]): void { + const cb = args[args.length - 1] as DoubleArgCallback< + number, + NodeJS.ArrayBufferView + >; + // @ts-expect-error TS2345 + handle.read(...args.slice(1, -1)).then( + (result: { bytesRead: number; buffer: NodeJS.ArrayBufferView }) => { + cb(null, result.bytesRead, result.buffer); + }, + (err: unknown) => cb(err), + ); + }; + const write = function (...args: unknown[]): void { + const cb = args[args.length - 1] as DoubleArgCallback< + number, + NodeJS.ArrayBufferView + >; + // @ts-expect-error TS2556 + handle.write(...args.slice(1, -1)).then( + (result: { bytesWritten: number; buffer: NodeJS.ArrayBufferView }) => { + cb(null, result.bytesWritten, result.buffer); + }, + (err: unknown) => cb(err), + ); + }; + const writev = function (...args: unknown[]): void { + const cb = args[args.length - 1] as DoubleArgCallback< + number, + NodeJS.ArrayBufferView[] + >; + // @ts-expect-error TS2556 + handle.writev(...args.slice(1, -1)).then( + (result: { + bytesWritten: number; + buffers: NodeJS.ArrayBufferView[]; + }) => { + cb(null, result.bytesWritten, result.buffers); + }, + (err: unknown) => cb(err), + ); + }; + return { + open, + close, + fsync, + read, + write, + writev, + }; + } + + let { + open = kDefaultFsOperations.open, + close = kDefaultFsOperations.close, + fsync = kDefaultFsOperations.fsync, + read = kDefaultFsOperations.read, + write = kDefaultFsOperations.write, + writev = kDefaultFsOperations.writev, + } = fs as FsOperations; + + validateFunction(open, "options.fs.open"); + validateFunction(read, "options.fs.read"); + validateFunction(close, "options.fs.close"); + validateFunction(fsync, "options.fs.fsync"); + validateFunction(write, "options.fs.write"); + validateFunction(writev, "options.fs.writev"); + return { open, close, fsync, read, write, writev }; +} + +function readImpl(this: ReadStream, n: number): void { + n = + this.pos !== undefined + ? Math.min(this.end - this.pos + 1, n) + : Math.min(this.end - this.bytesRead + 1, n); + if (n <= 0) { + this.push(null); + return; + } + + const buf = Buffer.allocUnsafeSlow(n); + const ee = this as unknown as EventEmitter; + + this[kIsPerformingIO] = true; + if (this.fd == null) { + this.push(null); + return; + } + this[kFs].read(this.fd, buf, 0, n, this.pos, (er, bytesRead, buf) => { + this[kIsPerformingIO] = false; + + // Tell ._destroy() that it's safe to close the fd now. + if (this.destroyed) { + ee.emit(kIoDone, er); + return; + } + + if (er) { + errorOrDestroy(this, er as Error); + return; + } + + if (buf == null) { + errorOrDestroy(this, new ERR_INVALID_ARG_VALUE("buf", "null")); + return; + } + + if (bytesRead != null && bytesRead > 0) { + if (this.pos !== undefined) { + this.pos += bytesRead; + } + + this.bytesRead += bytesRead; + + if (bytesRead !== buf.byteLength) { + // Slow path. Shrink to fit. + // Copy instead of slice so that we don't retain + // large backing buffer for small reads. + const dst = Buffer.allocUnsafeSlow(bytesRead); + if (Buffer.isBuffer(buf)) { + (buf as Buffer).copy(dst, 0, 0, bytesRead); + } else { + const buffer = Buffer.from(buf.buffer, buf.byteOffset, bytesRead); + buffer.copy(dst, 0, 0, bytesRead); + } + buf = dst; + } + + this.push(buf); + } else { + this.push(null); + } + }); +} + +function actualCloseImpl( + stream: ReadStream | WriteStream, + err: unknown, + cb: ErrorOnlyCallback, +): void { + if (stream.fd == null) return; + stream[kFs].close(stream.fd, (er) => { + cb(er || err); + }); + stream.fd = null; +} + +function closeImpl( + stream: ReadStream | WriteStream, + err: unknown, + cb: ErrorOnlyCallback, +): void { + if (stream.fd == null) { + cb(err); + } else if (stream.flush) { + stream[kFs].fsync(stream.fd, (flushErr) => { + actualCloseImpl(stream, err || flushErr, cb); + }); + } else { + actualCloseImpl(stream, err, cb); + } +} + +function destroyImpl( + this: ReadStream | WriteStream, + err: unknown, + cb: ErrorOnlyCallback, +): void { + // Usually for async IO it is safe to close a file descriptor + // even when there are pending operations. However, due to platform + // differences file IO is implemented using synchronous operations + // running in a thread pool. Therefore, file descriptors are not safe + // to close while used in a pending read or write operation. Wait for + // any pending IO (kIsPerformingIO) to complete (kIoDone). + const ee = this as unknown as EventEmitter; + if (this[kIsPerformingIO]) { + ee.once(kIoDone, (er) => { + closeImpl(this, err || er, cb); + }); + } else { + closeImpl(this, err, cb); + } +} + +export const ReadStream: typeof ReadStreamInstance & { + (path: FilePath | null, options?: ReadStreamOptions): ReadStream; +} = function ReadStream( + this: ReadStream, + path: FilePath, + options: ReadStreamOptions = {}, +): ReadStream { + if (!(this instanceof ReadStream)) { + return new ReadStream(path, options); + } + if (options === null) { + options = {}; + } else if (typeof options === "string") { + options = { encoding: options }; + } + + validateObject(options, "options"); + const { + encoding = null, + autoClose = true, + emitClose = true, + start = 0, + end = Infinity, + highWaterMark = 64 * 1024, + signal = null, + flags = "r", + fd = null, + mode = 0o666, + fs = kDefaultFsOperations, + } = options; + const autoDestroy = autoClose; + + if ( + encoding !== "buffer" && + encoding !== null && + !Buffer.isEncoding(encoding) + ) { + throw new ERR_INVALID_ARG_VALUE("options.encoding", encoding); + } + validateBoolean(autoClose, "options.autoClose"); + validateBoolean(emitClose, "options.emitClose"); + validateUint32(start, "options.start"); + if (end !== Infinity) { + validateUint32(end, "options.end"); + if (start > end) { + throw new ERR_OUT_OF_RANGE("start", `<= "end" (here: ${end})`, start); + } + } + validateUint32(highWaterMark, "options.highWaterMark"); + if (signal != null) { + validateAbortSignal(signal, "options.signal"); + } + + validateString(flags, "options.flags"); + + // We don't actually use the mode in our implementation. Parsing it is + // just to ensure that it is validated. + parseFileMode(mode, "options.mode", 0o666); + + this[kFs] = bindFs(getValidatedFsOptions(fs)); + + if (fd == null) { + this.fd = null; + // Path will be ignored when fd is specified, so it can be falsy + this.path = toPathIfFileURL(normalizePath(path)); + this.flags = options.flags === undefined ? "r" : options.flags; + this.mode = options.mode === undefined ? 0o666 : options.mode; + } else { + if (isFileHandle(fd)) { + if (fs !== kDefaultFsOperations) { + throw new ERR_METHOD_NOT_IMPLEMENTED("FileHandle with fs"); + } + this[kHandle] = fd as FileHandle; + this[kFs] = bindFs(getValidatedFsOptions(fd as unknown as FsOperations)); + const ee = fd as unknown as EventEmitter; + ee.on("close", () => this.close()); + this.fd = (fd as FileHandle).fd || null; + } else { + this.fd = getValidatedFd(fd as number); + } + } + + this.start = start; + this.end = end; + this.pos = start; + this.bytesRead = 0; + this[kIsPerformingIO] = false; + + Reflect.apply(Readable, this, [ + { + highWaterMark, + encoding, + emitClose, + autoDestroy, + signal, + construct: construct.bind(this), + read: readImpl.bind(this), + destroy: destroyImpl.bind(this), + }, + ]); + return this; +}; +Object.setPrototypeOf(ReadStream.prototype, Readable.prototype); +Object.setPrototypeOf(ReadStream, Readable); + +Object.defineProperty(ReadStream.prototype, "autoClose", { + get(this: ReadStream): boolean { + validateThisInternalField(this, kFs, "ReadStream"); + return this._readableState?.autoDestroy || false; + }, + set(this: ReadStream, _val: boolean): void { + validateThisInternalField(this, kFs, "ReadStream"); + if (this._readableState !== undefined) { + this._readableState.autoDestroy = _val; + } + }, +}); + +ReadStream.prototype.close = function ( + cb: ErrorOnlyCallback = (_err: unknown): void => {}, +): void { + if (typeof cb === "function") eos(this, cb); + this.destroy(); +}; + +Object.defineProperty(ReadStream.prototype, "pending", { + get(this: ReadStream): boolean { + return this.fd === null; + }, + configurable: true, +}); + +// ====================================================================================== + +export type WriteStreamOptions = { + encoding?: string | undefined; + autoClose?: boolean | undefined; + autoDestroy?: boolean | undefined; + emitClose?: boolean | undefined; + start?: number | undefined; + highWaterMark?: number | undefined; + signal?: AbortSignal | undefined; + flags?: string | undefined; + fd?: number | FileHandle | undefined; + mode?: number | undefined; + fs?: FsOperations | undefined; + flush?: boolean | undefined; +}; + +declare type WriteVChunk = { + chunk: string | NodeJS.ArrayBufferView; + encoding: ValidEncoding; +}; + +declare class WriteStreamInstance extends DeclaredWritable { + fd: number | null; + path: string; + flags: string; + mode: number; + flush: boolean; + autoClose: boolean; + destroyed: boolean; + start: number; + pos: number; + bytesRead: number; + bytesWritten: number; + [kIsPerformingIO]: boolean; + [kFs]: RealizedFsOperations; + [kHandle]?: FileHandle; + constructor(path: FilePath, options?: WriteStreamOptions); + close(cb?: ErrorOnlyCallback): void; + destroySoon(): void; +} +export type WriteStream = WriteStreamInstance; + +function writeAll( + this: WriteStream, + data: string | NodeJS.ArrayBufferView, + size: number, + pos: number, + cb: ErrorOnlyCallback, + retries = 0, +) { + if (this.fd == null) { + return cb(new ERR_INVALID_ARG_VALUE("fd", "null")); + } + + this[kFs].write( + this.fd, + data, + 0, + size, + pos, + ( + er: unknown, + bytesWritten?: number, + buffer?: string | NodeJS.ArrayBufferView, + ) => { + // No data currently available and operation should be retried later. + if ((er as any)?.code === "EAGAIN") { + er = null; + bytesWritten = 0; + } + + if (this.destroyed || er) { + return cb(er || new ERR_STREAM_DESTROYED("write")); + } + + // The value should be set but let's suppress the possible + // typescript error here. + bytesWritten = bytesWritten ?? 0; + + this.bytesWritten += bytesWritten; + + retries = bytesWritten ? 0 : retries + 1; + size -= bytesWritten; + pos += bytesWritten; + + // Try writing non-zero number of bytes up to 5 times. + if (retries > 5) { + cb(new ERR_SYSTEM_ERROR("write failed")); + } else if (size) { + if (buffer == null) { + cb(null); + return; + } + const buf = + typeof buffer === "string" + ? Buffer.from(buffer as string) + : Buffer.from(buffer.buffer, buffer.byteOffset, buffer.byteLength); + writeAll.call(this, buf.slice(bytesWritten), size, pos, cb, retries); + } else { + cb(null); + } + }, + ); +} + +function writevAll( + this: WriteStream, + chunks: NodeJS.ArrayBufferView[], + size: number, + pos: number, + cb: ErrorOnlyCallback, + retries = 0, +) { + if (this.fd == null) { + return cb(new ERR_INVALID_ARG_VALUE("fd", "null")); + } + this[kFs].writev( + this.fd, + chunks, + this.pos, + ( + er: unknown, + bytesWritten?: number, + buffers?: NodeJS.ArrayBufferView[], + ) => { + // No data currently available and operation should be retried later. + if ((er as any)?.code === "EAGAIN") { + er = null; + bytesWritten = 0; + } + + if (this.destroyed || er) { + return cb(er || new ERR_STREAM_DESTROYED("writev")); + } + + bytesWritten = bytesWritten ?? 0; + + this.bytesWritten += bytesWritten; + + retries = bytesWritten ? 0 : retries + 1; + size -= bytesWritten; + pos += bytesWritten; + + // Try writing non-zero number of bytes up to 5 times. + if (retries > 5) { + cb(new ERR_SYSTEM_ERROR("writev failed")); + } else if (size) { + buffers ??= []; + const bufs = buffers.map((b): Buffer => { + return Buffer.from(b.buffer, b.byteOffset, b.byteLength); + }); + if (buffers.length === 0) { + cb(null); + return; + } + writevAll.call( + this, + [Buffer.concat(bufs).slice(bytesWritten)], + size, + pos, + cb, + retries, + ); + } else { + cb(null); + } + }, + ); +} + +function writeImpl( + this: WriteStream, + data: string | NodeJS.ArrayBufferView, + encodingOrCallback: ValidEncoding | ErrorOnlyCallback | undefined, + cb?: ErrorOnlyCallback, +): void { + let callback: ErrorOnlyCallback; + let encoding: ValidEncoding = null; + if (typeof encodingOrCallback === "function") { + callback = encodingOrCallback; + } else if (encodingOrCallback != null) { + encoding = encodingOrCallback; + if (cb !== undefined) callback = cb; + } + // @ts-expect-error TS2345 + validateFunction(callback, "write callback"); + + if (typeof data === "string") { + data = Buffer.from(data, encoding as any); + } + + this[kIsPerformingIO] = true; + const ee = this as unknown as EventEmitter; + writeAll.call(this, data, data.byteLength, this.pos, (er: unknown) => { + this[kIsPerformingIO] = false; + if (this.destroyed) { + // Tell ._destroy() that it's safe to close the fd now. + callback(er); + ee.emit(kIoDone, er); + return; + } + + callback(er); + }); + + if (this.pos !== undefined) this.pos += data.byteLength; +} + +function writevImpl( + this: WriteStream, + data: WriteVChunk[], + callback: ErrorOnlyCallback, +) { + let size = 0; + const chunks = data.map((d) => { + const chunk = (d as any).chunk; + size += chunk.length; + return chunk; + }); + + this[kIsPerformingIO] = true; + writevAll.call(this, chunks, size, this.pos, (er: unknown) => { + this[kIsPerformingIO] = false; + const ee = this as unknown as EventEmitter; + if (this.destroyed) { + // Tell ._destroy() that it's safe to close the fd now. + callback(er); + ee.emit(kIoDone, er); + return; + } + + callback(er); + }); + + if (this.pos !== undefined) this.pos += size; +} + +export const WriteStream: typeof WriteStreamInstance & { + (path: FilePath, options?: WriteStreamOptions): WriteStream; +} = function WriteStream( + this: WriteStream, + path: FilePath, + options: WriteStreamOptions = {}, +): WriteStream { + if (!(this instanceof WriteStream)) { + return new WriteStream(path, options); + } + + if (options === null) { + options = {}; + } else if (typeof options === "string") { + options = { encoding: options }; + } + + validateObject(options, "options"); + const { + encoding = null, + autoClose = true, + emitClose = true, + start = 0, + highWaterMark = 64 * 1024, + signal = null, + flags = "w", + fd = null, + mode = 0o666, + fs = kDefaultFsOperations, + } = options; + let { flush = false } = options; + const autoDestroy = autoClose; + + if ( + encoding !== "buffer" && + encoding !== null && + !Buffer.isEncoding(encoding) + ) { + throw new ERR_INVALID_ARG_VALUE("options.encoding", encoding); + } + validateBoolean(autoClose, "options.autoClose"); + validateBoolean(emitClose, "options.emitClose"); + if (flush === null) flush = false; + validateBoolean(flush, "options.flush"); + validateUint32(start, "options.start"); + validateUint32(highWaterMark, "options.highWaterMark"); + if (signal != null) { + validateAbortSignal(signal, "options.signal"); + } + + validateString(flags, "options.flags"); + + // We don't actually use the mode in our implementation. Parsing it is + // just to ensure that it is validated. + parseFileMode(mode, "options.mode", 0o666); + + this[kFs] = bindFs(getValidatedFsOptions(fs)); + this.flush = flush; + + if (fd == null) { + this.fd = null; + // Path will be ignored when fd is specified, so it can be falsy + this.path = toPathIfFileURL(normalizePath(path)); + this.flags = options.flags === undefined ? "w" : options.flags; + this.mode = options.mode === undefined ? 0o666 : options.mode; + } else { + if (isFileHandle(fd)) { + if (fs !== kDefaultFsOperations) { + throw new ERR_METHOD_NOT_IMPLEMENTED("FileHandle with fs"); + } + this[kHandle] = fd as FileHandle; + this[kFs] = bindFs(getValidatedFsOptions(fd as unknown as FsOperations)); + const ee = fd as unknown as EventEmitter; + ee.on("close", () => this.close()); + this.fd = (fd as FileHandle).fd || null; + } else { + this.fd = getValidatedFd(fd as number); + } + } + + this.start = start; + this.pos = start; + this.bytesRead = 0; + this.bytesWritten = 0; + this[kIsPerformingIO] = false; + + Reflect.apply(Writable, this, [ + { + highWaterMark, + encoding, + emitClose, + autoDestroy, + signal, + construct: construct.bind(this), + write: writeImpl.bind(this), + writev: writevImpl.bind(this), + destroy: destroyImpl.bind(this), + }, + ]); + + if (encoding != null && encoding !== "buffer") { + (this as unknown as Writable).setDefaultEncoding( + encoding as BufferEncoding, + ); + } + + return this; +}; +Object.setPrototypeOf(WriteStream.prototype, Writable.prototype); +Object.setPrototypeOf(WriteStream, Writable); + +Object.defineProperty(WriteStream.prototype, "autoClose", { + get(this: WriteStream): boolean { + validateThisInternalField(this, kFs, "WriteStream"); + return this._writableState?.autoDestroy || false; + }, + set(this: WriteStream, val: boolean): void { + validateThisInternalField(this, kFs, "WriteStream"); + if (this._writableState !== undefined) { + this._writableState.autoDestroy = val; + } + }, +}); + +WriteStream.prototype.close = function ( + this: WriteStream, + cb: ErrorOnlyCallback = (_err: unknown): void => {}, +): void { + const writable = this as unknown as Writable; + const ee = this as unknown as EventEmitter; + if (cb) { + if (writable.closed) { + queueMicrotask(() => cb(null)); + return; + } + ee.on("close", cb); + } + + // If we are not autoClosing, we should call + // destroy on 'finish'. + if (!this.autoClose) { + ee.on("finish", () => writable.destroy()); + } + + // We use end() instead of destroy() because of + // https://github.com/nodejs/node/issues/2006 + writable.end(); +}; + +// There is no shutdown() for files. +WriteStream.prototype.destroySoon = WriteStream.prototype.end; + +Object.defineProperty(WriteStream.prototype, "pending", { + get(this: WriteStream): boolean { + return this.fd === null; + }, + configurable: true, +}); + +export function createReadStream( + path: FilePath, + options: ReadStreamOptions = {}, +): ReadStream { + return new ReadStream(path, options); +} +export function createWriteStream( + path: FilePath, + options: WriteStreamOptions = {}, +): WriteStream { + return new WriteStream(path, options); +} diff --git a/src/compat/fs/upstream-access.fixture.mjs b/src/compat/fs/upstream-access.fixture.mjs new file mode 100644 index 0000000..5295c51 --- /dev/null +++ b/src/compat/fs/upstream-access.fixture.mjs @@ -0,0 +1,354 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +import { ok, rejects, strictEqual, throws } from "node:assert"; + +// Tests node:fs access and exists APIs +// These tests are not a straight up port of the Node.js tests but have +// been adapted and verified to match the behavior of the Node.js APIs +// as closely as possible. + +import { + accessSync, + existsSync, + access, + exists, + promises, + constants, + fstatSync, + closeSync, + openSync, + writeSync, + writeFileSync, + readSync, +} from "./index.js"; + +const { F_OK, R_OK, W_OK, X_OK } = constants; + +strictEqual(typeof accessSync, "function"); +strictEqual(typeof access, "function"); +strictEqual(typeof existsSync, "function"); +strictEqual(typeof exists, "function"); +strictEqual(typeof promises, "object"); +strictEqual(typeof promises.access, "function"); + +strictEqual(typeof F_OK, "number"); +strictEqual(typeof R_OK, "number"); +strictEqual(typeof W_OK, "number"); +strictEqual(typeof X_OK, "number"); +strictEqual(F_OK, 0); +strictEqual(X_OK, 1); +strictEqual(W_OK, 2); +strictEqual(R_OK, 4); + +const kKnownPaths = [ + { path: "/", writable: false }, + { path: "/bundle", writable: false }, + { path: "/bundle/worker", writable: false }, + { path: "/dev", writable: false }, + { path: "/dev/null", writable: true }, + { path: "/dev/zero", writable: true }, + { path: "/dev/full", writable: true }, + { path: "/dev/random", writable: true }, + { path: "/tmp", writable: true }, +]; + +const UV_ENOENT = -2; + +const kNoEntError = { + code: "ENOENT", + errno: UV_ENOENT, + syscall: "access", +}; + +const kInvalidArgTypeError = { code: "ERR_INVALID_ARG_TYPE" }; +const kOutOfRangeError = { code: "ERR_OUT_OF_RANGE" }; + +function mustNotCall() { + throw new Error("This function should not be called"); +} + +export const accessInputValidationTest = { + async test() { + // For both sync and async callback versions, input validation errors + // are thrown synchronously. + [ + 123n, + { + [Symbol.toPrimitive]() { + return R_OK; + }, + }, + "/", + ].forEach((i) => { + throws(() => accessSync("/", i), kInvalidArgTypeError); + throws(() => access("/", i, mustNotCall), kInvalidArgTypeError); + throws(() => access("/", F_OK, i), kInvalidArgTypeError); + }); + throws(() => accessSync(123), kInvalidArgTypeError); + throws(() => access(123, "", mustNotCall), kInvalidArgTypeError); + + // Mode is out of range + throws(() => accessSync("/", -1), kOutOfRangeError); + throws( + () => accessSync("/", (F_OK | R_OK | W_OK | X_OK) + 1), + kOutOfRangeError, + ); + + // For the promise version, input validation errors cause rejected promises. + await rejects(promises.access("/", 123n), kInvalidArgTypeError); + await rejects(promises.access(123), kInvalidArgTypeError); + await rejects(promises.access("/", -1), kOutOfRangeError); + await rejects( + promises.access("/", (F_OK | R_OK | W_OK | X_OK) + 1), + kOutOfRangeError, + ); + }, +}; + +export const accessSyncTest = { + test() { + kKnownPaths.forEach(({ path, writable }) => { + const bufferPath = Buffer.from(path); + const urlPath = new URL(path, "file:///"); + accessSync(path); + accessSync(bufferPath); + accessSync(urlPath); + accessSync(path, F_OK); + accessSync(bufferPath, F_OK); + accessSync(urlPath, F_OK); + accessSync(path, R_OK); + accessSync(bufferPath, R_OK); + accessSync(urlPath, R_OK); + + // Some paths are writable (W_OK) + if (writable) { + accessSync(path, W_OK); + accessSync(bufferPath, W_OK); + accessSync(urlPath, W_OK); + } else { + throws(() => accessSync(path, W_OK), kNoEntError); + throws(() => accessSync(bufferPath, W_OK), kNoEntError); + throws(() => accessSync(urlPath, W_OK), kNoEntError); + } + + // No path is executable (X_OK) + throws(() => accessSync(path, X_OK), kNoEntError); + throws(() => accessSync(bufferPath, X_OK), kNoEntError); + throws(() => accessSync(urlPath, X_OK), kNoEntError); + }); + + // Paths that don't exist... + throws(() => accessSync("/does/not/exist"), kNoEntError); + throws(() => accessSync("/does/not/exist", F_OK), kNoEntError); + throws(() => accessSync("/does/not/exist", R_OK), kNoEntError); + throws(() => accessSync("/does/not/exist", W_OK), kNoEntError); + throws(() => accessSync("/does/not/exist", X_OK), kNoEntError); + }, +}; + +export const accessCallbackTest = { + async test() { + const doTest = (path, mode) => { + const { promise, resolve, reject } = Promise.withResolvers(); + access(path, mode, (err) => { + if (err) reject(err); + else resolve(); + }); + return promise; + }; + + const cases = []; + kKnownPaths.forEach(({ path, writable }) => { + const bufferPath = Buffer.from(path); + const urlPath = new URL(path, "file:///"); + cases.push( + doTest(path, F_OK), + doTest(bufferPath, F_OK), + doTest(urlPath, F_OK), + doTest(path, R_OK), + doTest(bufferPath, R_OK), + doTest(urlPath, R_OK), + ); + if (writable) { + cases.push( + doTest(path, W_OK), + doTest(bufferPath, W_OK), + doTest(urlPath, W_OK), + ); + } else { + cases.push( + rejects(doTest(path, W_OK), kNoEntError), + rejects(doTest(bufferPath, W_OK), kNoEntError), + rejects(doTest(urlPath, W_OK), kNoEntError), + ); + } + // No paths are executable (X_OK) + cases.push( + rejects(doTest(path, X_OK), kNoEntError), + rejects(doTest(bufferPath, X_OK), kNoEntError), + rejects(doTest(urlPath, X_OK), kNoEntError), + ); + }); + // Paths that don't exist have no permissions. + cases.push( + rejects(doTest("/does/not/exist", F_OK), kNoEntError), + rejects(doTest("/does/not/exist", R_OK), kNoEntError), + rejects(doTest("/does/not/exist", W_OK), kNoEntError), + rejects(doTest("/does/not/exist", X_OK), kNoEntError), + ); + strictEqual(cases.length, kKnownPaths.length * 12 + 4); + await Promise.all(cases); + }, +}; + +export const accessPromiseTest = { + async test() { + const cases = []; + kKnownPaths.forEach(({ path, writable }) => { + const bufferPath = Buffer.from(path); + const urlPath = new URL(path, "file:///"); + cases.push( + promises.access(path, F_OK), + promises.access(bufferPath, F_OK), + promises.access(urlPath, F_OK), + promises.access(path, R_OK), + promises.access(bufferPath, R_OK), + promises.access(urlPath, R_OK), + ); + if (writable) { + cases.push( + promises.access(path, W_OK), + promises.access(bufferPath, W_OK), + promises.access(urlPath, W_OK), + ); + } else { + cases.push( + rejects(promises.access(path, W_OK), kNoEntError), + rejects(promises.access(bufferPath, W_OK), kNoEntError), + rejects(promises.access(urlPath, W_OK), kNoEntError), + ); + } + // No paths are executable (X_OK) + cases.push( + rejects(promises.access(path, X_OK), kNoEntError), + rejects(promises.access(bufferPath, X_OK), kNoEntError), + rejects(promises.access(urlPath, X_OK), kNoEntError), + ); + }); + // Paths that don't exist have no permissions. + cases.push( + rejects(promises.access("/does/not/exist", F_OK), kNoEntError), + rejects(promises.access("/does/not/exist", R_OK), kNoEntError), + rejects(promises.access("/does/not/exist", W_OK), kNoEntError), + rejects(promises.access("/does/not/exist", X_OK), kNoEntError), + ); + strictEqual(cases.length, kKnownPaths.length * 12 + 4); + await Promise.all(cases); + }, +}; + +export const existsSyncTest = { + test() { + kKnownPaths.forEach(({ path }) => { + const bufferPath = Buffer.from(path); + const urlPath = new URL(path, "file:///"); + ok(existsSync(path)); + ok(existsSync(bufferPath)); + ok(existsSync(urlPath)); + }); + + // Paths that don't exist + ok(!existsSync("/does/not/exist")); + // Incorrect inputs types results in false returned + ok(!existsSync(123)); + }, +}; + +export const existsCallbackTest = { + async test() { + const doTest = (path, expected) => { + const { promise, resolve, reject } = Promise.withResolvers(); + exists(path, (exists) => { + if (exists !== expected) + reject(new Error(`Expected ${path} to be ${expected}`)); + else resolve(); + }); + return promise; + }; + + const cases = []; + kKnownPaths.forEach(({ path }) => { + const bufferPath = Buffer.from(path); + const urlPath = new URL(path, "file:///"); + cases.push( + doTest(path, true), + doTest(bufferPath, true), + doTest(urlPath, true), + ); + }); + // Paths that don't exist + cases.push(doTest("/does/not/exist", false)); + // Incorrect inputs types results in false returned + cases.push(doTest(123, false)); + + strictEqual(cases.length, kKnownPaths.length * 3 + 2); + await Promise.all(cases); + }, +}; + +export const stdioFds = { + test() { + let stat1 = fstatSync(0); + let stat2 = fstatSync(1); + let stat3 = fstatSync(2); + ok(stat1.isFile()); + ok(stat2.isFile()); + ok(stat3.isFile()); + closeSync(0); + closeSync(1); + closeSync(2); + // After closing the stdio file descriptors, they can still be used. + // That is, we don't actually close them. + stat1 = fstatSync(0); + stat2 = fstatSync(1); + stat3 = fstatSync(2); + ok(stat1.isFile()); + ok(stat2.isFile()); + ok(stat3.isFile()); + + // Writing to the stdio file descriptors is permitted, always in append mode. + // Position is ignored. + writeSync(0, "Hello, stdin!\n", 1000); + writeSync(0, "test", 500); + writeSync(1, "Hello, stdin!\n", 1000); + writeSync(1, "test", 500); + writeSync(2, "Hello, stdin!\n", 1000); + writeSync(2, "test", 500); + + stat1 = fstatSync(0); + strictEqual(stat1.size, 0); + + const buf = Buffer.alloc(20); + const _bytesRead = readSync(0, buf, { position: 1000 }); + + const fd = openSync("/tmp/test.txt", "w"); + strictEqual(fd, 3); + }, +}; +// There is no promise version of exists in Node.js, so no test for it here. + +export const fsPercentEncodingTest = { + test() { + // The ? and # in the string path will be percent-encoded as part of the + // normalization, and the empty // in the path will be replaced as /. + // the %FF is left as is. + const path = "/tmp//?#a%FF"; + ok(!existsSync("/tmp/%3F%23a%FF")); + writeFileSync(path, "test"); + + // The casing of the percent-encoded characters is not significant. + ok(existsSync("/tmp/%3f%23a%FF")); + ok(existsSync("/tmp/%3F%23a%ff")); + }, +}; diff --git a/src/compat/fs/upstream-chown-chmod.fixture.mjs b/src/compat/fs/upstream-chown-chmod.fixture.mjs new file mode 100644 index 0000000..a197271 --- /dev/null +++ b/src/compat/fs/upstream-chown-chmod.fixture.mjs @@ -0,0 +1,594 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +import { rejects, strictEqual, throws } from "node:assert"; + +// Tests node:fs chmod and chown APIS +// These tests are not a straight up port of the Node.js tests but have +// been adapted and verified to match the behavior of the Node.js APIs +// as closely as possible. + +// Our implementation of these APIs is mostly a non-op. The most we do +// is validate the input arguments and verify that the file exists and +// is accessible. The actual chown and chmod operations are not performed +// since we do not have a concept of users, groups, or posix-style permissions. + +import { + openSync, + closeSync, + statSync, + fstatSync, + lstatSync, + symlinkSync, + chmod, + lchmod, + fchmod, + chmodSync, + lchmodSync, + fchmodSync, + chown, + lchown, + fchown, + chownSync, + lchownSync, + fchownSync, + promises, +} from "./index.js"; + +strictEqual(typeof openSync, "function"); +strictEqual(typeof closeSync, "function"); +strictEqual(typeof statSync, "function"); +strictEqual(typeof fstatSync, "function"); +strictEqual(typeof lstatSync, "function"); +strictEqual(typeof symlinkSync, "function"); +strictEqual(typeof chmod, "function"); +strictEqual(typeof lchmod, "function"); +strictEqual(typeof fchmod, "function"); +strictEqual(typeof chmodSync, "function"); +strictEqual(typeof lchmodSync, "function"); +strictEqual(typeof fchmodSync, "function"); +strictEqual(typeof chown, "function"); +strictEqual(typeof lchown, "function"); +strictEqual(typeof fchown, "function"); +strictEqual(typeof chownSync, "function"); +strictEqual(typeof lchownSync, "function"); +strictEqual(typeof fchownSync, "function"); +strictEqual(typeof promises.chown, "function"); +strictEqual(typeof promises.lchown, "function"); + +const kInvalidArgTypeError = { code: "ERR_INVALID_ARG_TYPE" }; +const kOutOfRangeError = { code: "ERR_OUT_OF_RANGE" }; + +function checkStat(path) { + const { uid, gid } = statSync(path); + strictEqual(uid, 0); + strictEqual(gid, 0); +} + +function checkfStat(fd) { + const { uid, gid } = fstatSync(fd); + strictEqual(uid, 0); + strictEqual(gid, 0); +} + +const path = "/tmp"; +const bufferPath = Buffer.from(path); +const urlPath = new URL(path, "file:///"); + +export const chownSyncTest = { + test() { + // Incorrect input types should throw. + throws(() => chownSync(123), kInvalidArgTypeError); + throws(() => chownSync("/", {}), kInvalidArgTypeError); + throws(() => chownSync("/", 0, {}), kInvalidArgTypeError); + throws(() => chownSync(path, -1000, 0), kOutOfRangeError); + throws(() => chownSync(path, 0, -1000), kOutOfRangeError); + + // We stat the file before and after to verify the impact + // of the chown operation. Specifically, the uid and gid + // should not change since our impl is a non-op. + checkStat(path); + chownSync(path, 1000, 1000); + checkStat(path); + + chownSync(bufferPath, 1000, 1000); + checkStat(bufferPath); + + chownSync(urlPath, 1000, 1000); + checkStat(urlPath); + + // A non-existent path should throw ENOENT + throws(() => chownSync("/non-existent-path", 1000, 1000), { + code: "ENOENT", + // Access because it is an access check under the covers. + syscall: "chown", + }); + }, +}; + +export const chownCallbackTest = { + async test() { + // Incorrect input types should throw synchronously + throws(() => chown(123), kInvalidArgTypeError); + throws(() => chown("/", {}), kInvalidArgTypeError); + throws(() => chown("/", 0, {}), kInvalidArgTypeError); + throws(() => chownSync(path, -1000, 0), kOutOfRangeError); + throws(() => chownSync(path, 0, -1000), kOutOfRangeError); + + async function callChown(path) { + const { promise, resolve, reject } = Promise.withResolvers(); + chown(path, 1000, 1000, (err) => { + if (err) return reject(err); + resolve(); + }); + await promise; + } + + // Should be non-op + checkStat(path); + await callChown(path); + checkStat(path); + + await callChown(bufferPath); + checkStat(bufferPath); + + await callChown(urlPath); + checkStat(urlPath); + + // A non-existent path should throw ENOENT + const { promise, resolve, reject } = Promise.withResolvers(); + chown("/non-existent-path", 1000, 1000, (err) => { + if (err) return reject(err); + resolve(); + }); + await rejects(promise, { + code: "ENOENT", + // Access because it is an access check under the covers. + syscall: "chown", + }); + }, +}; + +export const chownPromiseTest = { + async test() { + // Incorrect input types should reject the promise. + await rejects(promises.chown(123), kInvalidArgTypeError); + await rejects(promises.chown("/", {}), kInvalidArgTypeError); + await rejects(promises.chown("/", 0, {}), kInvalidArgTypeError); + await rejects(promises.chown(path, -1000, 0), kOutOfRangeError); + await rejects(promises.chown(path, 0, -1000), kOutOfRangeError); + + // Should be non-op + checkStat(path); + await promises.chown(path, 1000, 1000); + checkStat(path); + + await promises.chown(bufferPath, 1000, 1000); + checkStat(bufferPath); + + await promises.chown(urlPath, 1000, 1000); + checkStat(urlPath); + + // A non-existent path should throw ENOENT + await rejects(promises.chown("/non-existent-path", 1000, 1000), { + code: "ENOENT", + // Access because it is an access check under the covers. + syscall: "chown", + }); + }, +}; + +export const lchownSyncTest = { + test() { + // Incorrect input types should throw. + throws(() => lchownSync(123), kInvalidArgTypeError); + throws(() => lchownSync("/", {}), kInvalidArgTypeError); + throws(() => lchownSync("/", 0, {}), kInvalidArgTypeError); + throws(() => lchownSync(path, -1000, 0), kOutOfRangeError); + throws(() => lchownSync(path, 0, -1000), kOutOfRangeError); + + // We stat the file before and after to verify the impact + // of the chown operation. Specifically, the uid and gid + // should not change since our impl is a non-op. + checkStat(path); + lchownSync(path, 1000, 1000); + checkStat(path); + + lchownSync(bufferPath, 1000, 1000); + checkStat(bufferPath); + + lchownSync(urlPath, 1000, 1000); + checkStat(urlPath); + + // A non-existent path should throw ENOENT + throws(() => lchownSync("/non-existent-path", 1000, 1000), { + code: "ENOENT", + // Access because it is an access check under the covers. + syscall: "lchown", + }); + }, +}; + +export const lchownCallbackTest = { + async test() { + // Incorrect input types should throw synchronously + throws(() => lchown(123), kInvalidArgTypeError); + throws(() => lchown("/", {}), kInvalidArgTypeError); + throws(() => lchown("/", 0, {}), kInvalidArgTypeError); + throws(() => lchownSync(path, -1000, 0), kOutOfRangeError); + throws(() => lchownSync(path, 0, -1000), kOutOfRangeError); + + async function callChown(path) { + const { promise, resolve, reject } = Promise.withResolvers(); + lchown(path, 1000, 1000, (err) => { + if (err) return reject(err); + resolve(); + }); + await promise; + } + + // Should be non-op + checkStat(path); + await callChown(path); + checkStat(path); + + await callChown(bufferPath); + checkStat(bufferPath); + + await callChown(urlPath); + checkStat(urlPath); + + // A non-existent path should throw ENOENT + const { promise, resolve, reject } = Promise.withResolvers(); + lchown("/non-existent-path", 1000, 1000, (err) => { + if (err) return reject(err); + resolve(); + }); + await rejects(promise, { + code: "ENOENT", + // Access because it is an access check under the covers. + syscall: "lchown", + }); + }, +}; + +export const lchownPromiseTest = { + async test() { + // Incorrect input types should reject the promise. + await rejects(promises.lchown(123), kInvalidArgTypeError); + await rejects(promises.lchown("/", {}), kInvalidArgTypeError); + await rejects(promises.lchown("/", 0, {}), kInvalidArgTypeError); + await rejects(promises.lchown(path, -1000, 0), kOutOfRangeError); + await rejects(promises.lchown(path, 0, -1000), kOutOfRangeError); + + // Should be non-op + checkStat(path); + await promises.lchown(path, 1000, 1000); + checkStat(path); + + await promises.lchown(bufferPath, 1000, 1000); + checkStat(bufferPath); + + await promises.lchown(urlPath, 1000, 1000); + checkStat(urlPath); + + // A non-existent path should throw ENOENT + await rejects(promises.lchown("/non-existent-path", 1000, 1000), { + code: "ENOENT", + syscall: "lchown", + }); + }, +}; + +export const fchownSyncTest = { + test() { + // Incorrect input types should throw. + throws(() => fchownSync({}), kInvalidArgTypeError); + throws(() => fchownSync(123), kInvalidArgTypeError); + throws(() => fchownSync(123, {}), kInvalidArgTypeError); + throws(() => fchownSync(123, 0, {}), kInvalidArgTypeError); + throws(() => fchownSync(123, -1000, 0), kOutOfRangeError); + throws(() => fchownSync(123, 0, -1000), kOutOfRangeError); + + const fd = openSync("/tmp"); + + // We stat the file before and after to verify the impact + // of the chown operation. Specifically, the uid and gid + // should not change since our impl is a non-op. + checkfStat(fd); + fchownSync(fd, 1000, 1000); + checkfStat(fd); + + throws(() => fchownSync(999, 1000, 1000), { + code: "EBADF", + syscall: "fstat", + }); + + closeSync(fd); + }, +}; + +export const fchownCallbackTest = { + async test() { + // Incorrect input types should throw synchronously + throws(() => fchown({}), kInvalidArgTypeError); + throws(() => fchown(123), kInvalidArgTypeError); + throws(() => fchown(123, {}), kInvalidArgTypeError); + throws(() => fchown(123, 0, {}), kInvalidArgTypeError); + throws(() => fchown(123, -1000, 0), kOutOfRangeError); + throws(() => fchown(123, 0, -1000), kOutOfRangeError); + + const fd = openSync("/tmp"); + + async function callChown() { + const { promise, resolve, reject } = Promise.withResolvers(); + fchown(fd, 1000, 1000, (err) => { + if (err) return reject(err); + resolve(); + }); + await promise; + } + + // Should be non-op + checkfStat(fd); + await callChown(); + checkfStat(fd); + + const { promise, resolve, reject } = Promise.withResolvers(); + fchown(999, 1000, 1000, (err) => { + if (err) return reject(err); + resolve(); + }); + await rejects(promise, { + code: "EBADF", + syscall: "fstat", + }); + + closeSync(fd); + }, +}; + +// =========================================================================== + +export const chmodSyncTest = { + test() { + // Incorrect input types should throw. + throws(() => chmodSync(123), kInvalidArgTypeError); + throws(() => chmodSync("/", {}), kInvalidArgTypeError); + throws(() => chmodSync("/tmp", -1), kOutOfRangeError); + + // Should be non-op + checkStat(path); + chmodSync(path, 0o777); + checkStat(path); + + chmodSync(bufferPath, 0o777); + checkStat(bufferPath); + + chmodSync(urlPath, 0o777); + checkStat(urlPath); + + throws(() => chmodSync("/non-existent-path", 0o777), { + code: "ENOENT", + // Access because it is an access check under the covers. + syscall: "chmod", + }); + }, +}; + +export const chmodCallbackTest = { + async test() { + // Incorrect input types should throw. + throws(() => chmod(123), kInvalidArgTypeError); + throws(() => chmod("/", {}), kInvalidArgTypeError); + throws(() => chmod("/tmp", -1), kOutOfRangeError); + + async function callChmod(path) { + const { promise, resolve, reject } = Promise.withResolvers(); + chmod(path, 0o000, (err) => { + if (err) return reject(err); + resolve(); + }); + await promise; + } + + checkStat(path); + await callChmod(path); + checkStat(path); + + await callChmod(bufferPath); + checkStat(bufferPath); + + await callChmod(urlPath); + checkStat(bufferPath); + + const { promise, resolve, reject } = Promise.withResolvers(); + chmod("/non-existent-path", 0o777, (err) => { + if (err) return reject(err); + resolve(); + }); + await rejects(promise, { + code: "ENOENT", + // Access because it is an access check under the covers. + syscall: "chmod", + }); + }, +}; + +export const chmodPromiseTest = { + async test() { + // Incorrect input types should reject the promise. + await rejects(promises.chmod(123), kInvalidArgTypeError); + await rejects(promises.chmod("/", {}), kInvalidArgTypeError); + await rejects(promises.chmod("/tmp", -1), kOutOfRangeError); + + // Should be non-op + checkStat(path); + await promises.chmod(path, 0o777); + checkStat(path); + + await promises.chmod(bufferPath, 0o777); + checkStat(bufferPath); + + await promises.chmod(urlPath, 0o777); + checkStat(urlPath); + + await rejects(promises.chmod("/non-existent-path", 0o777), { + code: "ENOENT", + syscall: "chmod", + }); + }, +}; + +export const lchmodSyncTest = { + test() { + // Incorrect input types should throw. + throws(() => lchmodSync(123), kInvalidArgTypeError); + throws(() => lchmodSync("/", {}), kInvalidArgTypeError); + throws(() => lchmodSync("/tmp", -1), kOutOfRangeError); + + // Should be non-op + checkStat(path); + lchmodSync(path, 0o777); + checkStat(path); + + lchmodSync(bufferPath, 0o777); + checkStat(bufferPath); + + lchmodSync(urlPath, 0o777); + checkStat(urlPath); + + throws(() => lchmodSync("/non-existent-path", 0o777), { + code: "ENOENT", + // Access because it is an access check under the covers. + syscall: "lchmod", + }); + }, +}; + +export const lchmodCallbackTest = { + async test() { + // Incorrect input types should throw. + throws(() => lchmod(123), kInvalidArgTypeError); + throws(() => lchmod("/", {}), kInvalidArgTypeError); + throws(() => lchmod("/tmp", -1), kOutOfRangeError); + + async function callChmod(path) { + const { promise, resolve, reject } = Promise.withResolvers(); + lchmod(path, 0o000, (err) => { + if (err) return reject(err); + resolve(); + }); + await promise; + } + + checkStat(path); + await callChmod(path); + checkStat(path); + + await callChmod(bufferPath); + checkStat(bufferPath); + + await callChmod(urlPath); + checkStat(bufferPath); + + const { promise, resolve, reject } = Promise.withResolvers(); + lchmod("/non-existent-path", 0o777, (err) => { + if (err) return reject(err); + resolve(); + }); + await rejects(promise, { + code: "ENOENT", + // Access because it is an access check under the covers. + syscall: "lchmod", + }); + }, +}; + +export const lchmodPromiseTest = { + async test() { + // Incorrect input types should reject the promise. + await rejects(promises.lchmod(123), kInvalidArgTypeError); + await rejects(promises.lchmod("/", {}), kInvalidArgTypeError); + await rejects(promises.lchmod("/tmp", -1), kOutOfRangeError); + + // Should be non-op + checkStat(path); + await promises.lchmod(path, 0o777); + checkStat(path); + + await promises.lchmod(bufferPath, 0o777); + checkStat(bufferPath); + + await promises.lchmod(urlPath, 0o777); + checkStat(urlPath); + + await rejects(promises.lchmod("/non-existent-path", 0o777), { + code: "ENOENT", + syscall: "lchmod", + }); + }, +}; + +export const fchmodSyncTest = { + test() { + // Incorrect input types should throw. + throws(() => fchmodSync({}), kInvalidArgTypeError); + throws(() => fchmodSync(123), kInvalidArgTypeError); + throws(() => fchmodSync(123, {}), kInvalidArgTypeError); + throws(() => fchmodSync(123, -1000), kOutOfRangeError); + + const fd = openSync("/tmp"); + + // We stat the file before and after to verify the impact + // of the chown operation. Specifically, the uid and gid + // should not change since our impl is a non-op. + checkfStat(fd); + fchmodSync(fd, 0o777); + checkfStat(fd); + + throws(() => fchmodSync(999, 0o777), { + code: "EBADF", + syscall: "fstat", + }); + + closeSync(fd); + }, +}; + +export const fchmodCallbackTest = { + async test() { + // Incorrect input types should throw synchronously + throws(() => fchmod({}), kInvalidArgTypeError); + throws(() => fchmod(123), kInvalidArgTypeError); + throws(() => fchmod(123, {}), kInvalidArgTypeError); + + const fd = openSync("/tmp"); + + async function callChmod() { + const { promise, resolve, reject } = Promise.withResolvers(); + fchmod(fd, 0o777, (err) => { + if (err) return reject(err); + resolve(); + }); + await promise; + } + + // Should be non-op + checkfStat(fd); + await callChmod(); + checkfStat(fd); + + const { promise, resolve, reject } = Promise.withResolvers(); + fchmod(999, 0o777, (err) => { + if (err) return reject(err); + resolve(); + }); + await rejects(promise, { + code: "EBADF", + syscall: "fstat", + }); + + closeSync(fd); + }, +}; diff --git a/src/compat/fs/upstream-cp-into-subdirectory.fixture.mjs b/src/compat/fs/upstream-cp-into-subdirectory.fixture.mjs new file mode 100644 index 0000000..3b491c0 --- /dev/null +++ b/src/compat/fs/upstream-cp-into-subdirectory.fixture.mjs @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +import { + cpSync, + mkdirSync, + readFileSync, + readdirSync, + symlinkSync, + writeFileSync, +} from "./index.js"; +import { strictEqual, throws } from "node:assert"; + +export const FsCpIntoSubdirectory = { + test() { + mkdirSync(new URL("file:///tmp/src"), { recursive: true }); + writeFileSync(new URL("file:///tmp/src/file.txt"), "test"); + + throws( + () => + cpSync(new URL("file:///tmp/src"), new URL("file:///tmp/src/dest"), { + recursive: true, + }), + { + code: "ERR_FS_CP_EINVAL", + }, + ); + }, +}; + +export const FsCpDestinationSymlinkToSource = { + test() { + const src = new URL("file:///tmp/src"); + const dest = new URL("file:///tmp/dest"); + + mkdirSync(new URL("file:///tmp/src/child"), { recursive: true }); + writeFileSync(new URL("file:///tmp/src/child/payload.txt"), "payload"); + writeFileSync(new URL("file:///tmp/src/a.txt"), "a"); + strictEqual(readdirSync(src).length, 2); + + mkdirSync(dest); + // /tmp/dest/child --> /tmp/src + symlinkSync(src, new URL("file:///tmp/dest/child")); + + cpSync(src, dest, { recursive: true, dereference: true }); + strictEqual(readFileSync(new URL("file:///tmp/dest/a.txt"), "utf8"), "a"); + strictEqual( + readFileSync(new URL("file:///tmp/src/payload.txt"), "utf8"), + "payload", + ); + strictEqual( + readFileSync(new URL("file:///tmp/src/child/payload.txt"), "utf8"), + "payload", + ); + }, +}; diff --git a/src/compat/fs/upstream-cp.fixture.mjs b/src/compat/fs/upstream-cp.fixture.mjs new file mode 100644 index 0000000..1a1c101 --- /dev/null +++ b/src/compat/fs/upstream-cp.fixture.mjs @@ -0,0 +1,2149 @@ +// Copyright (c) 2025 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +import { + writeFileSync, + mkdirSync, + symlinkSync, + cpSync, + cp, + existsSync, + readFileSync, + lstatSync, + statSync, + promises as fsPromises, +} from "./index.js"; + +import { ok, deepStrictEqual, strictEqual, throws, rejects } from "node:assert"; + +const pathA = new URL("file:///tmp/a"); // File +const pathB = new URL("file:///tmp/b"); // Directory +const pathBE = new URL("file:///tmp/b/e"); // File in directory +const pathC = new URL("file:///tmp/c"); // Symlink to file +const pathD = new URL("file:///tmp/d"); // Symlink to directory +const pathE = new URL("file:///tmp/e"); // Directory +const pathF = new URL("file:///tmp/f"); // Path to copy to + +function setupPaths() { + writeFileSync(pathA, "foo"); + mkdirSync(pathB); + symlinkSync(pathA, pathC); + symlinkSync(pathB, pathD); + mkdirSync(pathE); + writeFileSync(pathBE, "bar"); +} + +export const simpleFileCopy = { + test() { + setupPaths(); + ok(existsSync(pathA)); + deepStrictEqual(readFileSync(pathA, "utf8"), "foo"); + ok(!existsSync(pathF)); + cpSync(pathA, pathF); + ok(existsSync(pathF)); + deepStrictEqual(readFileSync(pathF, "utf8"), "foo"); + writeFileSync(pathA, "baz"); + deepStrictEqual(readFileSync(pathA, "utf8"), "baz"); + deepStrictEqual(readFileSync(pathF, "utf8"), "foo"); + cpSync(pathA, pathF); + cpSync(pathA, pathF, { errorOnExist: true }); // force is true by default, weird but true. + + // Copying a file when the destination already exists and force is false and + // errorOnExist is true, should throw an error. + throws(() => cpSync(pathA, pathF, { force: false, errorOnExist: true }), { + code: "ERR_FS_CP_EEXIST", + }); + + // Copying a file to a directory should throw an error. + throws(() => cpSync(pathA, pathB), { + code: "ERR_FS_CP_NON_DIR_TO_DIR", + }); + + // Copy a file to a symlink to a directory should throw by default + throws(() => cpSync(pathA, pathD, { dereference: true }), { + code: "ERR_FS_CP_NON_DIR_TO_DIR", + }); + + ok(statSync(pathD).isDirectory()); + ok(lstatSync(pathD).isSymbolicLink()); + cpSync(pathA, pathD); + ok(lstatSync(pathD).isFile()); + }, +}; + +// Copy file to non-existent directory (should fail) +export const copyFileToNonExistentDirectory = { + test() { + setupPaths(); + // The directory will be created by cpSync if it doesn't exist. + const pathG = new URL("file:///tmp/nonexistent"); + const pathH = new URL("file:///tmp/nonexistent/g"); + ok(!existsSync(pathG)); + ok(!existsSync(pathH)); + cpSync(pathA, pathH); + ok(existsSync(pathG)); + ok(existsSync(pathH)); + }, +}; + +// Copy file to existing file with force +export const copyFileToExistingFileWithForce = { + test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + writeFileSync(pathG, "original"); + cpSync(pathA, pathG, { force: true }); + deepStrictEqual(readFileSync(pathG, "utf8"), "foo"); + }, +}; + +// Copy file to existing file without force +export const copyFileToExistingFileWithoutForce = { + test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + writeFileSync(pathG, "original"); + // When force is false, it should not overwrite the existing file. + cpSync(pathA, pathG, { force: false }); + deepStrictEqual(readFileSync(pathG, "utf8"), "original"); + }, +}; + +// Copy file to existing directory +export const copyFileToExistingDirectory = { + test() { + setupPaths(); + throws(() => cpSync(pathA, pathB), { + code: "ERR_FS_CP_NON_DIR_TO_DIR", + }); + }, +}; + +// Copy file to existing symlink to directory +export const copyFileToExistingSymlinkToDirectory = { + test() { + setupPaths(); + // With dereference: true, should throw EISDIR + throws(() => cpSync(pathA, pathD, { dereference: true }), { + code: "ERR_FS_CP_NON_DIR_TO_DIR", + }); + + // Without dereference (default), should replace the symlink + cpSync(pathA, pathD); + ok(lstatSync(pathD).isFile()); + deepStrictEqual(readFileSync(pathD, "utf8"), "foo"); + }, +}; + +// Copy directory to non-existent directory +export const copyDirectoryToNonExistentDirectory = { + test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + cpSync(pathB, pathG, { recursive: true }); + ok(existsSync(pathG)); + ok(lstatSync(pathG).isDirectory()); + ok(existsSync(new URL("file:///tmp/g/e"))); + deepStrictEqual(readFileSync(new URL("file:///tmp/g/e"), "utf8"), "bar"); + }, +}; + +// Copy directory to existing directory +export const copyDirectoryToExistingDirectory = { + test() { + setupPaths(); + cpSync(pathB, pathE, { recursive: true }); + ok(existsSync(new URL("file:///tmp/e/e"))); + deepStrictEqual(readFileSync(new URL("file:///tmp/e/e"), "utf8"), "bar"); + }, +}; + +// Copy directory to existing file +export const copyDirectoryToExistingFile = { + test() { + setupPaths(); + throws(() => cpSync(pathB, pathA, { recursive: true }), { + code: "ERR_FS_CP_DIR_TO_NON_DIR", + }); + }, +}; + +// Copy directory to existing symlink to file +export const copyDirectoryToExistingSymlinkToFile = { + test() { + setupPaths(); + throws(() => cpSync(pathB, pathC, { recursive: true, dereference: true }), { + code: "ERR_FS_CP_DIR_TO_NON_DIR", + }); + }, +}; + +// Copy symlink to file +export const copySymlinkToFile = { + test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + + // With dereference: false (default), should copy the symlink + cpSync(pathC, pathG); + ok(lstatSync(pathG).isSymbolicLink()); + deepStrictEqual(readFileSync(pathG, "utf8"), "foo"); + + // With dereference: true, should copy the target file + const pathH = new URL("file:///tmp/h"); + cpSync(pathC, pathH, { dereference: true }); + ok(lstatSync(pathH).isFile()); + deepStrictEqual(readFileSync(pathH, "utf8"), "foo"); + }, +}; + +// Copy symlink to directory +export const copySymlinkToDirectory = { + test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + + // With dereference: false (default), should copy the symlink + cpSync(pathD, pathG); + ok(lstatSync(pathG).isSymbolicLink()); + ok(statSync(pathG).isDirectory()); + + // With dereference: true, should copy the target directory + const pathH = new URL("file:///tmp/h"); + cpSync(pathD, pathH, { recursive: true, dereference: true }); + ok(lstatSync(pathH).isDirectory()); + ok(existsSync(new URL("file:///tmp/h/e"))); + }, +}; + +// Copy symlink to existing file +export const copySymlinkToExistingFile = { + test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + writeFileSync(pathG, "original"); + + // Should replace the file with symlink + cpSync(pathC, pathG); + ok(lstatSync(pathG).isSymbolicLink()); + deepStrictEqual(readFileSync(pathG, "utf8"), "foo"); + }, +}; + +// Copy symlink to existing directory +export const copySymlinkToExistingDirectory = { + test() { + setupPaths(); + throws(() => cpSync(pathC, pathE), { + code: "ENOTDIR", + }); + }, +}; + +// Copy symlink to existing symlink to directory +export const copySymlinkToExistingSymlinkToDirectory = { + test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + const pathH = new URL("file:///tmp/h"); + mkdirSync(pathH); + symlinkSync(pathH, pathG); + + // Should replace the symlink + cpSync(pathD, pathG); + ok(lstatSync(pathG).isSymbolicLink()); + ok(statSync(pathG).isDirectory()); + }, +}; + +// Copy symlink operations with various option combinations +export const copySymlinkWithDereferenceOptions = { + test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + const pathH = new URL("file:///tmp/h"); + const pathI = new URL("file:///tmp/i"); + + // Test with force: false, errorOnExist: true + symlinkSync(pathA, pathG); + throws(() => cpSync(pathC, pathG, { force: false, errorOnExist: true }), { + code: "ERR_FS_CP_EEXIST", + }); + + // Test with force: false, errorOnExist: false + cpSync(pathC, pathG, { force: false, errorOnExist: false }); + ok(lstatSync(pathG).isSymbolicLink()); + + // Test with dereference and errorOnExist combinations + writeFileSync(pathH, "existing"); + throws( + () => + cpSync(pathC, pathH, { + dereference: true, + force: false, + errorOnExist: true, + }), + { + code: "ERR_FS_CP_EEXIST", + }, + ); + + // Test copying to non-existent path + cpSync(pathC, pathI); + ok(lstatSync(pathI).isSymbolicLink()); + deepStrictEqual(readFileSync(pathI, "utf8"), "foo"); + }, +}; + +// Option validation tests +export const optionValidationTests = { + test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + + // Test invalid option types + throws(() => cpSync(pathA, pathG, { dereference: "invalid" }), { + code: "ERR_INVALID_ARG_TYPE", + }); + + throws(() => cpSync(pathA, pathG, { errorOnExist: "invalid" }), { + code: "ERR_INVALID_ARG_TYPE", + }); + + throws(() => cpSync(pathA, pathG, { force: "invalid" }), { + code: "ERR_INVALID_ARG_TYPE", + }); + + throws(() => cpSync(pathA, pathG, { recursive: "invalid" }), { + code: "ERR_INVALID_ARG_TYPE", + }); + + throws(() => cpSync(pathA, pathG, { preserveTimestamps: "invalid" }), { + code: "ERR_INVALID_ARG_TYPE", + }); + + throws(() => cpSync(pathA, pathG, { verbatimSymlinks: "invalid" }), { + code: "ERR_INVALID_ARG_TYPE", + }); + + // Test invalid filter type + throws(() => cpSync(pathA, pathG, { filter: "not-a-function" }), { + code: "ERR_INVALID_ARG_TYPE", + }); + + // Test filter option throws unsupported operation + throws(() => cpSync(pathA, pathG, { filter: () => true }), { + code: "ERR_UNSUPPORTED_OPERATION", + }); + + // Test COPYFILE_FICLONE_FORCE mode + throws(() => cpSync(pathA, pathG, { mode: 4 }), { + // COPYFILE_FICLONE_FORCE = 4 + code: "ERR_INVALID_ARG_VALUE", + }); + }, +}; + +// Recursive option tests +export const recursiveOptionTests = { + test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + const pathH = new URL("file:///tmp/h"); + + // Test copying directory without recursive option should fail + throws(() => cpSync(pathB, pathG), { + code: "ERR_FS_EISDIR", + }); + + // Test with recursive: false explicitly + throws(() => cpSync(pathB, pathG, { recursive: false }), { + code: "ERR_FS_EISDIR", + }); + + // Test copying directory with recursive: true should work + cpSync(pathB, pathG, { recursive: true }); + ok(existsSync(pathG)); + ok(lstatSync(pathG).isDirectory()); + ok(existsSync(new URL("file:///tmp/g/e"))); + + // Test deep directory structure + mkdirSync(new URL("file:///tmp/deep")); + mkdirSync(new URL("file:///tmp/deep/nested")); + mkdirSync(new URL("file:///tmp/deep/nested/very")); + writeFileSync(new URL("file:///tmp/deep/nested/very/deep.txt"), "content"); + + cpSync(new URL("file:///tmp/deep"), pathH, { recursive: true }); + ok(existsSync(new URL("file:///tmp/h/nested/very/deep.txt"))); + deepStrictEqual( + readFileSync(new URL("file:///tmp/h/nested/very/deep.txt"), "utf8"), + "content", + ); + }, +}; + +// PreserveTimestamps option tests +export const preserveTimestampsTests = { + test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + const pathH = new URL("file:///tmp/h"); + + // Get original timestamps + const originalStat = lstatSync(pathA); + const originalMtime = originalStat.mtime; + + // Copy without preserveTimestamps (default behavior) + cpSync(pathA, pathG); + const _copiedStat = lstatSync(pathG); + + // Copy with preserveTimestamps: true + cpSync(pathA, pathH, { preserveTimestamps: true }); + const preservedStat = lstatSync(pathH); + + // The preserved timestamps should match the original + // Note: This test depends on the implementation actually preserving timestamps + // Since the implementation is noted as having this option, we test it + ok( + Math.abs(preservedStat.mtime.getTime() - originalMtime.getTime()) < 1000, + ); + }, +}; + +// Error handling edge cases +export const errorHandlingTests = { + test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + const nonExistent = new URL("file:///tmp/nonexistent"); + + // Test copying from non-existent source + throws(() => cpSync(nonExistent, pathG), { + code: "ENOENT", + }); + }, +}; + +// Symlink edge cases +export const symlinkEdgeCases = { + test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + const pathH = new URL("file:///tmp/h"); + const pathI = new URL("file:///tmp/i"); + const pathJ = new URL("file:///tmp/j"); + + // Create a broken symlink + const brokenTarget = new URL("file:///tmp/broken-target"); + symlinkSync(brokenTarget, pathG); + + // Test copying broken symlink without dereference + cpSync(pathG, pathH); + ok(lstatSync(pathH).isSymbolicLink()); + + // Test copying broken symlink with dereference should fail + throws(() => cpSync(pathG, pathI, { dereference: true }), { + code: "ENOENT", + }); + + // Create symlink chain: J -> I -> A + symlinkSync(pathA, pathI); + symlinkSync(pathI, pathJ); + + // Test copying symlink chain + const pathK = new URL("file:///tmp/k"); + cpSync(pathJ, pathK); + ok(lstatSync(pathK).isSymbolicLink()); + deepStrictEqual(readFileSync(pathK, "utf8"), "foo"); + }, +}; + +// Combined options tests +export const combinedOptionsTests = { + test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + + // Test multiple options together + cpSync(pathB, pathG, { + recursive: true, + dereference: true, + force: true, + preserveTimestamps: true, + errorOnExist: false, + }); + + ok(existsSync(pathG)); + ok(lstatSync(pathG).isDirectory()); + }, +}; + +// Path type tests +export const pathTypeTests = { + test() { + setupPaths(); + + // Test with Buffer paths + const pathG = new URL("file:///tmp/g"); + const bufferSrc = Buffer.from("/tmp/a"); + const bufferDest = Buffer.from("/tmp/g"); + + // Note: The implementation uses normalizePath which should handle Buffer inputs + // This tests the path normalization behavior + cpSync(bufferSrc, bufferDest); + ok(existsSync(pathG)); + deepStrictEqual(readFileSync(pathG, "utf8"), "foo"); + }, +}; + +// ===== CALLBACK-BASED API TESTS ===== + +// Simple file copy - callback version +export const simpleFileCopyCallback = { + async test() { + setupPaths(); + ok(existsSync(pathA)); + deepStrictEqual(readFileSync(pathA, "utf8"), "foo"); + ok(!existsSync(pathF)); + + await new Promise((resolve, reject) => { + cp(pathA, pathF, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + ok(existsSync(pathF)); + deepStrictEqual(readFileSync(pathF, "utf8"), "foo"); + writeFileSync(pathA, "baz"); + deepStrictEqual(readFileSync(pathA, "utf8"), "baz"); + deepStrictEqual(readFileSync(pathF, "utf8"), "foo"); + + // Test with errorOnExist: true + await new Promise((resolve, reject) => { + cp(pathA, pathF, { errorOnExist: true }, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + // Test error case: force: false, errorOnExist: true + await new Promise((resolve) => { + cp(pathA, pathF, { force: false, errorOnExist: true }, (err) => { + ok(err); + strictEqual(err.code, "ERR_FS_CP_EEXIST"); + resolve(); + }); + }); + + // Test error case: copying file to directory + await new Promise((resolve) => { + cp(pathA, pathB, (err) => { + ok(err); + strictEqual(err.code, "ERR_FS_CP_NON_DIR_TO_DIR"); + resolve(); + }); + }); + + // Test copying to symlink to directory + await new Promise((resolve) => { + cp(pathA, pathD, { dereference: true }, (err) => { + ok(err); + strictEqual(err.code, "ERR_FS_CP_NON_DIR_TO_DIR"); + resolve(); + }); + }); + + // Test copying to symlink without dereference + await new Promise((resolve, reject) => { + cp(pathA, pathD, (err) => { + if (err) reject(err); + else { + ok(lstatSync(pathD).isFile()); + resolve(); + } + }); + }); + }, +}; + +// Copy file to non-existent directory - callback version +export const copyFileToNonExistentDirectoryCallback = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/nonexistent"); + const pathH = new URL("file:///tmp/nonexistent/g"); + ok(!existsSync(pathG)); + ok(!existsSync(pathH)); + + await new Promise((resolve, reject) => { + cp(pathA, pathH, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + ok(existsSync(pathG)); + ok(existsSync(pathH)); + }, +}; + +// Copy file to existing file with force - callback version +export const copyFileToExistingFileWithForceCallback = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + writeFileSync(pathG, "original"); + + await new Promise((resolve, reject) => { + cp(pathA, pathG, { force: true }, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + deepStrictEqual(readFileSync(pathG, "utf8"), "foo"); + }, +}; + +// Copy file to existing file without force - callback version +export const copyFileToExistingFileWithoutForceCallback = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + writeFileSync(pathG, "original"); + + await new Promise((resolve, reject) => { + cp(pathA, pathG, { force: false }, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + deepStrictEqual(readFileSync(pathG, "utf8"), "original"); + }, +}; + +// Copy file to existing directory - callback version +export const copyFileToExistingDirectoryCallback = { + async test() { + setupPaths(); + + await new Promise((resolve) => { + cp(pathA, pathB, (err) => { + ok(err); + strictEqual(err.code, "ERR_FS_CP_NON_DIR_TO_DIR"); + resolve(); + }); + }); + }, +}; + +// Copy file to existing symlink to directory - callback version +export const copyFileToExistingSymlinkToDirectoryCallback = { + async test() { + setupPaths(); + + // With dereference: true, should throw EISDIR + await new Promise((resolve) => { + cp(pathA, pathD, { dereference: true }, (err) => { + ok(err); + strictEqual(err.code, "ERR_FS_CP_NON_DIR_TO_DIR"); + resolve(); + }); + }); + + // Without dereference (default), should replace the symlink + await new Promise((resolve, reject) => { + cp(pathA, pathD, (err) => { + if (err) reject(err); + else { + ok(lstatSync(pathD).isFile()); + deepStrictEqual(readFileSync(pathD, "utf8"), "foo"); + resolve(); + } + }); + }); + }, +}; + +// Copy directory to non-existent directory - callback version +export const copyDirectoryToNonExistentDirectoryCallback = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + + await new Promise((resolve, reject) => { + cp(pathB, pathG, { recursive: true }, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + ok(existsSync(pathG)); + ok(lstatSync(pathG).isDirectory()); + ok(existsSync(new URL("file:///tmp/g/e"))); + deepStrictEqual(readFileSync(new URL("file:///tmp/g/e"), "utf8"), "bar"); + }, +}; + +// Copy directory to existing directory - callback version +export const copyDirectoryToExistingDirectoryCallback = { + async test() { + setupPaths(); + + await new Promise((resolve, reject) => { + cp(pathB, pathE, { recursive: true }, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + ok(existsSync(new URL("file:///tmp/e/e"))); + deepStrictEqual(readFileSync(new URL("file:///tmp/e/e"), "utf8"), "bar"); + }, +}; + +// Copy directory to existing file - callback version +export const copyDirectoryToExistingFileCallback = { + async test() { + setupPaths(); + + await new Promise((resolve) => { + cp(pathB, pathA, { recursive: true }, (err) => { + ok(err); + strictEqual(err.code, "ERR_FS_CP_DIR_TO_NON_DIR"); + resolve(); + }); + }); + }, +}; + +// Copy directory to existing symlink to file - callback version +export const copyDirectoryToExistingSymlinkToFileCallback = { + async test() { + setupPaths(); + + await new Promise((resolve) => { + cp(pathB, pathC, { recursive: true, dereference: true }, (err) => { + ok(err); + strictEqual(err.code, "ERR_FS_CP_DIR_TO_NON_DIR"); + resolve(); + }); + }); + }, +}; + +// Copy symlink to file - callback version +export const copySymlinkToFileCallback = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + + // With dereference: false (default), should copy the symlink + await new Promise((resolve, reject) => { + cp(pathC, pathG, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + ok(lstatSync(pathG).isSymbolicLink()); + deepStrictEqual(readFileSync(pathG, "utf8"), "foo"); + + // With dereference: true, should copy the target file + const pathH = new URL("file:///tmp/h"); + await new Promise((resolve, reject) => { + cp(pathC, pathH, { dereference: true }, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + ok(lstatSync(pathH).isFile()); + deepStrictEqual(readFileSync(pathH, "utf8"), "foo"); + }, +}; + +// Copy symlink to directory - callback version +export const copySymlinkToDirectoryCallback = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + + // With dereference: false (default), should copy the symlink + await new Promise((resolve, reject) => { + cp(pathD, pathG, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + ok(lstatSync(pathG).isSymbolicLink()); + ok(statSync(pathG).isDirectory()); + + // With dereference: true, should copy the target directory + const pathH = new URL("file:///tmp/h"); + await new Promise((resolve, reject) => { + cp(pathD, pathH, { recursive: true, dereference: true }, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + ok(lstatSync(pathH).isDirectory()); + ok(existsSync(new URL("file:///tmp/h/e"))); + }, +}; + +// Copy symlink to existing file - callback version +export const copySymlinkToExistingFileCallback = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + writeFileSync(pathG, "original"); + + // Should replace the file with symlink + await new Promise((resolve, reject) => { + cp(pathC, pathG, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + ok(lstatSync(pathG).isSymbolicLink()); + deepStrictEqual(readFileSync(pathG, "utf8"), "foo"); + }, +}; + +// Copy symlink to existing directory - callback version +export const copySymlinkToExistingDirectoryCallback = { + async test() { + setupPaths(); + + await new Promise((resolve) => { + cp(pathC, pathE, (err) => { + ok(err); + strictEqual(err.code, "ENOTDIR"); + resolve(); + }); + }); + }, +}; + +// Copy symlink to existing symlink to directory - callback version +export const copySymlinkToExistingSymlinkToDirectoryCallback = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + const pathH = new URL("file:///tmp/h"); + mkdirSync(pathH); + symlinkSync(pathH, pathG); + + // Should replace the symlink + await new Promise((resolve, reject) => { + cp(pathD, pathG, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + ok(lstatSync(pathG).isSymbolicLink()); + ok(statSync(pathG).isDirectory()); + }, +}; + +// Copy symlink operations with various option combinations - callback version +export const copySymlinkWithDereferenceOptionsCallback = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + const pathH = new URL("file:///tmp/h"); + const pathI = new URL("file:///tmp/i"); + + // Test with force: false, errorOnExist: true + symlinkSync(pathA, pathG); + await new Promise((resolve) => { + cp(pathC, pathG, { force: false, errorOnExist: true }, (err) => { + ok(err); + strictEqual(err.code, "ERR_FS_CP_EEXIST"); + resolve(); + }); + }); + + // Test with force: false, errorOnExist: false + await new Promise((resolve, reject) => { + cp(pathC, pathG, { force: false, errorOnExist: false }, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + ok(lstatSync(pathG).isSymbolicLink()); + + // Test with dereference and errorOnExist combinations + writeFileSync(pathH, "existing"); + await new Promise((resolve) => { + cp( + pathC, + pathH, + { dereference: true, force: false, errorOnExist: true }, + (err) => { + ok(err); + strictEqual(err.code, "ERR_FS_CP_EEXIST"); + resolve(); + }, + ); + }); + + // Test copying to non-existent path + await new Promise((resolve, reject) => { + cp(pathC, pathI, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + ok(lstatSync(pathI).isSymbolicLink()); + deepStrictEqual(readFileSync(pathI, "utf8"), "foo"); + }, +}; + +// Option validation tests - callback version +export const optionValidationTestsCallback = { + test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + + // Test invalid option types - these throw synchronously + throws(() => cp(pathA, pathG, { dereference: "invalid" }, () => {}), { + code: "ERR_INVALID_ARG_TYPE", + }); + + throws(() => cp(pathA, pathG, { errorOnExist: "invalid" }, () => {}), { + code: "ERR_INVALID_ARG_TYPE", + }); + + throws(() => cp(pathA, pathG, { force: "invalid" }, () => {}), { + code: "ERR_INVALID_ARG_TYPE", + }); + + throws(() => cp(pathA, pathG, { recursive: "invalid" }, () => {}), { + code: "ERR_INVALID_ARG_TYPE", + }); + + throws( + () => cp(pathA, pathG, { preserveTimestamps: "invalid" }, () => {}), + { + code: "ERR_INVALID_ARG_TYPE", + }, + ); + + throws(() => cp(pathA, pathG, { verbatimSymlinks: "invalid" }, () => {}), { + code: "ERR_INVALID_ARG_TYPE", + }); + + // Test invalid filter type - throws synchronously + throws(() => cp(pathA, pathG, { filter: "not-a-function" }, () => {}), { + code: "ERR_INVALID_ARG_TYPE", + }); + + // Test filter option throws unsupported operation - throws synchronously + throws(() => cp(pathA, pathG, { filter: () => true }, () => {}), { + code: "ERR_UNSUPPORTED_OPERATION", + }); + + // Test COPYFILE_FICLONE_FORCE mode - throws synchronously + throws(() => cp(pathA, pathG, { mode: 4 }, () => {}), { + code: "ERR_INVALID_ARG_VALUE", + }); + }, +}; + +// Recursive option tests - callback version +export const recursiveOptionTestsCallback = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + const pathH = new URL("file:///tmp/h"); + + // Test copying directory without recursive option should fail + await new Promise((resolve) => { + cp(pathB, pathG, (err) => { + ok(err); + strictEqual(err.code, "ERR_FS_EISDIR"); + resolve(); + }); + }); + + // Test with recursive: false explicitly + await new Promise((resolve) => { + cp(pathB, pathG, { recursive: false }, (err) => { + ok(err); + strictEqual(err.code, "ERR_FS_EISDIR"); + resolve(); + }); + }); + + // Test copying directory with recursive: true should work + await new Promise((resolve, reject) => { + cp(pathB, pathG, { recursive: true }, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + ok(existsSync(pathG)); + ok(lstatSync(pathG).isDirectory()); + ok(existsSync(new URL("file:///tmp/g/e"))); + + // Test deep directory structure + mkdirSync(new URL("file:///tmp/deep")); + mkdirSync(new URL("file:///tmp/deep/nested")); + mkdirSync(new URL("file:///tmp/deep/nested/very")); + writeFileSync(new URL("file:///tmp/deep/nested/very/deep.txt"), "content"); + + await new Promise((resolve, reject) => { + cp(new URL("file:///tmp/deep"), pathH, { recursive: true }, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + ok(existsSync(new URL("file:///tmp/h/nested/very/deep.txt"))); + deepStrictEqual( + readFileSync(new URL("file:///tmp/h/nested/very/deep.txt"), "utf8"), + "content", + ); + }, +}; + +// PreserveTimestamps option tests - callback version +export const preserveTimestampsTestsCallback = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + const pathH = new URL("file:///tmp/h"); + + // Get original timestamps + const originalStat = lstatSync(pathA); + const originalMtime = originalStat.mtime; + + // Copy without preserveTimestamps (default behavior) + await new Promise((resolve, reject) => { + cp(pathA, pathG, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + const _copiedStat = lstatSync(pathG); + + // Copy with preserveTimestamps: true + await new Promise((resolve, reject) => { + cp(pathA, pathH, { preserveTimestamps: true }, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + const preservedStat = lstatSync(pathH); + + ok( + Math.abs(preservedStat.mtime.getTime() - originalMtime.getTime()) < 1000, + ); + }, +}; + +// Error handling edge cases - callback version +export const errorHandlingTestsCallback = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + const nonExistent = new URL("file:///tmp/nonexistent"); + + // Test copying from non-existent source + await new Promise((resolve) => { + cp(nonExistent, pathG, (err) => { + ok(err); + strictEqual(err.code, "ENOENT"); + resolve(); + }); + }); + }, +}; + +// Symlink edge cases - callback version +export const symlinkEdgeCasesCallback = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + const pathH = new URL("file:///tmp/h"); + const pathI = new URL("file:///tmp/i"); + const pathJ = new URL("file:///tmp/j"); + + // Create a broken symlink + const brokenTarget = new URL("file:///tmp/broken-target"); + symlinkSync(brokenTarget, pathG); + + // Test copying broken symlink without dereference + await new Promise((resolve, reject) => { + cp(pathG, pathH, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + ok(lstatSync(pathH).isSymbolicLink()); + + // Test copying broken symlink with dereference should fail + await new Promise((resolve) => { + cp(pathG, pathI, { dereference: true }, (err) => { + ok(err); + strictEqual(err.code, "ENOENT"); + resolve(); + }); + }); + + // Create symlink chain: J -> I -> A + symlinkSync(pathA, pathI); + symlinkSync(pathI, pathJ); + + // Test copying symlink chain + const pathK = new URL("file:///tmp/k"); + await new Promise((resolve, reject) => { + cp(pathJ, pathK, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + ok(lstatSync(pathK).isSymbolicLink()); + deepStrictEqual(readFileSync(pathK, "utf8"), "foo"); + }, +}; + +// Combined options tests - callback version +export const combinedOptionsTestsCallback = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + + // Test multiple options together + await new Promise((resolve, reject) => { + cp( + pathB, + pathG, + { + recursive: true, + dereference: true, + force: true, + preserveTimestamps: true, + errorOnExist: false, + }, + (err) => { + if (err) reject(err); + else resolve(); + }, + ); + }); + + ok(existsSync(pathG)); + ok(lstatSync(pathG).isDirectory()); + }, +}; + +// Path type tests - callback version +export const pathTypeTestsCallback = { + async test() { + setupPaths(); + + // Test with Buffer paths + const pathG = new URL("file:///tmp/g"); + const bufferSrc = Buffer.from("/tmp/a"); + const bufferDest = Buffer.from("/tmp/g"); + + await new Promise((resolve, reject) => { + cp(bufferSrc, bufferDest, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + ok(existsSync(pathG)); + deepStrictEqual(readFileSync(pathG, "utf8"), "foo"); + }, +}; + +// ===== PROMISES-BASED API TESTS ===== + +// Simple file copy - promises version +export const simpleFileCopyPromises = { + async test() { + setupPaths(); + ok(existsSync(pathA)); + deepStrictEqual(readFileSync(pathA, "utf8"), "foo"); + ok(!existsSync(pathF)); + + await fsPromises.cp(pathA, pathF); + ok(existsSync(pathF)); + deepStrictEqual(readFileSync(pathF, "utf8"), "foo"); + writeFileSync(pathA, "baz"); + deepStrictEqual(readFileSync(pathA, "utf8"), "baz"); + deepStrictEqual(readFileSync(pathF, "utf8"), "foo"); + + // Test with errorOnExist: true + await fsPromises.cp(pathA, pathF, { errorOnExist: true }); + + // Test error case: force: false, errorOnExist: true + await rejects( + async () => { + await fsPromises.cp(pathA, pathF, { force: false, errorOnExist: true }); + }, + { code: "ERR_FS_CP_EEXIST" }, + ); + + // Test error case: copying file to directory + await rejects( + async () => { + await fsPromises.cp(pathA, pathB); + }, + { code: "ERR_FS_CP_NON_DIR_TO_DIR" }, + ); + + // Test copying to symlink to directory + await rejects( + async () => { + await fsPromises.cp(pathA, pathD, { dereference: true }); + }, + { code: "ERR_FS_CP_NON_DIR_TO_DIR" }, + ); + + // Test copying to symlink without dereference + await fsPromises.cp(pathA, pathD); + ok(lstatSync(pathD).isFile()); + }, +}; + +// Copy file to non-existent directory - promises version +export const copyFileToNonExistentDirectoryPromises = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/nonexistent"); + const pathH = new URL("file:///tmp/nonexistent/g"); + ok(!existsSync(pathG)); + ok(!existsSync(pathH)); + + await fsPromises.cp(pathA, pathH); + ok(existsSync(pathG)); + ok(existsSync(pathH)); + }, +}; + +// Copy file to existing file with force - promises version +export const copyFileToExistingFileWithForcePromises = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + writeFileSync(pathG, "original"); + + await fsPromises.cp(pathA, pathG, { force: true }); + deepStrictEqual(readFileSync(pathG, "utf8"), "foo"); + }, +}; + +// Copy file to existing file without force - promises version +export const copyFileToExistingFileWithoutForcePromises = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + writeFileSync(pathG, "original"); + + await fsPromises.cp(pathA, pathG, { force: false }); + deepStrictEqual(readFileSync(pathG, "utf8"), "original"); + }, +}; + +// Copy file to existing directory - promises version +export const copyFileToExistingDirectoryPromises = { + async test() { + setupPaths(); + + await rejects( + async () => { + await fsPromises.cp(pathA, pathB); + }, + { code: "ERR_FS_CP_NON_DIR_TO_DIR" }, + ); + }, +}; + +// Copy file to existing symlink to directory - promises version +export const copyFileToExistingSymlinkToDirectoryPromises = { + async test() { + setupPaths(); + + // With dereference: true, should throw EISDIR + await rejects( + async () => { + await fsPromises.cp(pathA, pathD, { dereference: true }); + }, + { code: "ERR_FS_CP_NON_DIR_TO_DIR" }, + ); + + // Without dereference (default), should replace the symlink + await fsPromises.cp(pathA, pathD); + ok(lstatSync(pathD).isFile()); + deepStrictEqual(readFileSync(pathD, "utf8"), "foo"); + }, +}; + +// Copy directory to non-existent directory - promises version +export const copyDirectoryToNonExistentDirectoryPromises = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + + await fsPromises.cp(pathB, pathG, { recursive: true }); + ok(existsSync(pathG)); + ok(lstatSync(pathG).isDirectory()); + ok(existsSync(new URL("file:///tmp/g/e"))); + deepStrictEqual(readFileSync(new URL("file:///tmp/g/e"), "utf8"), "bar"); + }, +}; + +// Copy directory to existing directory - promises version +export const copyDirectoryToExistingDirectoryPromises = { + async test() { + setupPaths(); + + await fsPromises.cp(pathB, pathE, { recursive: true }); + ok(existsSync(new URL("file:///tmp/e/e"))); + deepStrictEqual(readFileSync(new URL("file:///tmp/e/e"), "utf8"), "bar"); + }, +}; + +// Copy directory to existing file - promises version +export const copyDirectoryToExistingFilePromises = { + async test() { + setupPaths(); + + await rejects( + async () => { + await fsPromises.cp(pathB, pathA, { recursive: true }); + }, + { code: "ERR_FS_CP_DIR_TO_NON_DIR" }, + ); + }, +}; + +// Copy directory to existing symlink to file - promises version +export const copyDirectoryToExistingSymlinkToFilePromises = { + async test() { + setupPaths(); + + await rejects( + async () => { + await fsPromises.cp(pathB, pathC, { + recursive: true, + dereference: true, + }); + }, + { code: "ERR_FS_CP_DIR_TO_NON_DIR" }, + ); + }, +}; + +// Copy symlink to file - promises version +export const copySymlinkToFilePromises = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + + // With dereference: false (default), should copy the symlink + await fsPromises.cp(pathC, pathG); + ok(lstatSync(pathG).isSymbolicLink()); + deepStrictEqual(readFileSync(pathG, "utf8"), "foo"); + + // With dereference: true, should copy the target file + const pathH = new URL("file:///tmp/h"); + await fsPromises.cp(pathC, pathH, { dereference: true }); + ok(lstatSync(pathH).isFile()); + deepStrictEqual(readFileSync(pathH, "utf8"), "foo"); + }, +}; + +// Copy symlink to directory - promises version +export const copySymlinkToDirectoryPromises = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + + // With dereference: false (default), should copy the symlink + await fsPromises.cp(pathD, pathG); + ok(lstatSync(pathG).isSymbolicLink()); + ok(statSync(pathG).isDirectory()); + + // With dereference: true, should copy the target directory + const pathH = new URL("file:///tmp/h"); + await fsPromises.cp(pathD, pathH, { recursive: true, dereference: true }); + ok(lstatSync(pathH).isDirectory()); + ok(existsSync(new URL("file:///tmp/h/e"))); + }, +}; + +// Copy symlink to existing file - promises version +export const copySymlinkToExistingFilePromises = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + writeFileSync(pathG, "original"); + + // Should replace the file with symlink + await fsPromises.cp(pathC, pathG); + ok(lstatSync(pathG).isSymbolicLink()); + deepStrictEqual(readFileSync(pathG, "utf8"), "foo"); + }, +}; + +// Copy symlink to existing directory - promises version +export const copySymlinkToExistingDirectoryPromises = { + async test() { + setupPaths(); + + await rejects( + async () => { + await fsPromises.cp(pathC, pathE); + }, + { code: "ENOTDIR" }, + ); + }, +}; + +// Copy symlink to existing symlink to directory - promises version +export const copySymlinkToExistingSymlinkToDirectoryPromises = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + const pathH = new URL("file:///tmp/h"); + mkdirSync(pathH); + symlinkSync(pathH, pathG); + + // Should replace the symlink + await fsPromises.cp(pathD, pathG); + ok(lstatSync(pathG).isSymbolicLink()); + ok(statSync(pathG).isDirectory()); + }, +}; + +// Copy symlink operations with various option combinations - promises version +export const copySymlinkWithDereferenceOptionsPromises = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + const pathH = new URL("file:///tmp/h"); + const pathI = new URL("file:///tmp/i"); + + // Test with force: false, errorOnExist: true + symlinkSync(pathA, pathG); + await rejects( + async () => { + await fsPromises.cp(pathC, pathG, { force: false, errorOnExist: true }); + }, + { code: "ERR_FS_CP_EEXIST" }, + ); + + // Test with force: false, errorOnExist: false + await fsPromises.cp(pathC, pathG, { force: false, errorOnExist: false }); + ok(lstatSync(pathG).isSymbolicLink()); + + // Test with dereference and errorOnExist combinations + writeFileSync(pathH, "existing"); + await rejects( + async () => { + await fsPromises.cp(pathC, pathH, { + dereference: true, + force: false, + errorOnExist: true, + }); + }, + { code: "ERR_FS_CP_EEXIST" }, + ); + + // Test copying to non-existent path + await fsPromises.cp(pathC, pathI); + ok(lstatSync(pathI).isSymbolicLink()); + deepStrictEqual(readFileSync(pathI, "utf8"), "foo"); + }, +}; + +// Option validation tests - promises version +export const optionValidationTestsPromises = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + + // Test invalid option types + await rejects( + async () => { + await fsPromises.cp(pathA, pathG, { dereference: "invalid" }); + }, + { code: "ERR_INVALID_ARG_TYPE" }, + ); + + await rejects( + async () => { + await fsPromises.cp(pathA, pathG, { errorOnExist: "invalid" }); + }, + { code: "ERR_INVALID_ARG_TYPE" }, + ); + + await rejects( + async () => { + await fsPromises.cp(pathA, pathG, { force: "invalid" }); + }, + { code: "ERR_INVALID_ARG_TYPE" }, + ); + + await rejects( + async () => { + await fsPromises.cp(pathA, pathG, { recursive: "invalid" }); + }, + { code: "ERR_INVALID_ARG_TYPE" }, + ); + + await rejects( + async () => { + await fsPromises.cp(pathA, pathG, { preserveTimestamps: "invalid" }); + }, + { code: "ERR_INVALID_ARG_TYPE" }, + ); + + await rejects( + async () => { + await fsPromises.cp(pathA, pathG, { verbatimSymlinks: "invalid" }); + }, + { code: "ERR_INVALID_ARG_TYPE" }, + ); + + // Test invalid filter type + await rejects( + async () => { + await fsPromises.cp(pathA, pathG, { filter: "not-a-function" }); + }, + { code: "ERR_INVALID_ARG_TYPE" }, + ); + + // Test filter option throws unsupported operation + await rejects( + async () => { + await fsPromises.cp(pathA, pathG, { filter: () => true }); + }, + { code: "ERR_UNSUPPORTED_OPERATION" }, + ); + + // Test COPYFILE_FICLONE_FORCE mode + await rejects( + async () => { + await fsPromises.cp(pathA, pathG, { mode: 4 }); + }, + { code: "ERR_INVALID_ARG_VALUE" }, + ); + }, +}; + +// Recursive option tests - promises version +export const recursiveOptionTestsPromises = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + const pathH = new URL("file:///tmp/h"); + + // Test copying directory without recursive option should fail + await rejects( + async () => { + await fsPromises.cp(pathB, pathG); + }, + { code: "ERR_FS_EISDIR" }, + ); + + // Test with recursive: false explicitly + await rejects( + async () => { + await fsPromises.cp(pathB, pathG, { recursive: false }); + }, + { code: "ERR_FS_EISDIR" }, + ); + + // Test copying directory with recursive: true should work + await fsPromises.cp(pathB, pathG, { recursive: true }); + ok(existsSync(pathG)); + ok(lstatSync(pathG).isDirectory()); + ok(existsSync(new URL("file:///tmp/g/e"))); + + // Test deep directory structure + mkdirSync(new URL("file:///tmp/deep")); + mkdirSync(new URL("file:///tmp/deep/nested")); + mkdirSync(new URL("file:///tmp/deep/nested/very")); + writeFileSync(new URL("file:///tmp/deep/nested/very/deep.txt"), "content"); + + await fsPromises.cp(new URL("file:///tmp/deep"), pathH, { + recursive: true, + }); + ok(existsSync(new URL("file:///tmp/h/nested/very/deep.txt"))); + deepStrictEqual( + readFileSync(new URL("file:///tmp/h/nested/very/deep.txt"), "utf8"), + "content", + ); + }, +}; + +// PreserveTimestamps option tests - promises version +export const preserveTimestampsTestsPromises = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + const pathH = new URL("file:///tmp/h"); + + // Get original timestamps + const originalStat = lstatSync(pathA); + const originalMtime = originalStat.mtime; + + // Copy without preserveTimestamps (default behavior) + await fsPromises.cp(pathA, pathG); + const _copiedStat = lstatSync(pathG); + + // Copy with preserveTimestamps: true + await fsPromises.cp(pathA, pathH, { preserveTimestamps: true }); + const preservedStat = lstatSync(pathH); + + ok( + Math.abs(preservedStat.mtime.getTime() - originalMtime.getTime()) < 1000, + ); + }, +}; + +// Error handling edge cases - promises version +export const errorHandlingTestsPromises = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + const nonExistent = new URL("file:///tmp/nonexistent"); + + // Test copying from non-existent source + await rejects( + async () => { + await fsPromises.cp(nonExistent, pathG); + }, + { code: "ENOENT" }, + ); + }, +}; + +// Symlink edge cases - promises version +export const symlinkEdgeCasesPromises = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + const pathH = new URL("file:///tmp/h"); + const pathI = new URL("file:///tmp/i"); + const pathJ = new URL("file:///tmp/j"); + + // Create a broken symlink + const brokenTarget = new URL("file:///tmp/broken-target"); + symlinkSync(brokenTarget, pathG); + + // Test copying broken symlink without dereference + await fsPromises.cp(pathG, pathH); + ok(lstatSync(pathH).isSymbolicLink()); + + // Test copying broken symlink with dereference should fail + await rejects( + async () => { + await fsPromises.cp(pathG, pathI, { dereference: true }); + }, + { code: "ENOENT" }, + ); + + // Create symlink chain: J -> I -> A + symlinkSync(pathA, pathI); + symlinkSync(pathI, pathJ); + + // Test copying symlink chain + const pathK = new URL("file:///tmp/k"); + await fsPromises.cp(pathJ, pathK); + + ok(lstatSync(pathK).isSymbolicLink()); + deepStrictEqual(readFileSync(pathK, "utf8"), "foo"); + }, +}; + +// Combined options tests - promises version +export const combinedOptionsTestsPromises = { + async test() { + setupPaths(); + const pathG = new URL("file:///tmp/g"); + + // Test multiple options together + await fsPromises.cp(pathB, pathG, { + recursive: true, + dereference: true, + force: true, + preserveTimestamps: true, + errorOnExist: false, + }); + + ok(existsSync(pathG)); + ok(lstatSync(pathG).isDirectory()); + }, +}; + +// Path type tests - promises version +export const pathTypeTestsPromises = { + async test() { + setupPaths(); + + // Test with Buffer paths + const pathG = new URL("file:///tmp/g"); + const bufferSrc = Buffer.from("/tmp/a"); + const bufferDest = Buffer.from("/tmp/g"); + + await fsPromises.cp(bufferSrc, bufferDest); + ok(existsSync(pathG)); + deepStrictEqual(readFileSync(pathG, "utf8"), "foo"); + }, +}; + +// Deep directory structure tests - sync version +export const deepDirectoryStructureTestsSync = { + test() { + const sourceRoot = "/tmp/deep_source"; + const destRoot = "/tmp/deep_dest"; + + // Create source directory structure + mkdirSync(sourceRoot, { recursive: true }); + mkdirSync(`${sourceRoot}/level1`, { recursive: true }); + mkdirSync(`${sourceRoot}/level1/level2`, { recursive: true }); + mkdirSync(`${sourceRoot}/level1/level2/level3`, { recursive: true }); + mkdirSync(`${sourceRoot}/level1/level2/level3/level4`, { recursive: true }); + mkdirSync(`${sourceRoot}/level1/level2/level3/level4/level5`, { + recursive: true, + }); + + // Create files in various levels + writeFileSync(`${sourceRoot}/root_file.txt`, "root content"); + writeFileSync(`${sourceRoot}/level1/level1_file.txt`, "level1 content"); + writeFileSync( + `${sourceRoot}/level1/level2/level2_file.txt`, + "level2 content", + ); + writeFileSync( + `${sourceRoot}/level1/level2/level3/level3_file.txt`, + "level3 content", + ); + writeFileSync( + `${sourceRoot}/level1/level2/level3/level4/level4_file.txt`, + "level4 content", + ); + writeFileSync( + `${sourceRoot}/level1/level2/level3/level4/level5/level5_file.txt`, + "level5 content", + ); + + // Create symlinks (will not be followed) + writeFileSync(`${sourceRoot}/link_target.txt`, "link target content"); + symlinkSync( + `${sourceRoot}/link_target.txt`, + `${sourceRoot}/level1/level2/symlink_to_file`, + ); + symlinkSync( + `${sourceRoot}/level1`, + `${sourceRoot}/level1/level2/level3/symlink_to_dir`, + ); + + // Create destination directory structure with some existing files (duplicates) + mkdirSync(destRoot, { recursive: true }); + mkdirSync(`${destRoot}/level1`, { recursive: true }); + mkdirSync(`${destRoot}/level1/level2`, { recursive: true }); + mkdirSync(`${destRoot}/existing_dir`, { recursive: true }); + + // Create files that will be duplicates (should be ignored) + writeFileSync(`${destRoot}/duplicate_file.txt`, "existing content"); + writeFileSync( + `${destRoot}/level1/duplicate_file.txt`, + "existing level1 content", + ); + + // Add some files to source that match existing files (duplicates) + writeFileSync( + `${sourceRoot}/duplicate_file.txt`, + "source duplicate content", + ); + writeFileSync( + `${sourceRoot}/level1/duplicate_file.txt`, + "source level1 duplicate content", + ); + + // Perform recursive copy (should ignore duplicates and not follow symlinks) + cpSync(sourceRoot, destRoot, { recursive: true, force: false }); + + // Verify destination structure + ok(existsSync(`${destRoot}/root_file.txt`)); + ok(existsSync(`${destRoot}/level1/level1_file.txt`)); + ok(existsSync(`${destRoot}/level1/level2/level2_file.txt`)); + ok(existsSync(`${destRoot}/level1/level2/level3/level3_file.txt`)); + ok(existsSync(`${destRoot}/level1/level2/level3/level4/level4_file.txt`)); + ok( + existsSync( + `${destRoot}/level1/level2/level3/level4/level5/level5_file.txt`, + ), + ); + + // Verify file contents + deepStrictEqual( + readFileSync(`${destRoot}/root_file.txt`, "utf8"), + "root content", + ); + deepStrictEqual( + readFileSync(`${destRoot}/level1/level1_file.txt`, "utf8"), + "level1 content", + ); + deepStrictEqual( + readFileSync(`${destRoot}/level1/level2/level2_file.txt`, "utf8"), + "level2 content", + ); + deepStrictEqual( + readFileSync(`${destRoot}/level1/level2/level3/level3_file.txt`, "utf8"), + "level3 content", + ); + deepStrictEqual( + readFileSync( + `${destRoot}/level1/level2/level3/level4/level4_file.txt`, + "utf8", + ), + "level4 content", + ); + deepStrictEqual( + readFileSync( + `${destRoot}/level1/level2/level3/level4/level5/level5_file.txt`, + "utf8", + ), + "level5 content", + ); + + // Verify symlinks are copied as symlinks (not followed) + ok(lstatSync(`${destRoot}/level1/level2/symlink_to_file`).isSymbolicLink()); + ok( + lstatSync( + `${destRoot}/level1/level2/level3/symlink_to_dir`, + ).isSymbolicLink(), + ); + + // Verify duplicate files were ignored (original content preserved) + deepStrictEqual( + readFileSync(`${destRoot}/duplicate_file.txt`, "utf8"), + "existing content", + ); + deepStrictEqual( + readFileSync(`${destRoot}/level1/duplicate_file.txt`, "utf8"), + "existing level1 content", + ); + + // Verify existing directory structure remains + ok(existsSync(`${destRoot}/existing_dir`)); + }, +}; + +// Deep directory structure tests - callback version +export const deepDirectoryStructureTestsCallback = { + async test() { + const sourceRoot = "/tmp/deep_source_cb"; + const destRoot = "/tmp/deep_dest_cb"; + + // Create source directory structure + mkdirSync(sourceRoot, { recursive: true }); + mkdirSync(`${sourceRoot}/level1`, { recursive: true }); + mkdirSync(`${sourceRoot}/level1/level2`, { recursive: true }); + mkdirSync(`${sourceRoot}/level1/level2/level3`, { recursive: true }); + mkdirSync(`${sourceRoot}/level1/level2/level3/level4`, { recursive: true }); + mkdirSync(`${sourceRoot}/level1/level2/level3/level4/level5`, { + recursive: true, + }); + + // Create files in various levels + writeFileSync(`${sourceRoot}/root_file.txt`, "root content"); + writeFileSync(`${sourceRoot}/level1/level1_file.txt`, "level1 content"); + writeFileSync( + `${sourceRoot}/level1/level2/level2_file.txt`, + "level2 content", + ); + writeFileSync( + `${sourceRoot}/level1/level2/level3/level3_file.txt`, + "level3 content", + ); + writeFileSync( + `${sourceRoot}/level1/level2/level3/level4/level4_file.txt`, + "level4 content", + ); + writeFileSync( + `${sourceRoot}/level1/level2/level3/level4/level5/level5_file.txt`, + "level5 content", + ); + + // Create symlinks (will not be followed) + writeFileSync(`${sourceRoot}/link_target.txt`, "link target content"); + symlinkSync( + `${sourceRoot}/link_target.txt`, + `${sourceRoot}/level1/level2/symlink_to_file`, + ); + symlinkSync( + `${sourceRoot}/level1`, + `${sourceRoot}/level1/level2/level3/symlink_to_dir`, + ); + + // Create destination directory structure with some existing files (duplicates) + mkdirSync(destRoot, { recursive: true }); + mkdirSync(`${destRoot}/level1`, { recursive: true }); + mkdirSync(`${destRoot}/level1/level2`, { recursive: true }); + mkdirSync(`${destRoot}/existing_dir`, { recursive: true }); + + // Create files that will be duplicates (should be ignored) + writeFileSync(`${destRoot}/duplicate_file.txt`, "existing content"); + writeFileSync( + `${destRoot}/level1/duplicate_file.txt`, + "existing level1 content", + ); + + // Add some files to source that match existing files (duplicates) + writeFileSync( + `${sourceRoot}/duplicate_file.txt`, + "source duplicate content", + ); + writeFileSync( + `${sourceRoot}/level1/duplicate_file.txt`, + "source level1 duplicate content", + ); + // Perform recursive copy (should ignore duplicates and not follow symlinks) + await new Promise((resolve, reject) => { + cp(sourceRoot, destRoot, { recursive: true, force: false }, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + // Verify destination structure + ok(existsSync(`${destRoot}/root_file.txt`)); + ok(existsSync(`${destRoot}/level1/level1_file.txt`)); + ok(existsSync(`${destRoot}/level1/level2/level2_file.txt`)); + ok(existsSync(`${destRoot}/level1/level2/level3/level3_file.txt`)); + ok(existsSync(`${destRoot}/level1/level2/level3/level4/level4_file.txt`)); + ok( + existsSync( + `${destRoot}/level1/level2/level3/level4/level5/level5_file.txt`, + ), + ); + + // Verify file contents + deepStrictEqual( + readFileSync(`${destRoot}/root_file.txt`, "utf8"), + "root content", + ); + deepStrictEqual( + readFileSync(`${destRoot}/level1/level1_file.txt`, "utf8"), + "level1 content", + ); + deepStrictEqual( + readFileSync(`${destRoot}/level1/level2/level2_file.txt`, "utf8"), + "level2 content", + ); + deepStrictEqual( + readFileSync(`${destRoot}/level1/level2/level3/level3_file.txt`, "utf8"), + "level3 content", + ); + deepStrictEqual( + readFileSync( + `${destRoot}/level1/level2/level3/level4/level4_file.txt`, + "utf8", + ), + "level4 content", + ); + deepStrictEqual( + readFileSync( + `${destRoot}/level1/level2/level3/level4/level5/level5_file.txt`, + "utf8", + ), + "level5 content", + ); + + // Verify symlinks are copied as symlinks (not followed) + ok(lstatSync(`${destRoot}/level1/level2/symlink_to_file`).isSymbolicLink()); + ok( + lstatSync( + `${destRoot}/level1/level2/level3/symlink_to_dir`, + ).isSymbolicLink(), + ); + + // Verify duplicate files were ignored (original content preserved) + deepStrictEqual( + readFileSync(`${destRoot}/duplicate_file.txt`, "utf8"), + "existing content", + ); + deepStrictEqual( + readFileSync(`${destRoot}/level1/duplicate_file.txt`, "utf8"), + "existing level1 content", + ); + + // Verify existing directory structure remains + ok(existsSync(`${destRoot}/existing_dir`)); + }, +}; + +// Deep directory structure tests - promises version +export const deepDirectoryStructureTestsPromises = { + async test() { + const sourceRoot = "/tmp/deep_source_pr"; + const destRoot = "/tmp/deep_dest_pr"; + + // Create source directory structure + mkdirSync(sourceRoot, { recursive: true }); + mkdirSync(`${sourceRoot}/level1`, { recursive: true }); + mkdirSync(`${sourceRoot}/level1/level2`, { recursive: true }); + mkdirSync(`${sourceRoot}/level1/level2/level3`, { recursive: true }); + mkdirSync(`${sourceRoot}/level1/level2/level3/level4`, { recursive: true }); + mkdirSync(`${sourceRoot}/level1/level2/level3/level4/level5`, { + recursive: true, + }); + + // Create files in various levels + writeFileSync(`${sourceRoot}/root_file.txt`, "root content"); + writeFileSync(`${sourceRoot}/level1/level1_file.txt`, "level1 content"); + writeFileSync( + `${sourceRoot}/level1/level2/level2_file.txt`, + "level2 content", + ); + writeFileSync( + `${sourceRoot}/level1/level2/level3/level3_file.txt`, + "level3 content", + ); + writeFileSync( + `${sourceRoot}/level1/level2/level3/level4/level4_file.txt`, + "level4 content", + ); + writeFileSync( + `${sourceRoot}/level1/level2/level3/level4/level5/level5_file.txt`, + "level5 content", + ); + + // Create symlinks (will not be followed) + writeFileSync(`${sourceRoot}/link_target.txt`, "link target content"); + symlinkSync( + `${sourceRoot}/link_target.txt`, + `${sourceRoot}/level1/level2/symlink_to_file`, + ); + symlinkSync( + `${sourceRoot}/level1`, + `${sourceRoot}/level1/level2/level3/symlink_to_dir`, + ); + + // Create destination directory structure with some existing files (duplicates) + mkdirSync(destRoot, { recursive: true }); + mkdirSync(`${destRoot}/level1`, { recursive: true }); + mkdirSync(`${destRoot}/level1/level2`, { recursive: true }); + mkdirSync(`${destRoot}/existing_dir`, { recursive: true }); + + // Create files that will be duplicates (should be ignored) + writeFileSync(`${destRoot}/duplicate_file.txt`, "existing content"); + writeFileSync( + `${destRoot}/level1/duplicate_file.txt`, + "existing level1 content", + ); + + // Add some files to source that match existing files (duplicates) + writeFileSync( + `${sourceRoot}/duplicate_file.txt`, + "source duplicate content", + ); + writeFileSync( + `${sourceRoot}/level1/duplicate_file.txt`, + "source level1 duplicate content", + ); + + // Perform recursive copy (should ignore duplicates and not follow symlinks) + await fsPromises.cp(sourceRoot, destRoot, { + recursive: true, + force: false, + }); + + // Verify destination structure + ok(existsSync(`${destRoot}/root_file.txt`)); + ok(existsSync(`${destRoot}/level1/level1_file.txt`)); + ok(existsSync(`${destRoot}/level1/level2/level2_file.txt`)); + ok(existsSync(`${destRoot}/level1/level2/level3/level3_file.txt`)); + ok(existsSync(`${destRoot}/level1/level2/level3/level4/level4_file.txt`)); + ok( + existsSync( + `${destRoot}/level1/level2/level3/level4/level5/level5_file.txt`, + ), + ); + + // Verify file contents + deepStrictEqual( + readFileSync(`${destRoot}/root_file.txt`, "utf8"), + "root content", + ); + deepStrictEqual( + readFileSync(`${destRoot}/level1/level1_file.txt`, "utf8"), + "level1 content", + ); + deepStrictEqual( + readFileSync(`${destRoot}/level1/level2/level2_file.txt`, "utf8"), + "level2 content", + ); + deepStrictEqual( + readFileSync(`${destRoot}/level1/level2/level3/level3_file.txt`, "utf8"), + "level3 content", + ); + deepStrictEqual( + readFileSync( + `${destRoot}/level1/level2/level3/level4/level4_file.txt`, + "utf8", + ), + "level4 content", + ); + deepStrictEqual( + readFileSync( + `${destRoot}/level1/level2/level3/level4/level5/level5_file.txt`, + "utf8", + ), + "level5 content", + ); + + // Verify symlinks are copied as symlinks (not followed) + ok(lstatSync(`${destRoot}/level1/level2/symlink_to_file`).isSymbolicLink()); + ok( + lstatSync( + `${destRoot}/level1/level2/level3/symlink_to_dir`, + ).isSymbolicLink(), + ); + + // Verify duplicate files were ignored (original content preserved) + deepStrictEqual( + readFileSync(`${destRoot}/duplicate_file.txt`, "utf8"), + "existing content", + ); + deepStrictEqual( + readFileSync(`${destRoot}/level1/duplicate_file.txt`, "utf8"), + "existing level1 content", + ); + + // Verify existing directory structure remains + ok(existsSync(`${destRoot}/existing_dir`)); + }, +}; + +export const copyTwiceTest = { + test() { + cpSync("/bundle/worker", "/tmp/a"); + cpSync("/tmp/a", "/tmp/b"); + const a = readFileSync("/bundle/worker"); + const b = readFileSync("/tmp/a"); + const c = readFileSync("/tmp/b"); + deepStrictEqual(a, b); + deepStrictEqual(b, c); + }, +}; diff --git a/src/compat/fs/upstream-directory.fixture.mjs b/src/compat/fs/upstream-directory.fixture.mjs new file mode 100644 index 0000000..bfd5239 --- /dev/null +++ b/src/compat/fs/upstream-directory.fixture.mjs @@ -0,0 +1,920 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +import { + deepStrictEqual, + ok, + match, + rejects, + strictEqual, + throws, +} from "node:assert"; + +import { + existsSync, + writeFileSync, + mkdirSync, + mkdtempSync, + rmSync, + rmdirSync, + readdirSync, + mkdtemp, + mkdir, + rm, + rmdir, + readdir, + promises, + opendirSync, + opendir, +} from "./index.js"; + +strictEqual(typeof existsSync, "function"); +strictEqual(typeof writeFileSync, "function"); +strictEqual(typeof mkdirSync, "function"); +strictEqual(typeof mkdtempSync, "function"); +strictEqual(typeof rmSync, "function"); +strictEqual(typeof rmdirSync, "function"); +strictEqual(typeof readdirSync, "function"); +strictEqual(typeof mkdtemp, "function"); +strictEqual(typeof mkdir, "function"); +strictEqual(typeof rm, "function"); +strictEqual(typeof rmdir, "function"); +strictEqual(typeof readdir, "function"); +strictEqual(typeof opendirSync, "function"); +strictEqual(typeof opendir, "function"); +strictEqual(typeof promises.mkdir, "function"); +strictEqual(typeof promises.mkdtemp, "function"); +strictEqual(typeof promises.rm, "function"); +strictEqual(typeof promises.rmdir, "function"); +strictEqual(typeof promises.readdir, "function"); +strictEqual(typeof promises.opendir, "function"); + +const kInvalidArgTypeError = { code: "ERR_INVALID_ARG_TYPE" }; +const kInvalidArgValueError = { code: "ERR_INVALID_ARG_VALUE" }; +const kEPermError = { code: "EPERM" }; +const kENoEntError = { code: "ENOENT" }; +const kEExistError = { code: "EEXIST" }; +const kENotDirError = { code: "ENOTDIR" }; +const kENotEmptyError = { code: "ENOTEMPTY" }; + +export const mkdirSyncTest = { + test() { + throws(() => mkdirSync(), kInvalidArgTypeError); + throws(() => mkdirSync(123), kInvalidArgTypeError); + throws(() => mkdirSync("/tmp/testdir", "hello"), kInvalidArgTypeError); + throws( + () => mkdirSync("/tmp/testdir", { recursive: 123 }), + kInvalidArgTypeError, + ); + + // Make a directory. + ok(!existsSync("/tmp/testdir")); + strictEqual(mkdirSync("/tmp/testdir"), undefined); + ok(existsSync("/tmp/testdir")); + + // Making a subdirectory in a non-existing path fails by default + ok(!existsSync("/tmp/testdir/a/b/c")); + throws(() => mkdirSync("/tmp/testdir/a/b/c"), kENoEntError); + + // But passing the recursive option allows the entire path to be created. + ok(!existsSync("/tmp/testdir/a/b/c")); + strictEqual( + mkdirSync("/tmp/testdir/a/b/c", { recursive: true }), + "/tmp/testdir/a", + ); + ok(existsSync("/tmp/testdir/a/b/c")); + + // Cannot make a directory in a read-only location + throws(() => mkdirSync("/bundle/a"), kEPermError); + + // Making a directory that already exists is a non-op + mkdirSync("/tmp/testdir"); + + // Attempting to create a directory that already exists as a file throws + writeFileSync("/tmp/abc", "Hello World"); + throws(() => mkdirSync("/tmp/abc"), kEExistError); + + // Attempting to create a directory recursively when a parent is a file + // throws + throws(() => mkdirSync("/tmp/abc/foo", { recursive: true }), kENotDirError); + }, +}; + +export const mkdirAsyncCallbackTest = { + async test() { + throws(() => mkdir(), kInvalidArgTypeError); + throws(() => mkdir(123), kInvalidArgTypeError); + throws(() => mkdir("/tmp/testdir", "hello"), kInvalidArgTypeError); + throws( + () => mkdir("/tmp/testdir", { recursive: 123 }), + kInvalidArgTypeError, + ); + + // Make a directory. + ok(!existsSync("/tmp/testdir")); + await new Promise((resolve, reject) => { + mkdir("/tmp/testdir", (err) => { + if (err) reject(err); + else resolve(); + }); + }); + ok(existsSync("/tmp/testdir")); + + // Making a subdirectory in a non-existing path fails by default + ok(!existsSync("/tmp/testdir/a/b/c")); + await new Promise((resolve, reject) => { + mkdir("/tmp/testdir/a/b/c", (err) => { + if (err && err.code === kENoEntError.code) resolve(); + else reject(err); + }); + }); + + // But passing the recursive option allows the entire path to be created. + ok(!existsSync("/tmp/testdir/a/b/c")); + await new Promise((resolve, reject) => { + mkdir("/tmp/testdir/a/b/c", { recursive: true }, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + ok(existsSync("/tmp/testdir/a/b/c")); + + // Cannot make a directory in a read-only location + await new Promise((resolve, reject) => { + mkdir("/bundle/a", (err) => { + if (err && err.code === kEPermError.code) resolve(); + else reject(err); + }); + }); + + // Making a directory that already exists is a non-op + await new Promise((resolve, reject) => { + mkdir("/tmp/testdir", (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + // Attempting to create a directory that already exists as a file throws + writeFileSync("/tmp/abc", "Hello World"); + await new Promise((resolve, reject) => { + mkdir("/tmp/abc", (err) => { + if (err && err.code === kEExistError.code) resolve(); + else reject(err); + }); + }); + + // Attempting to create a directory recursively when a parent is a file + // throws + await new Promise((resolve, reject) => { + mkdir("/tmp/abc/foo", { recursive: true }, (err) => { + if (err && err.code === kENotDirError.code) resolve(); + else reject(err); + }); + }); + }, +}; + +export const mkdirAsyncPromiseTest = { + async test() { + await rejects(promises.mkdir(), kInvalidArgTypeError); + await rejects(promises.mkdir(123), kInvalidArgTypeError); + await rejects( + promises.mkdir("/tmp/testdir", "hello"), + kInvalidArgTypeError, + ); + await rejects( + promises.mkdir("/tmp/testdir", { recursive: 123 }), + kInvalidArgTypeError, + ); + + // Make a directory. + ok(!existsSync("/tmp/testdir")); + await promises.mkdir("/tmp/testdir"); + ok(existsSync("/tmp/testdir")); + + // Making a subdirectory in a non-existing path fails by default + ok(!existsSync("/tmp/testdir/a/b/c")); + await rejects(promises.mkdir("/tmp/testdir/a/b/c"), kENoEntError); + + // But passing the recursive option allows the entire path to be created. + ok(!existsSync("/tmp/testdir/a/b/c")); + await promises.mkdir("/tmp/testdir/a/b/c", { recursive: true }); + ok(existsSync("/tmp/testdir/a/b/c")); + + // Cannot make a directory in a read-only location + await rejects(promises.mkdir("/bundle/a"), kEPermError); + + // Making a directory that already exists is a non-op + await promises.mkdir("/tmp/testdir"); + + // Attempting to create a directory that already exists as a file throws + writeFileSync("/tmp/abc", "Hello World"); + await rejects(promises.mkdir("/tmp/abc"), kEExistError); + + // Attempting to create a directory recursively when a parent is a file + // throws + await rejects( + promises.mkdir("/tmp/abc/foo", { recursive: true }), + kENotDirError, + ); + }, +}; + +export const mkdtempSyncTest = { + test() { + throws(() => mkdtempSync(), kInvalidArgTypeError); + const ret1 = mkdtempSync("/tmp/testdir-"); + const ret2 = mkdtempSync("/tmp/testdir-"); + match(ret1, /\/tmp\/testdir-\d+/); + match(ret2, /\/tmp\/testdir-\d+/); + ok(existsSync(ret1)); + ok(existsSync(ret2)); + throws(() => mkdtempSync("/bundle/testdir-"), kEPermError); + }, +}; + +export const mkdtempAsyncCallbackTest = { + async test() { + throws(() => mkdtemp(), kInvalidArgTypeError); + const ret1 = await new Promise((resolve, reject) => { + mkdtemp("/tmp/testdir-", (err, dir) => { + if (err) reject(err); + else resolve(dir); + }); + }); + const ret2 = await new Promise((resolve, reject) => { + mkdtemp("/tmp/testdir-", (err, dir) => { + if (err) reject(err); + else resolve(dir); + }); + }); + match(ret1, /\/tmp\/testdir-\d+/); + match(ret2, /\/tmp\/testdir-\d+/); + ok(existsSync(ret1)); + ok(existsSync(ret2)); + await new Promise((resolve, reject) => { + mkdtemp("/bundle/testdir-", (err) => { + if (err && err.code === kEPermError.code) resolve(); + else reject(err); + }); + }); + }, +}; + +export const mkdtempAsyncPromiseTest = { + async test() { + await rejects(promises.mkdtemp(), kInvalidArgTypeError); + const ret1 = await promises.mkdtemp("/tmp/testdir-"); + const ret2 = await promises.mkdtemp("/tmp/testdir-"); + match(ret1, /\/tmp\/testdir-\d+/); + match(ret2, /\/tmp\/testdir-\d+/); + ok(existsSync(ret1)); + ok(existsSync(ret2)); + await rejects(promises.mkdtemp("/bundle/testdir-"), kEPermError); + }, +}; + +export const rmSyncTest = { + test() { + // Passing incorrect types for options throws + throws( + () => rmSync("/tmp/testdir", { recursive: "yes" }), + kInvalidArgTypeError, + ); + throws(() => rmSync("/tmp/testdir", "abc"), kInvalidArgTypeError); + throws( + () => rmSync("/tmp/testdir", { force: "yes" }), + kInvalidArgTypeError, + ); + throws( + () => rmSync("/tmp/testdir", { maxRetries: "yes" }), + kInvalidArgTypeError, + ); + throws( + () => rmSync("/tmp/testdir", { retryDelay: "yes" }), + kInvalidArgTypeError, + ); + throws( + () => rmSync("/tmp/testdir", { maxRetries: 1, retryDelay: "yes" }), + kInvalidArgTypeError, + ); + throws( + () => rmSync("/tmp/testdir", { maxRetries: "yes", retryDelay: 1 }), + kInvalidArgTypeError, + ); + throws( + () => + rmSync("/tmp/testdir", { maxRetries: 1, retryDelay: 1, force: "yes" }), + kInvalidArgTypeError, + ); + + throws( + () => rmdirSync("/tmp/testdir", { recursive: "yes" }), + kInvalidArgTypeError, + ); + throws(() => rmdirSync("/tmp/testdir", "abc"), kInvalidArgTypeError); + throws( + () => rmdirSync("/tmp/testdir", { maxRetries: "yes" }), + kInvalidArgTypeError, + ); + throws( + () => rmdirSync("/tmp/testdir", { retryDelay: "yes" }), + kInvalidArgTypeError, + ); + throws( + () => rmdirSync("/tmp/testdir", { maxRetries: 1, retryDelay: "yes" }), + kInvalidArgTypeError, + ); + throws( + () => rmdirSync("/tmp/testdir", { maxRetries: "yes", retryDelay: 1 }), + kInvalidArgTypeError, + ); + + ok(!existsSync("/tmp/testdir")); + mkdirSync("/tmp/testdir"); + writeFileSync("/tmp/testdir/a.txt", "Hello World"); + + // When the recusive option is not set, then removing a directory + // with children throws... + throws(() => rmdirSync("/tmp/testdir"), kENotEmptyError); + ok(existsSync("/tmp/testdir")); + + // But works when the recursive option is set + rmdirSync("/tmp/testdir", { recursive: true }); + ok(!existsSync("/tmp/testdir")); + + mkdirSync("/tmp/testdir"); + writeFileSync("/tmp/testdir/a.txt", "Hello World"); + writeFileSync("/tmp/testdir/b.txt", "Hello World"); + ok(existsSync("/tmp/testdir/a.txt")); + + // trying to remove a file with rmdir throws + throws(() => rmdirSync("/tmp/testdir/a.txt"), kENotDirError); + + // removing a file with rm works + rmSync("/tmp/testdir/a.txt"); + ok(!existsSync("/tmp/testdir/a.txt")); + + // Calling rmSync when the directory is not empty throws + throws(() => rmSync("/tmp/testdir"), kENotEmptyError); + ok(existsSync("/tmp/testdir")); + + // But works when the recursive option is set + throws(() => rmSync("/tmp/testdir")); + rmSync("/tmp/testdir", { recursive: true }); + ok(!existsSync("/tmp/testdir")); + }, +}; + +export const rmAsyncCallbackTest = { + async test() { + // Passing incorrect types for options throws + throws( + () => rm("/tmp/testdir", { recursive: "yes" }), + kInvalidArgTypeError, + ); + throws(() => rm("/tmp/testdir", "abc"), kInvalidArgTypeError); + throws(() => rm("/tmp/testdir", { force: "yes" }), kInvalidArgTypeError); + throws( + () => rm("/tmp/testdir", { maxRetries: "yes" }), + kInvalidArgTypeError, + ); + throws( + () => rm("/tmp/testdir", { retryDelay: "yes" }), + kInvalidArgTypeError, + ); + throws( + () => rm("/tmp/testdir", { maxRetries: 1, retryDelay: "yes" }), + kInvalidArgTypeError, + ); + throws( + () => rm("/tmp/testdir", { maxRetries: "yes", retryDelay: 1 }), + kInvalidArgTypeError, + ); + throws( + () => rm("/tmp/testdir", { maxRetries: 1, retryDelay: 1, force: "yes" }), + kInvalidArgTypeError, + ); + + throws( + () => rmdir("/tmp/testdir", { recursive: "yes" }), + kInvalidArgTypeError, + ); + throws(() => rmdir("/tmp/testdir", "abc"), kInvalidArgTypeError); + throws( + () => rmdir("/tmp/testdir", { maxRetries: "yes" }), + kInvalidArgTypeError, + ); + throws( + () => rmdir("/tmp/testdir", { retryDelay: "yes" }), + kInvalidArgTypeError, + ); + throws( + () => rmdir("/tmp/testdir", { maxRetries: 1, retryDelay: "yes" }), + kInvalidArgTypeError, + ); + throws( + () => rmdir("/tmp/testdir", { maxRetries: "yes", retryDelay: 1 }), + kInvalidArgTypeError, + ); + + ok(!existsSync("/tmp/testdir")); + mkdirSync("/tmp/testdir"); + writeFileSync("/tmp/testdir/a.txt", "Hello World"); + + // When the recusive option is not set, then removing a directory + // with children throws... + await new Promise((resolve, reject) => { + rmdir("/tmp/testdir", (err) => { + if (err && err.code === kENotEmptyError.code) resolve(); + else reject(err); + }); + }); + + ok(existsSync("/tmp/testdir")); + // But works when the recursive option is set + await new Promise((resolve, reject) => { + rmdir("/tmp/testdir", { recursive: true }, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + ok(!existsSync("/tmp/testdir")); + mkdirSync("/tmp/testdir"); + writeFileSync("/tmp/testdir/a.txt", "Hello World"); + writeFileSync("/tmp/testdir/b.txt", "Hello World"); + + ok(existsSync("/tmp/testdir/a.txt")); + // trying to remove a file with rmdir throws + await new Promise((resolve, reject) => { + rmdir("/tmp/testdir/a.txt", (err) => { + if (err && err.code === kENotDirError.code) resolve(); + else reject(err); + }); + }); + // removing a file with rm works + await new Promise((resolve, reject) => { + rm("/tmp/testdir/a.txt", (err) => { + if (err) reject(err); + else resolve(); + }); + }); + ok(!existsSync("/tmp/testdir/a.txt")); + // Calling rm when the directory is not empty throws + await new Promise((resolve, reject) => { + rm("/tmp/testdir", (err) => { + if (err && err.code === kENotEmptyError.code) resolve(); + else reject(err); + }); + }); + ok(existsSync("/tmp/testdir")); + // But works when the recursive option is set + await new Promise((resolve, reject) => { + rm("/tmp/testdir", { recursive: true }, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + ok(!existsSync("/tmp/testdir")); + }, +}; + +export const rmAsyncPromiseTest = { + async test() { + // Passing incorrect types for options throws + await rejects( + promises.rm("/tmp/testdir", { recursive: "yes" }), + kInvalidArgTypeError, + ); + await rejects(promises.rm("/tmp/testdir", "abc"), kInvalidArgTypeError); + await rejects( + promises.rm("/tmp/testdir", { force: "yes" }), + kInvalidArgTypeError, + ); + await rejects( + promises.rm("/tmp/testdir", { maxRetries: "yes" }), + kInvalidArgTypeError, + ); + await rejects( + promises.rm("/tmp/testdir", { retryDelay: "yes" }), + kInvalidArgTypeError, + ); + await rejects( + promises.rm("/tmp/testdir", { maxRetries: 1, retryDelay: "yes" }), + kInvalidArgTypeError, + ); + await rejects( + promises.rm("/tmp/testdir", { maxRetries: "yes", retryDelay: 1 }), + kInvalidArgTypeError, + ); + await rejects( + promises.rm("/tmp/testdir", { + maxRetries: 1, + retryDelay: 1, + force: "yes", + }), + kInvalidArgTypeError, + ); + + await rejects( + promises.rmdir("/tmp/testdir", { recursive: "yes" }), + kInvalidArgTypeError, + ); + await rejects(promises.rmdir("/tmp/testdir", "abc"), kInvalidArgTypeError); + await rejects( + promises.rmdir("/tmp/testdir", { maxRetries: "yes" }), + kInvalidArgTypeError, + ); + await rejects( + promises.rmdir("/tmp/testdir", { retryDelay: "yes" }), + kInvalidArgTypeError, + ); + await rejects( + promises.rmdir("/tmp/testdir", { maxRetries: 1, retryDelay: "yes" }), + kInvalidArgTypeError, + ); + await rejects( + promises.rmdir("/tmp/testdir", { maxRetries: "yes", retryDelay: 1 }), + kInvalidArgTypeError, + ); + + ok(!existsSync("/tmp/testdir")); + mkdirSync("/tmp/testdir"); + writeFileSync("/tmp/testdir/a.txt", "Hello World"); + + // When the recusive option is not set, then removing a directory + // with children throws... + await rejects(promises.rmdir("/tmp/testdir"), kENotEmptyError); + + ok(existsSync("/tmp/testdir")); + // But works when the recursive option is set + await promises.rmdir("/tmp/testdir", { recursive: true }); + ok(!existsSync("/tmp/testdir")); + mkdirSync("/tmp/testdir"); + writeFileSync("/tmp/testdir/a.txt", "Hello World"); + writeFileSync("/tmp/testdir/b.txt", "Hello World"); + ok(existsSync("/tmp/testdir/a.txt")); + // trying to remove a file with rmdir throws + await rejects(promises.rmdir("/tmp/testdir/a.txt"), kENotDirError); + // removing a file with rm works + await promises.rm("/tmp/testdir/a.txt"); + ok(!existsSync("/tmp/testdir/a.txt")); + // Calling rm when the directory is not empty throws + await rejects(promises.rm("/tmp/testdir"), kENotEmptyError); + ok(existsSync("/tmp/testdir")); + // But works when the recursive option is set + await promises.rm("/tmp/testdir", { recursive: true }); + ok(!existsSync("/tmp/testdir")); + }, +}; + +export const readdirSyncTest = { + test() { + throws(() => readdirSync(), kInvalidArgTypeError); + throws(() => readdirSync(123), kInvalidArgTypeError); + throws( + () => readdirSync("/tmp/testdir", { withFileTypes: 123 }), + kInvalidArgTypeError, + ); + throws( + () => readdirSync("/tmp/testdir", { recursive: 123 }), + kInvalidArgTypeError, + ); + throws( + () => + readdirSync("/tmp/testdir", { withFileTypes: true, recursive: 123 }), + kInvalidArgTypeError, + ); + + deepStrictEqual(readdirSync("/"), ["bundle", "tmp", "dev"]); + + deepStrictEqual(readdirSync("/", "buffer"), [ + Buffer.from("bundle"), + Buffer.from("tmp"), + Buffer.from("dev"), + ]); + + { + const ents = readdirSync("/", { withFileTypes: true }); + strictEqual(ents.length, 3); + + strictEqual(ents[0].name, "bundle"); + strictEqual(ents[0].isDirectory(), true); + strictEqual(ents[0].isFile(), false); + strictEqual(ents[0].isBlockDevice(), false); + strictEqual(ents[0].isCharacterDevice(), false); + strictEqual(ents[0].isFIFO(), false); + strictEqual(ents[0].isSocket(), false); + strictEqual(ents[0].isSymbolicLink(), false); + strictEqual(ents[0].parentPath, "/"); + } + + { + const ents = readdirSync("/", { + withFileTypes: true, + encoding: "buffer", + }); + strictEqual(ents.length, 3); + + deepStrictEqual(ents[0].name, Buffer.from("bundle")); + strictEqual(ents[0].isDirectory(), true); + strictEqual(ents[0].isFile(), false); + strictEqual(ents[0].isBlockDevice(), false); + strictEqual(ents[0].isCharacterDevice(), false); + strictEqual(ents[0].isFIFO(), false); + strictEqual(ents[0].isSocket(), false); + strictEqual(ents[0].isSymbolicLink(), false); + strictEqual(ents[0].parentPath, "/"); + } + + { + const ents = readdirSync("/", { withFileTypes: true, recursive: true }); + strictEqual(ents.length, 8); + + strictEqual(ents[0].name, "bundle"); + strictEqual(ents[0].isDirectory(), true); + strictEqual(ents[0].isFile(), false); + strictEqual(ents[0].isBlockDevice(), false); + strictEqual(ents[0].isCharacterDevice(), false); + strictEqual(ents[0].isFIFO(), false); + strictEqual(ents[0].isSocket(), false); + strictEqual(ents[0].isSymbolicLink(), false); + strictEqual(ents[0].parentPath, "/"); + + strictEqual(ents[1].name, "bundle/worker"); + strictEqual(ents[1].isDirectory(), false); + strictEqual(ents[1].isFile(), true); + strictEqual(ents[1].isBlockDevice(), false); + strictEqual(ents[1].isCharacterDevice(), false); + strictEqual(ents[1].isFIFO(), false); + strictEqual(ents[1].isSocket(), false); + strictEqual(ents[1].isSymbolicLink(), false); + strictEqual(ents[1].parentPath, "/bundle"); + + strictEqual(ents[4].name, "dev/null"); + strictEqual(ents[4].isDirectory(), false); + strictEqual(ents[4].isFile(), false); + strictEqual(ents[4].isBlockDevice(), false); + strictEqual(ents[4].isCharacterDevice(), true); + strictEqual(ents[4].isFIFO(), false); + strictEqual(ents[4].isSocket(), false); + strictEqual(ents[4].isSymbolicLink(), false); + strictEqual(ents[4].parentPath, "/dev"); + } + }, +}; + +export const readdirAsyncCallbackTest = { + async test() { + deepStrictEqual( + await new Promise((resolve, reject) => { + readdir("/", (err, files) => { + if (err) reject(err); + else resolve(files); + }); + }), + ["bundle", "tmp", "dev"], + ); + + { + const ents = await new Promise((resolve, reject) => { + readdir("/", { withFileTypes: true }, (err, files) => { + if (err) reject(err); + else resolve(files); + }); + }); + strictEqual(ents.length, 3); + + strictEqual(ents[0].name, "bundle"); + strictEqual(ents[0].isDirectory(), true); + strictEqual(ents[0].isFile(), false); + strictEqual(ents[0].isBlockDevice(), false); + strictEqual(ents[0].isCharacterDevice(), false); + strictEqual(ents[0].isFIFO(), false); + strictEqual(ents[0].isSocket(), false); + strictEqual(ents[0].isSymbolicLink(), false); + strictEqual(ents[0].parentPath, "/"); + } + + { + const ents = await new Promise((resolve, reject) => { + readdir( + "/", + { withFileTypes: true, encoding: "buffer" }, + (err, files) => { + if (err) reject(err); + else resolve(files); + }, + ); + }); + strictEqual(ents.length, 3); + + deepStrictEqual(ents[0].name, Buffer.from("bundle")); + strictEqual(ents[0].isDirectory(), true); + strictEqual(ents[0].isFile(), false); + strictEqual(ents[0].isBlockDevice(), false); + strictEqual(ents[0].isCharacterDevice(), false); + strictEqual(ents[0].isFIFO(), false); + strictEqual(ents[0].isSocket(), false); + strictEqual(ents[0].isSymbolicLink(), false); + strictEqual(ents[0].parentPath, "/"); + } + + { + const ents = await new Promise((resolve, reject) => { + readdir("/", { withFileTypes: true, recursive: true }, (err, files) => { + if (err) reject(err); + else resolve(files); + }); + }); + strictEqual(ents.length, 8); + + strictEqual(ents[0].name, "bundle"); + strictEqual(ents[0].isDirectory(), true); + strictEqual(ents[0].isFile(), false); + strictEqual(ents[0].isBlockDevice(), false); + strictEqual(ents[0].isCharacterDevice(), false); + strictEqual(ents[0].isFIFO(), false); + strictEqual(ents[0].isSocket(), false); + strictEqual(ents[0].isSymbolicLink(), false); + strictEqual(ents[0].parentPath, "/"); + + strictEqual(ents[1].name, "bundle/worker"); + strictEqual(ents[1].isDirectory(), false); + strictEqual(ents[1].isFile(), true); + strictEqual(ents[1].isBlockDevice(), false); + strictEqual(ents[1].isCharacterDevice(), false); + strictEqual(ents[1].isFIFO(), false); + strictEqual(ents[1].isSocket(), false); + strictEqual(ents[1].isSymbolicLink(), false); + strictEqual(ents[1].parentPath, "/bundle"); + + strictEqual(ents[4].name, "dev/null"); + strictEqual(ents[4].isDirectory(), false); + strictEqual(ents[4].isFile(), false); + strictEqual(ents[4].isBlockDevice(), false); + strictEqual(ents[4].isCharacterDevice(), true); + strictEqual(ents[4].isFIFO(), false); + strictEqual(ents[4].isSocket(), false); + strictEqual(ents[4].isSymbolicLink(), false); + strictEqual(ents[4].parentPath, "/dev"); + } + }, +}; + +export const readdirAsyncPromiseTest = { + async test() { + deepStrictEqual(await promises.readdir("/"), ["bundle", "tmp", "dev"]); + + { + const ents = await promises.readdir("/", { withFileTypes: true }); + strictEqual(ents.length, 3); + + strictEqual(ents[0].name, "bundle"); + strictEqual(ents[0].isDirectory(), true); + strictEqual(ents[0].isFile(), false); + strictEqual(ents[0].isBlockDevice(), false); + strictEqual(ents[0].isCharacterDevice(), false); + strictEqual(ents[0].isFIFO(), false); + strictEqual(ents[0].isSocket(), false); + strictEqual(ents[0].isSymbolicLink(), false); + strictEqual(ents[0].parentPath, "/"); + } + + { + const ents = await promises.readdir("/", { + withFileTypes: true, + encoding: "buffer", + }); + strictEqual(ents.length, 3); + + deepStrictEqual(ents[0].name, Buffer.from("bundle")); + strictEqual(ents[0].isDirectory(), true); + strictEqual(ents[0].isFile(), false); + strictEqual(ents[0].isBlockDevice(), false); + strictEqual(ents[0].isCharacterDevice(), false); + strictEqual(ents[0].isFIFO(), false); + strictEqual(ents[0].isSocket(), false); + strictEqual(ents[0].isSymbolicLink(), false); + strictEqual(ents[0].parentPath, "/"); + } + + { + const ents = await promises.readdir("/", { + withFileTypes: true, + recursive: true, + }); + strictEqual(ents.length, 8); + + strictEqual(ents[0].name, "bundle"); + strictEqual(ents[0].isDirectory(), true); + strictEqual(ents[0].isFile(), false); + strictEqual(ents[0].isBlockDevice(), false); + strictEqual(ents[0].isCharacterDevice(), false); + strictEqual(ents[0].isFIFO(), false); + strictEqual(ents[0].isSocket(), false); + strictEqual(ents[0].isSymbolicLink(), false); + strictEqual(ents[0].parentPath, "/"); + + strictEqual(ents[1].name, "bundle/worker"); + strictEqual(ents[1].isDirectory(), false); + strictEqual(ents[1].isFile(), true); + strictEqual(ents[1].isBlockDevice(), false); + strictEqual(ents[1].isCharacterDevice(), false); + strictEqual(ents[1].isFIFO(), false); + strictEqual(ents[1].isSocket(), false); + strictEqual(ents[1].isSymbolicLink(), false); + strictEqual(ents[1].parentPath, "/bundle"); + strictEqual(ents[4].name, "dev/null"); + strictEqual(ents[4].isDirectory(), false); + strictEqual(ents[4].isFile(), false); + strictEqual(ents[4].isBlockDevice(), false); + strictEqual(ents[4].isCharacterDevice(), true); + strictEqual(ents[4].isFIFO(), false); + strictEqual(ents[4].isSocket(), false); + strictEqual(ents[4].isSymbolicLink(), false); + strictEqual(ents[4].parentPath, "/dev"); + } + }, +}; + +export const opendirSyncTest = { + test() { + throws(() => opendirSync(), kInvalidArgTypeError); + throws(() => opendirSync(123), kInvalidArgTypeError); + throws(() => opendirSync("/tmp", { encoding: 123 }), kInvalidArgValueError); + + const dir = opendirSync("/", { recursive: true }); + strictEqual(dir.path, "/"); + strictEqual(dir.readSync().name, "bundle"); + strictEqual(dir.readSync().name, "bundle/worker"); + strictEqual(dir.readSync().name, "tmp"); + strictEqual(dir.readSync().name, "dev"); + strictEqual(dir.readSync().name, "dev/null"); + strictEqual(dir.readSync().name, "dev/zero"); + strictEqual(dir.readSync().name, "dev/full"); + strictEqual(dir.readSync().name, "dev/random"); + strictEqual(dir.readSync(), null); // All done. + dir.closeSync(); + + // Closing again throws + throws(() => dir.closeSync(), { code: "ERR_DIR_CLOSED" }); + // Reading again throws + throws(() => dir.readSync(), { code: "ERR_DIR_CLOSED" }); + }, +}; + +export const opendirSyncAndAsyncTest = { + async test() { + throws(() => opendir(), kInvalidArgTypeError); + throws(() => opendir(123), kInvalidArgTypeError); + throws(() => opendir("/tmp", { encoding: 123 }), kInvalidArgValueError); + + const { promise, resolve, reject } = Promise.withResolvers(); + opendir("/", { recursive: true }, (err, dir) => { + if (err) reject(err); + else resolve(dir); + }); + + await using dir = await promise; + + strictEqual((await dir.read()).name, "bundle"); + strictEqual((await dir.read()).name, "bundle/worker"); + strictEqual((await dir.read()).name, "tmp"); + strictEqual((await dir.read()).name, "dev"); + strictEqual((await dir.read()).name, "dev/null"); + strictEqual((await dir.read()).name, "dev/zero"); + strictEqual((await dir.read()).name, "dev/full"); + strictEqual((await dir.read()).name, "dev/random"); + strictEqual(await dir.read(), null); // All done. + }, +}; + +export const opendirSyncAndAsyncTest2 = { + async test() { + throws(() => opendir(), kInvalidArgTypeError); + throws(() => opendir(123), kInvalidArgTypeError); + throws(() => opendir("/tmp", { encoding: 123 }), kInvalidArgValueError); + + const { promise, resolve, reject } = Promise.withResolvers(); + opendir("/", { recursive: true }, (err, dir) => { + if (err) reject(err); + else resolve(dir); + }); + + await using dir = await promise; + + const entries = await Array.fromAsync(dir); + + strictEqual(entries.length, 8); + strictEqual(entries[0].name, "bundle"); + strictEqual(entries[1].name, "bundle/worker"); + strictEqual(entries[2].name, "tmp"); + strictEqual(entries[3].name, "dev"); + strictEqual(entries[4].name, "dev/null"); + strictEqual(entries[5].name, "dev/zero"); + strictEqual(entries[6].name, "dev/full"); + strictEqual(entries[7].name, "dev/random"); + }, +}; diff --git a/src/compat/fs/upstream-filehandle.fixture.mjs b/src/compat/fs/upstream-filehandle.fixture.mjs new file mode 100644 index 0000000..3fc916f --- /dev/null +++ b/src/compat/fs/upstream-filehandle.fixture.mjs @@ -0,0 +1,170 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +import { ok, notStrictEqual, strictEqual, rejects } from "node:assert"; + +// sync and datasync are generally non-ops. The most they do is +// verify that the file descriptor is valid. + +import { promises } from "./index.js"; + +export const openCloseTest = { + async test() { + const fileHandle = await promises.open("/tmp/test.txt", "w+"); + strictEqual(fileHandle.constructor.name, "FileHandle"); + + // If the FileHandle is opened successfully, stat should work. + const stat = await fileHandle.stat(); + ok(stat.isFile()); + + // Close the file handle + await fileHandle.close(); + + // Verify that the file handle is closed + await rejects(fileHandle.stat(), { + code: "EBADF", + }); + }, +}; + +export const chmodChownTest = { + async test() { + await using fileHandle = await promises.open("/tmp/test.txt", "w+"); + + const stat = await fileHandle.stat(); + ok(stat.isFile()); + + // Change the file mode + await fileHandle.chmod(0o644); + + // Verify the mode has not changed since chmod is a no-op + const newStat = await fileHandle.stat(); + strictEqual(newStat.mode, stat.mode); + + // Change the file ownership + await fileHandle.chown(1000, 1000); + // Verify the ownership has not changed since chown is a no-op + const newStat2 = await fileHandle.stat(); + strictEqual(newStat2.uid, stat.uid); + strictEqual(newStat2.gid, stat.gid); + }, +}; + +export const syncTest = { + async test() { + await using fileHandle = await promises.open("/tmp/test.txt", "w+"); + strictEqual(fileHandle.constructor.name, "FileHandle"); + + // If the FileHandle is opened successfully, stat should work. + const stat = await fileHandle.stat(); + ok(stat.isFile()); + + // These are non-ops with no testable side effect. Just make + // sure they don't reject. + await fileHandle.sync(); + await fileHandle.datasync(); + }, +}; + +export const writeAppendReadFileTest = { + async test() { + await using fileHandle = await promises.open("/tmp/test.txt", "a+"); + strictEqual(fileHandle.constructor.name, "FileHandle"); + + await fileHandle.writeFile("Hello, World"); + + // Append some data to the file + await fileHandle.appendFile("!!!!\n"); + + // Read the file back + const data = await fileHandle.readFile("utf8"); + strictEqual(data, "Hello, World!!!!\n"); + }, +}; + +export const writeReadTest = { + async test() { + // Note that we're opening the file in append mode... + await using fileHandle = await promises.open("/tmp/test.txt", "a+"); + strictEqual(fileHandle.constructor.name, "FileHandle"); + + // Write some data to the file + await fileHandle.write("Hello, World"); + + // Append some data to the file + await fileHandle.write("!!!!\n"); + + // Read the file back + const buffer = Buffer.alloc(100); + const { bytesRead } = await fileHandle.read(buffer, 0, buffer.length, 0); + strictEqual(bytesRead, 17); + strictEqual(buffer.toString("utf8", 0, bytesRead), "Hello, World!!!!\n"); + + // Use readv + const buffer2 = Buffer.alloc(10); + const buffer3 = Buffer.alloc(10); + const { bytesRead: bytesRead2 } = await fileHandle.readv( + [buffer2, buffer3], + 0, + ); + strictEqual(bytesRead2, 17); + const buffer4 = Buffer.concat([buffer2, buffer3]); + strictEqual(buffer4.toString("utf8", 0, bytesRead2), "Hello, World!!!!\n"); + + await fileHandle.writev( + [Buffer.from("More data"), Buffer.from(" to write\n")], + 0, + ); + // Read the file back again + const buffer5 = Buffer.alloc(100); + const { bytesRead: bytesRead3 } = await fileHandle.read( + buffer5, + 0, + buffer5.length, + 0, + ); + strictEqual(bytesRead3, 36); + strictEqual( + buffer5.toString("utf8", 0, bytesRead3), + "Hello, World!!!!\nMore data to write\n", + ); + }, +}; + +export const truncateTest = { + async test() { + await using fileHandle = await promises.open("/tmp/test.txt", "w+"); + strictEqual(fileHandle.constructor.name, "FileHandle"); + + // Write some data to the file + await fileHandle.writeFile("Hello, World"); + + // Truncate the file to 5 bytes + await fileHandle.truncate(5); + + // Read the file back + const data = await fileHandle.readFile("utf8"); + strictEqual(data, "Hello"); + }, +}; + +export const utimesTest = { + async test() { + await using fileHandle = await promises.open("/tmp/test.txt", "w+"); + strictEqual(fileHandle.constructor.name, "FileHandle"); + + // If the FileHandle is opened successfully, stat should work. + const stat = await fileHandle.stat(); + ok(stat.isFile()); + + // Update the access and modification times + const now = new Date(); + await fileHandle.utimes(now, now); + + const newStat = await fileHandle.stat(); + + // atime should not be changed, mtime should. + strictEqual(newStat.atime.getTime(), stat.atime.getTime()); + notStrictEqual(newStat.mtime.getTime(), stat.mtime.getTime()); + }, +}; diff --git a/src/compat/fs/upstream-glob.fixture.mjs b/src/compat/fs/upstream-glob.fixture.mjs new file mode 100644 index 0000000..5baca2c --- /dev/null +++ b/src/compat/fs/upstream-glob.fixture.mjs @@ -0,0 +1,357 @@ +// Copyright (c) 2025 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +import { deepStrictEqual, ok, strictEqual } from "node:assert"; +import { glob, globSync, mkdirSync, writeFileSync, promises } from "./index.js"; + +// Node.js test fixture structure (minus symlinks — not supported in workerd VFS tests) +function setupFixtures() { + const base = "/tmp/gt"; + mkdirSync(base + "/a/.abcdef/x/y/z", { recursive: true }); + mkdirSync(base + "/a/abcdef/g", { recursive: true }); + mkdirSync(base + "/a/abcfed/g", { recursive: true }); + mkdirSync(base + "/a/b/c", { recursive: true }); + mkdirSync(base + "/a/bc/e", { recursive: true }); + mkdirSync(base + "/a/c/d/c", { recursive: true }); + mkdirSync(base + "/a/cb/e", { recursive: true }); + mkdirSync(base + "/a/x/.y", { recursive: true }); + mkdirSync(base + "/a/z/.y", { recursive: true }); + + writeFileSync(base + "/a/.abcdef/x/y/z/a", "x"); + writeFileSync(base + "/a/abcdef/g/h", "x"); + writeFileSync(base + "/a/abcfed/g/h", "x"); + writeFileSync(base + "/a/b/c/d", "x"); + writeFileSync(base + "/a/bc/e/f", "x"); + writeFileSync(base + "/a/c/d/c/b", "x"); + writeFileSync(base + "/a/cb/e/f", "x"); + writeFileSync(base + "/a/x/.y/b", "x"); + writeFileSync(base + "/a/z/.y/b", "x"); + writeFileSync(base + "/a/.b", "x"); + writeFileSync(base + "/a/b/.b", "x"); + + return base; +} + +const cwd = "/tmp/gt"; + +// ============================================================================ +// Pattern Matching Tests (ported from Node.js) +// ============================================================================ + +export const singleWildcardInSubdir = { + test() { + setupFixtures(); + const result = globSync("a/c/d/*/b", { cwd }); + deepStrictEqual(result.sort(), ["a/c/d/c/b"]); + }, +}; + +export const doubleSlashNormalization = { + test() { + setupFixtures(); + // Double slashes should be normalized + const result = globSync("a//c//d//*//b", { cwd }); + deepStrictEqual(result.sort(), ["a/c/d/c/b"]); + }, +}; + +export const multipleWildcards = { + test() { + setupFixtures(); + const result = globSync("a/*/d/*/b", { cwd }); + deepStrictEqual(result.sort(), ["a/c/d/c/b"]); + }, +}; + +export const extglobPlus = { + test() { + setupFixtures(); + // +(c|g) should match one or more occurrences of c or g + // a/*/+(c|g)/./d → matches a/b/c/./d which resolves to a/b/c/d + const result = globSync("a/*/+(c|g)/./d", { cwd }); + deepStrictEqual(result.sort(), ["a/b/c/d"]); + }, +}; + +export const braceExpansion = { + test() { + setupFixtures(); + const result = globSync("a/abc{fed,def}/g/h", { cwd }); + deepStrictEqual(result.sort(), ["a/abcdef/g/h", "a/abcfed/g/h"]); + }, +}; + +export const braceExpansionNoMatch = { + test() { + setupFixtures(); + // None of b,c,d,e,f directories have a **/g path + const result = globSync("a/{b,c,d,e,f}/**/g", { cwd }); + deepStrictEqual(result, []); + }, +}; + +export const globstarMatchesAll = { + test() { + setupFixtures(); + // a/b/** should match a/b itself and everything under it + const result = globSync("a/b/**", { cwd }); + deepStrictEqual(result.sort(), ["a/b", "a/b/.b", "a/b/c", "a/b/c/d"]); + }, +}; + +export const dotSlashGlobstar = { + test() { + setupFixtures(); + // ./**/g should find all 'g' entries + const result = globSync("./**/g", { cwd }); + deepStrictEqual(result.sort(), ["a/abcdef/g", "a/abcfed/g"]); + }, +}; + +export const globstarA = { + test() { + setupFixtures(); + // **/a — find all entries named 'a' + const result = globSync("**/a", { cwd }); + ok(result.includes("a")); + ok(result.includes("a/.abcdef/x/y/z/a")); + }, +}; + +export const threeWildcardLevelsF = { + test() { + setupFixtures(); + const result = globSync("*/*/*/f", { cwd }); + deepStrictEqual(result.sort(), ["a/bc/e/f", "a/cb/e/f"]); + }, +}; + +export const dotSlashGlobstarF = { + test() { + setupFixtures(); + const result = globSync("./**/f", { cwd }); + deepStrictEqual(result.sort(), ["a/bc/e/f", "a/cb/e/f"]); + }, +}; + +export const dotFileGlobstar = { + test() { + setupFixtures(); + // **/.b should match dot files named .b + const result = globSync("**/.b", { cwd }); + deepStrictEqual(result.sort(), ["a/.b", "a/b/.b"]); + }, +}; + +export const globstarCharClass = { + test() { + setupFixtures(); + // a/**/[cg] — match entries named 'c' or 'g' at any depth under a/ + const result = globSync("a/**/[cg]", { cwd }); + ok(result.includes("a/abcdef/g")); + ok(result.includes("a/abcfed/g")); + ok(result.includes("a/b/c")); + ok(result.includes("a/c")); + ok(result.includes("a/c/d/c")); + }, +}; + +export const parentTraversal = { + test() { + setupFixtures(); + // a/**/[cg]/../[cg] — go into [cg] dir, go up, match [cg] again + const result = globSync("a/**/[cg]/../[cg]", { cwd }); + ok(result.length > 0); + // Should find paths where a directory matching [cg] has a sibling matching [cg] + ok(result.includes("a/c")); + }, +}; + +export const extglobNegation = { + test() { + setupFixtures(); + // a/!(doesnotexist)/** — match all under a/ except entries named 'doesnotexist' + const result = globSync("a/!(doesnotexist)/**", { cwd }); + ok(result.length > 0); + ok(result.includes("a/b")); + ok(result.includes("a/b/c")); + ok(result.includes("a/b/c/d")); + }, +}; + +// ============================================================================ +// Exclude Tests +// ============================================================================ + +export const excludeFunction = { + test() { + setupFixtures(); + // Exclude everything — should return empty + const result = globSync("a/**", { cwd, exclude: () => true }); + deepStrictEqual(result, []); + }, +}; + +export const excludeFunctionSelective = { + test() { + setupFixtures(); + // Exclude paths containing 'abcdef' + const result = globSync("a/abc*/g/h", { + cwd, + exclude: (p) => p.includes("abcdef"), + }); + deepStrictEqual(result, ["a/abcfed/g/h"]); + }, +}; + +export const excludeArrayPattern = { + test() { + setupFixtures(); + // Exclude using array of glob patterns + const result = globSync("a/abc*/g/h", { + cwd, + exclude: ["**/abcdef/**"], + }); + deepStrictEqual(result, ["a/abcfed/g/h"]); + }, +}; + +export const excludeArrayMatchesAll = { + test() { + setupFixtures(); + // Exclude all with wildcard + const result = globSync("a/**", { cwd, exclude: ["**"] }); + deepStrictEqual(result, []); + }, +}; + +// ============================================================================ +// withFileTypes Tests +// ============================================================================ + +export const withFileTypesTest = { + test() { + setupFixtures(); + const result = globSync("a/b/c/d", { cwd, withFileTypes: true }); + strictEqual(result.length, 1); + const dirent = result[0]; + strictEqual(dirent.name, "d"); + ok(dirent.isFile()); + ok(!dirent.isDirectory()); + }, +}; + +export const withFileTypesDirTest = { + test() { + setupFixtures(); + const result = globSync("a/b/c", { cwd, withFileTypes: true }); + strictEqual(result.length, 1); + const dirent = result[0]; + strictEqual(dirent.name, "c"); + ok(dirent.isDirectory()); + ok(!dirent.isFile()); + }, +}; + +// ============================================================================ +// Callback API Tests +// ============================================================================ + +export const callbackGlobTest = { + async test() { + setupFixtures(); + const result = await new Promise((resolve, reject) => { + glob("a/abc{fed,def}/g/h", { cwd }, (err, matches) => { + if (err) reject(err); + else resolve(matches); + }); + }); + deepStrictEqual([...result].sort(), ["a/abcdef/g/h", "a/abcfed/g/h"]); + }, +}; + +export const callbackGlobNoOptions = { + async test() { + const result = await new Promise((resolve, reject) => { + glob("*.nonexistent", (err, matches) => { + if (err) reject(err); + else resolve(matches); + }); + }); + deepStrictEqual(result, []); + }, +}; + +// ============================================================================ +// Promises API Tests +// ============================================================================ + +export const promisesGlobTest = { + async test() { + setupFixtures(); + const results = []; + for await (const entry of promises.glob("a/abc{fed,def}/g/h", { cwd })) { + results.push(entry); + } + deepStrictEqual(results.sort(), ["a/abcdef/g/h", "a/abcfed/g/h"]); + }, +}; + +export const promisesGlobstarTest = { + async test() { + setupFixtures(); + const results = []; + for await (const entry of promises.glob("**/.b", { cwd })) { + results.push(entry); + } + deepStrictEqual(results.sort(), ["a/.b", "a/b/.b"]); + }, +}; + +// ============================================================================ +// Edge Cases +// ============================================================================ + +export const noMatchTest = { + test() { + setupFixtures(); + const result = globSync("nonexistent/**", { cwd }); + deepStrictEqual(result, []); + }, +}; + +export const exactPathTest = { + test() { + setupFixtures(); + // Exact path match (no wildcards) + const result = globSync("a/b/c/d", { cwd }); + deepStrictEqual(result, ["a/b/c/d"]); + }, +}; + +export const defaultCwdTest = { + test() { + // Without cwd option, should use process.cwd() and not throw + const result = globSync("*.nonexistent"); + deepStrictEqual(result, []); + }, +}; + +export const multiplePatternsTest = { + test() { + setupFixtures(); + const result = globSync(["a/b/c/d", "a/bc/e/f"], { cwd }); + deepStrictEqual(result.sort(), ["a/b/c/d", "a/bc/e/f"]); + }, +}; + +export const braceWithGlobstar = { + test() { + setupFixtures(); + // a/{b/**,b/c} should match a/b and everything under it, plus a/b/c + const result = globSync("a/{b/**,b/c}", { cwd }); + ok(result.includes("a/b")); + ok(result.includes("a/b/c")); + ok(result.includes("a/b/c/d")); + }, +}; diff --git a/src/compat/fs/upstream-link.fixture.mjs b/src/compat/fs/upstream-link.fixture.mjs new file mode 100644 index 0000000..228f3a7 --- /dev/null +++ b/src/compat/fs/upstream-link.fixture.mjs @@ -0,0 +1,468 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +import { deepStrictEqual, ok, rejects, strictEqual, throws } from "node:assert"; + +import { + existsSync, + statSync, + linkSync, + lstatSync, + symlinkSync, + readlinkSync, + realpathSync, + unlinkSync, + link, + symlink, + readlink, + realpath, + unlink, + promises, +} from "./index.js"; + +strictEqual(typeof existsSync, "function"); +strictEqual(typeof statSync, "function"); +strictEqual(typeof linkSync, "function"); +strictEqual(typeof lstatSync, "function"); +strictEqual(typeof symlinkSync, "function"); +strictEqual(typeof readlinkSync, "function"); +strictEqual(typeof realpathSync, "function"); +strictEqual(typeof unlinkSync, "function"); +strictEqual(typeof link, "function"); +strictEqual(typeof symlink, "function"); +strictEqual(typeof readlink, "function"); +strictEqual(typeof realpath, "function"); +strictEqual(typeof unlink, "function"); +strictEqual(typeof promises.link, "function"); +strictEqual(typeof promises.symlink, "function"); +strictEqual(typeof promises.readlink, "function"); +strictEqual(typeof promises.realpath, "function"); +strictEqual(typeof promises.unlink, "function"); + +const src = "/dev/null"; +const dst = "/tmp/test-link"; + +const kInvalidArgTypeError = { code: "ERR_INVALID_ARG_TYPE" }; +const kEInvalError = { code: "EINVAL" }; +const kENoentError = { code: "ENOENT" }; +const kEExistError = { code: "EEXIST" }; +const kEPermError = { code: "EPERM" }; +const kEIsDir = { code: "EISDIR" }; + +export const linkAndUnlinkSyncTest = { + test() { + throws(() => linkSync(), kInvalidArgTypeError); + throws(() => linkSync(""), kInvalidArgTypeError); + throws(() => realpathSync(), kInvalidArgTypeError); + throws(() => readlinkSync(), kInvalidArgTypeError); + throws(() => unlinkSync(), kInvalidArgTypeError); + + ok(existsSync(src)); + ok(!existsSync(dst)); + linkSync(src, dst); + ok(existsSync(dst)); + + // These are the same file. + deepStrictEqual(statSync(dst), statSync(src)); + deepStrictEqual(lstatSync(dst), statSync(src)); + + // Because this is a hard link, the realpath is /tmp/a + strictEqual(realpathSync(dst), dst); + + // And readlinkSync throws + throws(() => readlinkSync(dst), kEInvalError); + + // Linking from a non-existent file throws. + throws(() => linkSync("/does/not/exist", "/tmp/abc"), kENoentError); + + // Make sure the destination does not exist after the failed link. + ok(!existsSync("/tmp/abc")); + throws(() => linkSync("/dev/foo", "/tmp/abc"), kENoentError); + ok(!existsSync("/tmp/abc")); + + // Creating a link in a non-existent directory throws. + throws(() => linkSync("/dev/null", "/tmp/abc/xyz"), kENoentError); + + // Linking when the path already exists throws. + throws(() => linkSync(src, dst), kEExistError); + + // Linking with an empty file name throws. + throws(() => linkSync(src, "/foo/"), kEInvalError); + + // Invalid paths throw + throws(() => linkSync(src, "/dev/null/test"), kEInvalError); + throws(() => linkSync("/dev/null/test", "/tmp/null/test"), kENoentError); + + // Attempting to link directories throws + throws(() => linkSync("/tmp", "/tmp/abc"), kEPermError); + + // The destination can be unlinked + unlinkSync(dst); + ok(!existsSync(dst)); + + // But unlinking does not remove the source. + ok(existsSync(src)); + + // Cannot unlink read-only files + throws(() => unlinkSync("/bundle/worker"), kEPermError); + throws(() => unlinkSync("/bundle"), kEIsDir); + + // Make sure that the failed unlink had no effect. + ok(existsSync("/bundle/worker")); + ok(existsSync("/bundle")); + }, +}; + +export const linkAndUnlinkAsyncCallbackTest = { + async test() { + const doLinkTest = async (src, dst) => { + const { promise, resolve, reject } = Promise.withResolvers(); + link(src, dst, (err) => { + try { + if (err) return reject(err); + ok(existsSync(dst)); + deepStrictEqual(statSync(dst), statSync(src)); + deepStrictEqual(lstatSync(dst), statSync(src)); + strictEqual(realpathSync(dst), dst); + throws(() => readlinkSync(dst), kEInvalError); + } catch (err) { + reject(err); + } + resolve(); + }); + await promise; + }; + + const doFailedLinkTest = async (src, dst, expectedError) => { + const { promise, resolve, reject } = Promise.withResolvers(); + link(src, dst, (err) => { + try { + strictEqual(err.code, expectedError.code); + ok(!existsSync(dst)); + } catch (err) { + reject(err); + } + resolve(); + }); + await promise; + }; + + const doUnlinkTest = async (dst) => { + const { promise, resolve, reject } = Promise.withResolvers(); + unlink(dst, (err) => { + try { + if (err) return reject(err); + ok(!existsSync(dst)); + ok(existsSync(src)); + } catch (err) { + reject(err); + } + resolve(); + }); + await promise; + }; + + const doFailedUnlinkTest = async (dst, expectedError) => { + const { promise, resolve, reject } = Promise.withResolvers(); + unlink(dst, (err) => { + try { + strictEqual(err.code, expectedError.code); + ok(existsSync(dst)); + } catch (err) { + reject(err); + } + resolve(); + }); + await promise; + }; + + throws(() => link(), kInvalidArgTypeError); + throws(() => link(""), kInvalidArgTypeError); + throws(() => link("", ""), kInvalidArgTypeError); + throws(() => realpath(), kInvalidArgTypeError); + throws(() => realpath(""), kInvalidArgTypeError); + throws(() => readlink(), kInvalidArgTypeError); + throws(() => readlink(""), kInvalidArgTypeError); + throws(() => unlink(), kInvalidArgTypeError); + throws(() => unlink(""), kInvalidArgTypeError); + + ok(existsSync(src)); + ok(!existsSync(dst)); + await doLinkTest(src, dst); + + await doFailedLinkTest("/does/not/exist", "/tmp/abc", kENoentError); + ok(!existsSync("/tmp/abc")); + + await doFailedLinkTest("/dev/foo", "/tmp/abc", kENoentError); + ok(!existsSync("/tmp/abc")); + + // Creating a link in a non-existent directory throws. + await doFailedLinkTest("/dev/null", "/tmp/abc/xyz", kENoentError); + + // Invalid paths throw + await doFailedLinkTest(src, "/dev/null/test", kEInvalError); + await doFailedLinkTest("/dev/null/test", "/tmp/null/test", kENoentError); + + // Attempting to link directories throws + await doFailedLinkTest("/tmp", "/tmp/abc", kEPermError); + + await doUnlinkTest(dst); + + await doFailedUnlinkTest("/bundle/worker", kEPermError); + await doFailedUnlinkTest("/bundle", kEIsDir); + }, +}; + +export const linkAndLinkPromisesTest = { + async test() { + await rejects(promises.link(), kInvalidArgTypeError); + await rejects(promises.link(""), kInvalidArgTypeError); + await rejects(promises.realpath(), kInvalidArgTypeError); + await rejects(promises.readlink(), kInvalidArgTypeError); + await rejects(promises.unlink(), kInvalidArgTypeError); + + ok(existsSync(src)); + ok(!existsSync(dst)); + await promises.link(src, dst); + + // These are the same file. + deepStrictEqual(statSync(dst), statSync(src)); + deepStrictEqual(lstatSync(dst), statSync(src)); + + // Because this is a hard link, the realpath is /tmp/a + strictEqual(await promises.realpath(dst), dst); + + // And readlinkSync throws + await rejects(promises.readlink(dst), kEInvalError); + + await rejects(promises.link("/does/not/exist", "/tmp/abc"), kENoentError); + ok(!existsSync("/tmp/abc")); + + await rejects(promises.link("/dev/null", "/tmp/abc/xyz"), kENoentError); + + await rejects(promises.link(src, dst), kEExistError); + + await rejects(promises.link(src, "/foo/"), kEInvalError); + + await rejects(promises.link(src, "/dev/null/test"), kEInvalError); + + await rejects( + promises.link("/dev/null/test", "/tmp/null/test"), + kENoentError, + ); + + await rejects(promises.link("/tmp", "/tmp/abc"), kEPermError); + + await promises.unlink(dst); + ok(!existsSync(dst)); + // But unlinking does not remove the source. + ok(existsSync(src)); + + await rejects(promises.unlink("/bundle/worker"), kEPermError); + await rejects(promises.unlink("/bundle"), kEIsDir); + }, +}; + +export const symlinkAndUnlinkSyncTest = { + test() { + throws(() => symlinkSync(), kInvalidArgTypeError); + throws(() => symlinkSync(""), kInvalidArgTypeError); + + ok(existsSync(src)); + ok(!existsSync(dst)); + symlinkSync(src, dst); + ok(existsSync(dst)); + + deepStrictEqual(statSync(dst), statSync(src)); + + // The symlink points to the source. + strictEqual(readlinkSync(dst), src); + + // The realpath is the destination + strictEqual(realpathSync(dst), src); + + // And readlinkSync does not throw + strictEqual(readlinkSync(dst), src); + + // Linking from a non-existent file works + symlinkSync("/does/not/exist", "/tmp/abc"); + + // Exists sync follows the symlink, and show return false. + ok(!existsSync("/tmp/abc")); + + // But the symlink does exist. + const linkStat = lstatSync("/tmp/abc"); + ok(linkStat.isSymbolicLink()); + + // Creating a link in a non-existent directory throws. + throws(() => symlinkSync("/dev/null", "/tmp/abc/xyz"), kENoentError); + + // Linking when the path already exists throws. + throws(() => symlinkSync(src, dst), kEExistError); + + // Linking with an empty file name throws. + throws(() => symlinkSync(src, "/foo/"), kEInvalError); + + // Invalid paths throw + throws(() => symlinkSync(src, "/dev/null/test"), kEInvalError); + throws(() => symlinkSync("/dev/null/test", "/tmp/null/test"), kENoentError); + + // Symlinking directories works. + symlinkSync("/tmp", "/tmp/linked-dir"); + ok(existsSync("/tmp/linked-dir")); + + // The destination can be unlinked + unlinkSync(dst); + ok(!existsSync(dst)); + + // But unlinking does not remove the source. + ok(existsSync(src)); + }, +}; + +export const symlinkAndUnlinkAsyncCallbackTest = { + async test() { + throws(() => symlink(), kInvalidArgTypeError); + throws(() => symlink(""), kInvalidArgTypeError); + throws(() => symlink("", ""), kInvalidArgTypeError); + throws(() => realpath(), kInvalidArgTypeError); + throws(() => realpath(""), kInvalidArgTypeError); + throws(() => readlink(), kInvalidArgTypeError); + throws(() => readlink(""), kInvalidArgTypeError); + throws(() => unlink(), kInvalidArgTypeError); + throws(() => unlink(""), kInvalidArgTypeError); + + const doSymlinkTest = async (src, dst) => { + const { promise, resolve, reject } = Promise.withResolvers(); + symlink(src, dst, (err) => { + if (err) return reject(err); + try { + const srcStat = statSync(src, { throwIfNoEntry: false }); + if (srcStat != null) { + const dstStat = statSync(dst, { throwIfNoEntry: false }); + deepStrictEqual(dstStat, srcStat); + } else { + // The source does not exist but the symlink should still. + const dstStat = lstatSync(dst); + ok(dstStat.isSymbolicLink()); + } + strictEqual(readlinkSync(dst), src); + strictEqual(realpathSync(dst), src); + } catch (err) { + reject(err); + } + resolve(); + }); + await promise; + }; + + const doFailedSymlinkTest = async (src, dst, expectedError) => { + const { promise, resolve, reject } = Promise.withResolvers(); + symlink(src, dst, (err) => { + try { + strictEqual(err.code, expectedError.code); + } catch (err) { + reject(err); + } + resolve(); + }); + await promise; + }; + + const doUnlinkTest = async (dst) => { + const { promise, resolve, reject } = Promise.withResolvers(); + unlink(dst, (err) => { + try { + if (err) return reject(err); + ok(!existsSync(dst)); + ok(existsSync(src)); + } catch (err) { + reject(err); + } + resolve(); + }); + await promise; + }; + + ok(existsSync(src)); + ok(!existsSync(dst)); + await doSymlinkTest(src, dst); + await doSymlinkTest("/does/not/exist", "/tmp/abc"); + + // Exists sync follows the symlink, and show return false. + ok(!existsSync("/tmp/abc")); + + await doFailedSymlinkTest(src, dst, kEExistError); + await doFailedSymlinkTest(src, "/foo/", kEInvalError); + await doFailedSymlinkTest(src, "/dev/null/test", kEInvalError); + await doFailedSymlinkTest("/dev/null/test", "/tmp/null/test", kENoentError); + + // Symlinking directories works. + await doSymlinkTest("/tmp", "/tmp/linked-dir"); + + // The destination can be unlinked + await doUnlinkTest(dst); + }, +}; + +export const symlinkAndUnlinkPromisesTest = { + async test() { + await rejects(promises.symlink(), kInvalidArgTypeError); + await rejects(promises.symlink(""), kInvalidArgTypeError); + await rejects(promises.realpath(), kInvalidArgTypeError); + await rejects(promises.readlink(), kInvalidArgTypeError); + await rejects(promises.unlink(), kInvalidArgTypeError); + + ok(existsSync(src)); + ok(!existsSync(dst)); + await promises.symlink(src, dst); + + deepStrictEqual(statSync(dst), statSync(src)); + + // The symlink points to the source. + strictEqual(await promises.readlink(dst), src); + + // The realpath is the destination + strictEqual(await promises.realpath(dst), src); + + // And readlink does not throw + strictEqual(await promises.readlink(dst), src); + + // Linking from a non-existent file works + await promises.symlink("/does/not/exist", "/tmp/abc"); + + // Exists sync follows the symlink, and show return false. + ok(!existsSync("/tmp/abc")); + + // But the symlink does exist. + const linkStat = lstatSync("/tmp/abc"); + ok(linkStat.isSymbolicLink()); + + // Creating a link in a non-existent directory throws. + await rejects(promises.symlink("/dev/null", "/tmp/abc/xyz"), kENoentError); + + // Linking when the path already exists throws. + await rejects(promises.symlink(src, dst), kEExistError); + + // Linking with an empty file name throws. + await rejects(promises.symlink(src, "/foo/"), kEInvalError); + + // Invalid paths throw + await rejects(promises.symlink(src, "/dev/null/test"), kEInvalError); + await rejects( + promises.symlink("/dev/null/test", "/tmp/null/test"), + kENoentError, + ); + + // Symlinking directories works. + await promises.symlink("/tmp", "/tmp/linked-dir"); + ok(existsSync("/tmp/linked-dir")); + + // The destination can be unlinked + await promises.unlink(dst); + ok(!existsSync(dst)); + + // But unlinking does not remove the source. + ok(existsSync(src)); + }, +}; diff --git a/src/compat/fs/upstream-misc.fixture.mjs b/src/compat/fs/upstream-misc.fixture.mjs new file mode 100644 index 0000000..6f4eb1d --- /dev/null +++ b/src/compat/fs/upstream-misc.fixture.mjs @@ -0,0 +1,240 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +import { notStrictEqual, strictEqual, throws } from "node:assert"; + +// sync and datasync are generally non-ops. The most they do is +// verify that the file descriptor is valid. + +import { + openSync, + closeSync, + fsyncSync, + fdatasyncSync, + fdatasync, + fsync, + statfs, + statfsSync, + openAsBlob, + WriteStream, + FileWriteStream, + ReadStream, + FileReadStream, + constants, + F_OK, + R_OK, + W_OK, + X_OK, + writeFileSync, + writeSync, +} from "./index.js"; + +strictEqual(typeof openSync, "function"); +strictEqual(typeof closeSync, "function"); +strictEqual(typeof fdatasyncSync, "function"); +strictEqual(typeof fsyncSync, "function"); +strictEqual(typeof fdatasync, "function"); +strictEqual(typeof fsync, "function"); + +const kInvalidArgTypeError = { code: "ERR_INVALID_ARG_TYPE" }; +const kBadFError = { code: "EBADF" }; + +export const miscTest = { + async test() { + throws(() => fsyncSync(), kInvalidArgTypeError); + throws(() => fdatasyncSync(), kInvalidArgTypeError); + throws(() => fsyncSync("hello"), kInvalidArgTypeError); + throws(() => fdatasyncSync("hello"), kInvalidArgTypeError); + throws(() => fsync(), kInvalidArgTypeError); + throws(() => fdatasync(), kInvalidArgTypeError); + throws(() => fsync("hello"), kInvalidArgTypeError); + throws(() => fdatasync("hello"), kInvalidArgTypeError); + throws(() => fsyncSync(123), kBadFError); + throws(() => fdatasyncSync(123), kBadFError); + + const fd = openSync("/dev/null", "r"); + fsyncSync(fd); + fdatasyncSync(fd); + + { + const { promise, resolve, reject } = Promise.withResolvers(); + fsync(fd, (err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + await promise; + } + + { + const { promise, resolve, reject } = Promise.withResolvers(); + fdatasync(fd, (err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + await promise; + } + + { + const { promise, resolve, reject } = Promise.withResolvers(); + fsync(123, (err) => { + if (!err) { + reject(new Error("Expected an error")); + return; + } + strictEqual(err.code, "EBADF"); + resolve(); + }); + await promise; + } + + { + const { promise, resolve, reject } = Promise.withResolvers(); + fdatasync(123, (err) => { + if (!err) { + reject(new Error("Expected an error")); + return; + } + strictEqual(err.code, "EBADF"); + resolve(); + }); + await promise; + } + + closeSync(fd); + }, +}; + +export const statFsTest = { + async test() { + const stat = statfsSync("/"); + strictEqual(typeof stat, "object"); + strictEqual(stat.type, 0); + strictEqual(stat.bsize, 0); + strictEqual(stat.blocks, 0); + strictEqual(stat.bfree, 0); + strictEqual(stat.bavail, 0); + strictEqual(stat.files, 0); + strictEqual(stat.ffree, 0); + + throws(() => statfsSync(123), { + code: "ERR_INVALID_ARG_TYPE", + }); + + throws(() => statfsSync("/does/not/exist", { bigint: 123 }), { + code: "ERR_INVALID_ARG_TYPE", + }); + + const { promise, resolve, reject } = Promise.withResolvers(); + statfs("/", (err, stat) => { + if (err) reject(err); + else { + strictEqual(typeof stat, "object"); + strictEqual(stat.type, 0); + strictEqual(stat.bsize, 0); + strictEqual(stat.blocks, 0); + strictEqual(stat.bfree, 0); + strictEqual(stat.bavail, 0); + strictEqual(stat.files, 0); + strictEqual(stat.ffree, 0); + resolve(); + } + }); + await promise; + + throws(() => statfs(123, () => {}), { + code: "ERR_INVALID_ARG_TYPE", + }); + + throws(() => statfs("/does/not/exist", { bigint: 123 }), { + code: "ERR_INVALID_ARG_TYPE", + }); + }, +}; + +export const pathLimitTest = { + test() { + // Trying to open a path longer than 4096 characters should throw an error. + const longPath = "/tmp/a".repeat(4097); + throws(() => openSync(longPath, "r"), { + message: /File path is too long/, + }); + + // Trying to open a path with more than 48 segments should throw an error. + const tooManySegments = "/a".repeat(49); + throws(() => openSync(tooManySegments, "r"), { + message: /File path has too many segments/, + }); + }, +}; + +export const openAsBlobTest = { + async test() { + // We currently do not implement openAsBlob, but let's verify arg validation. + throws(() => openAsBlob(), { + code: "ERR_INVALID_ARG_TYPE", + }); + throws(() => openAsBlob(1), { + code: "ERR_INVALID_ARG_TYPE", + }); + throws(() => openAsBlob("/", 123), { + code: "ERR_INVALID_ARG_TYPE", + }); + throws(() => openAsBlob("/", { type: 123 }), { + code: "ERR_INVALID_ARG_TYPE", + }); + + writeFileSync("/tmp/abc", "123"); + throws(() => openAsBlob("/"), { + message: /illegal operation on a directory/, + }); + throws(() => openAsBlob(new URL("file:///")), { + message: /illegal operation on a directory/, + }); + + const blob = openAsBlob("/tmp/abc", { type: "text/plain" }); + strictEqual(blob instanceof Blob, true); + strictEqual(blob.size, 3); + strictEqual(blob.type, "text/plain"); + strictEqual(await blob.text(), "123"); + }, +}; + +export const otherExportsTest = { + test() { + strictEqual(WriteStream, FileWriteStream); + strictEqual(ReadStream, FileReadStream); + strictEqual(constants.F_OK, F_OK); + strictEqual(constants.R_OK, R_OK); + strictEqual(constants.W_OK, W_OK); + strictEqual(constants.X_OK, X_OK); + notStrictEqual(F_OK, undefined); + notStrictEqual(R_OK, undefined); + notStrictEqual(W_OK, undefined); + notStrictEqual(X_OK, undefined); + notStrictEqual(WriteStream, undefined); + notStrictEqual(ReadStream, undefined); + }, +}; + +export const oobWriteTest = { + test() { + const v3 = Buffer.from("Test data for write operations"); + const fd = openSync("/tmp/write-test.bin", "w+"); + throws( + () => { + strictEqual(writeSync(fd, v3, 10, 10, 4294967295), 0); + }, + { + message: /File size limit exceeded/, + }, + ); + + strictEqual(writeSync(fd, v3, 10, 10, 134217718), 10); + }, +}; diff --git a/src/compat/fs/upstream-nodejs.fixture.mjs b/src/compat/fs/upstream-nodejs.fixture.mjs new file mode 100644 index 0000000..dd1cc1e --- /dev/null +++ b/src/compat/fs/upstream-nodejs.fixture.mjs @@ -0,0 +1,1615 @@ +import { changeDirectory as chdir } from "./writable.js"; +const process = { chdir }; +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +import { + ifError, + ok, + rejects, + deepStrictEqual, + strictEqual, + notStrictEqual, + throws, + doesNotThrow, +} from "node:assert"; +import zlib from "node:zlib"; +import { Readable } from "node:stream"; + +import { + existsSync, + statSync, + mkdirSync, + rmSync, + symlinkSync, + lstatSync, + openSync, + closeSync, + fstatSync, + ftruncateSync, + truncateSync, + writeSync, + writevSync, + readSync, + readvSync, + readFileSync, + writeFileSync, + appendFileSync, + copyFileSync, + renameSync, + close, + fstat, + ftruncate, + truncate, + unlinkSync, + write, + writev, + writeFile, + appendFile, + read, + readdir, + readlink, + realpath, + mkdtemp, + ReadStream, + WriteStream, + readdirSync, + readlinkSync, + readv, + readFile, + rename, + copyFile, + realpathSync, + mkdtempSync, + constants, + promises, + createWriteStream, + createReadStream, +} from "./index.js"; + +import { join } from "node:path"; + +const { COPYFILE_EXCL } = constants; + +const kErrInvalidArgType = { code: "ERR_INVALID_ARG_TYPE" }; +const kErrInvalidArgValue = { code: "ERR_INVALID_ARG_VALUE" }; +const kErrEBadf = { code: "EBADF" }; +const kErrEExist = { code: "EEXIST" }; +const kErrOutOfRange = { code: "ERR_OUT_OF_RANGE" }; + +export const openCloseTest = { + async test() { + throws(() => fstatSync(123), kErrEBadf); + throws(() => fstatSync(123, { bigint: "yes" }), kErrInvalidArgType); + throws(() => fstatSync("abc"), kErrInvalidArgType); + + // Test that all the mode combinations work + const modes = [ + "r", + "r+", + "w", + "w+", + "a", + "a+", + "rs", + "rs+", + "wx", + "wx+", + "ax", + "ax+", + ]; + for (const mode of modes) { + // Modes without O_CREAT need an existing file. + if (!/[wa]/.test(mode)) writeFileSync("/tmp/test.txt", ""); + const fd = openSync("/tmp/test.txt", mode); + ok(existsSync("/tmp/test.txt")); + const stat = fstatSync(fd, { bigint: true }); + ok(stat); + unlinkSync("/tmp/test.txt"); + } + + ok(!existsSync("/tmp/test.txt")); + const fd = openSync("/tmp/test.txt", "w+"); + ok(existsSync("/tmp/test.txt")); + + // Check the exclusive option fails when the file already exists + throws(() => openSync("/tmp/test.txt", "wx"), kErrEExist); + throws(() => openSync("/tmp/test.txt", "wx+"), kErrEExist); + throws(() => openSync("/tmp/test.txt", "ax"), kErrEExist); + throws(() => openSync("/tmp/test.txt", "ax+"), kErrEExist); + throws(() => openSync(Buffer.from("/tmp/test.txt"), "wx"), kErrEExist); + throws(() => openSync(Buffer.from("/tmp/test.txt"), "wx+"), kErrEExist); + throws(() => openSync(Buffer.from("/tmp/test.txt"), "ax"), kErrEExist); + throws(() => openSync(Buffer.from("/tmp/test.txt"), "ax+"), kErrEExist); + + const stat = fstatSync(fd, { bigint: true }); + ok(stat); + ok(stat.isFile()); + ok(!stat.isDirectory()); + strictEqual(stat.size, 0n); + + { + const { promise, resolve, reject } = Promise.withResolvers(); + fstat(fd, (err, stat) => { + try { + ifError(err); + ok(stat); + ok(stat.isFile()); + ok(!stat.isDirectory()); + resolve(); + } catch (err) { + reject(err); + } + }); + await promise; + } + + // Close the file + closeSync(fd); + // Can close multiple times + closeSync(fd); + // Can close non-existent file descriptors + closeSync(123); + + ["a", {}, null].forEach((i) => { + throws(() => closeSync(i), { + code: "ERR_INVALID_ARG_TYPE", + }); + throws(() => close(i), { + code: "ERR_INVALID_ARG_TYPE", + }); + throws(() => close(0, i), { + code: "ERR_INVALID_ARG_TYPE", + }); + }); + + { + const { promise, resolve, reject } = Promise.withResolvers(); + close(fd, (err) => { + if (err) return reject(err); + resolve(); + }); + await promise; + } + }, +}; + +export const openFlagsTest = { + test() { + const { O_WRONLY, O_RDWR, O_CREAT, O_TRUNC, O_APPEND, O_EXCL } = constants; + + // O_TRUNC truncates an existing file, both as a string flag and numeric. + for (const flags of ["w", "w+", O_WRONLY | O_CREAT | O_TRUNC]) { + writeFileSync("/tmp/test.txt", "hello world long"); + const fd = openSync("/tmp/test.txt", flags); + strictEqual(fstatSync(fd).size, 0); + writeSync(fd, Buffer.from("hi")); + closeSync(fd); + strictEqual(readFileSync("/tmp/test.txt", "utf8"), "hi"); + } + + // Without O_TRUNC, existing content is preserved. + writeFileSync("/tmp/test.txt", "hello world long"); + { + const fd = openSync("/tmp/test.txt", O_WRONLY | O_CREAT); + writeSync(fd, Buffer.from("hi")); + closeSync(fd); + strictEqual(readFileSync("/tmp/test.txt", "utf8"), "hillo world long"); + } + + // Numeric O_APPEND appends regardless of position. + writeFileSync("/tmp/test.txt", "hello"); + { + const fd = openSync("/tmp/test.txt", O_WRONLY | O_CREAT | O_APPEND); + writeSync(fd, Buffer.from(" world"), 0, 6, 0); + closeSync(fd); + strictEqual(readFileSync("/tmp/test.txt", "utf8"), "hello world"); + } + + // Numeric O_EXCL with O_CREAT fails on an existing file. + throws( + () => openSync("/tmp/test.txt", O_WRONLY | O_CREAT | O_EXCL), + kErrEExist, + ); + throws( + () => openSync("/tmp/test.txt", O_RDWR | O_CREAT | O_EXCL | O_TRUNC), + kErrEExist, + ); + strictEqual(readFileSync("/tmp/test.txt", "utf8"), "hello world"); + + // O_TRUNC on a read-only open is ignored. + { + const fd = openSync("/tmp/test.txt", O_TRUNC); + closeSync(fd); + strictEqual(readFileSync("/tmp/test.txt", "utf8"), "hello world"); + } + + unlinkSync("/tmp/test.txt"); + }, +}; + +export const openMissingTest = { + async test() { + const { O_RDONLY, O_WRONLY, O_RDWR, O_CREAT, O_EXCL, O_TRUNC } = constants; + const kErrENoEnt = { + code: "ENOENT", + syscall: "open", + path: "/tmp/missing", + }; + + // Without O_CREAT, opening a missing path is ENOENT and creates nothing. + for (const flags of [ + "r", + "r+", + "rs+", + O_RDONLY, + O_WRONLY, + O_RDWR, + O_RDWR | O_TRUNC, + ]) { + throws(() => openSync("/tmp/missing", flags), kErrENoEnt); + ok(!existsSync("/tmp/missing")); + } + + // With O_CREAT, the file is created. + for (const flags of [ + "w", + "a", + "w+", + O_WRONLY | O_CREAT, + O_RDONLY | O_CREAT, + ]) { + const fd = openSync("/tmp/missing", flags); + closeSync(fd); + ok(existsSync("/tmp/missing")); + strictEqual(readFileSync("/tmp/missing", "utf8"), ""); + + // O_CREAT|O_EXCL fails once it exists, regardless of access mode. + throws( + () => openSync("/tmp/missing", O_RDONLY | O_CREAT | O_EXCL), + kErrEExist, + ); + throws(() => openSync("/tmp/missing", "wx"), kErrEExist); + unlinkSync("/tmp/missing"); + } + + // O_CREAT|O_EXCL on a missing path creates it. + closeSync(openSync("/tmp/missing", O_WRONLY | O_CREAT | O_EXCL)); + ok(existsSync("/tmp/missing")); + unlinkSync("/tmp/missing"); + + // O_CREAT does not create intermediate directories. + for (const flags of ["r", "w", "a", O_WRONLY | O_CREAT | O_EXCL]) { + throws(() => openSync("/tmp/missing/file", flags), { + code: "ENOENT", + syscall: "open", + path: "/tmp/missing/file", + }); + ok(!existsSync("/tmp/missing")); + } + + // A non-directory path component is ENOTDIR. + writeFileSync("/tmp/missing", ""); + for (const flags of ["r", "w"]) { + throws(() => openSync("/tmp/missing/file", flags), { + code: "ENOTDIR", + syscall: "open", + path: "/tmp/missing/file", + }); + } + strictEqual(readFileSync("/tmp/missing", "utf8"), ""); + unlinkSync("/tmp/missing"); + + // Streams open with their own flags: WriteStream defaults to 'w' and + // honours 'a'; ReadStream defaults to 'r' and fails on a missing path. + const writeAll = (path, data, options) => + new Promise((resolve, reject) => { + createWriteStream(path, options) + .on("close", resolve) + .on("error", reject) + .end(data); + }); + await writeAll("/tmp/missing", "first"); + strictEqual(readFileSync("/tmp/missing", "utf8"), "first"); + await writeAll("/tmp/missing", "second"); + strictEqual(readFileSync("/tmp/missing", "utf8"), "second"); + await writeAll("/tmp/missing", " third", { flags: "a" }); + strictEqual(readFileSync("/tmp/missing", "utf8"), "second third"); + unlinkSync("/tmp/missing"); + + const streamError = (make) => + new Promise((resolve, reject) => { + make().on("open", resolve).on("error", reject); + }); + await rejects( + streamError(() => createReadStream("/tmp/missing")), + kErrENoEnt, + ); + ok(!existsSync("/tmp/missing")); + + // Invalid flags are validated synchronously by fs.open and surface as + // the stream error rather than a hang. + await rejects( + streamError(() => createReadStream("/tmp/missing", { flags: "zz" })), + { code: "ERR_INVALID_ARG_VALUE" }, + ); + await rejects( + streamError(() => createWriteStream("/tmp/missing", { flags: "zz" })), + { code: "ERR_INVALID_ARG_VALUE" }, + ); + ok(!existsSync("/tmp/missing")); + }, +}; + +export const ftruncateTest = { + async test() { + const fd = openSync("/tmp/test.txt", "w+"); + ok(existsSync("/tmp/test.txt")); + + throws(() => ftruncateSync("hello"), kErrInvalidArgType); + throws(() => ftruncateSync(123), kErrEBadf); + throws(() => ftruncateSync(123, 10), kErrEBadf); + throws(() => ftruncateSync(fd, "hello"), kErrInvalidArgType); + + const stat = fstatSync(fd, { bigint: true }); + strictEqual(stat.size, 0n); + + // Truncate to 10 bytes + ftruncateSync(fd, 10); + const stat2 = fstatSync(fd, { bigint: true }); + strictEqual(stat2.size, 10n); + + // Truncate to 0 bytes + ftruncateSync(fd, 0); + const stat3 = fstatSync(fd, { bigint: true }); + strictEqual(stat3.size, 0n); + + // Truncate to a negative size throws an error + throws(() => ftruncateSync(fd, -1), kErrOutOfRange); + + { + const { promise, resolve, reject } = Promise.withResolvers(); + ftruncate(fd, 5, (err) => { + if (err) return reject(err); + const stat4 = fstatSync(fd, { bigint: true }); + strictEqual(stat4.size, 5n); + resolve(); + }); + await promise; + } + + { + throws(() => ftruncate(fd, -1, () => {}), { + code: "ERR_OUT_OF_RANGE", + }); + } + + { + throws(() => ftruncateSync(fd, 0xffffffff), { + message: /File size limit exceeded/, + }); + throws(() => ftruncateSync(fd, 0x08000000 + 1), { + message: /File size limit exceeded/, + }); + // 0x08000000 is the maximum allowed file size. + ftruncateSync(fd, 0x08000000); + } + + closeSync(fd); + }, +}; + +export const truncateTest = { + async test() { + throws(() => truncateSync(123), kErrInvalidArgType); + throws(() => truncateSync("/", "hello"), kErrInvalidArgType); + + ok(!existsSync("/tmp/test.txt")); + throws(() => truncateSync("/tmp/test.txt", 10), { + code: "ENOENT", + path: "/tmp/test.txt", + }); + + // Create the file + writeFileSync("/tmp/test.txt", "Hello World"); + ok(existsSync("/tmp/test.txt")); + let stat = statSync("/tmp/test.txt"); + strictEqual(stat.size, 11); + + // Truncate to 10 bytes + truncateSync("/tmp/test.txt", 10); + stat = statSync("/tmp/test.txt"); + strictEqual(stat.size, 10); + + // Truncate to 20 bytes + truncateSync("/tmp/test.txt", 20); + stat = statSync("/tmp/test.txt"); + strictEqual(stat.size, 20); + + // Truncate to 0 bytes + truncateSync("/tmp/test.txt", 0); + stat = statSync("/tmp/test.txt"); + strictEqual(stat.size, 0); + + // Truncate to a negative size throws an error + throws(() => truncateSync("/tmp/test.txt", -1), kErrOutOfRange); + + { + const { promise, resolve, reject } = Promise.withResolvers(); + truncate("/tmp/test.txt", 5, (err) => { + if (err) return reject(err); + const stat2 = statSync("/tmp/test.txt"); + strictEqual(stat2.size, 5); + resolve(); + }); + await promise; + } + + throws(() => truncate("/tmp/test.txt", -1), kErrOutOfRange); + }, +}; + +export const writeSyncTest = { + test() { + const fd = openSync("/tmp/test.txt", "w+"); + ok(existsSync("/tmp/test.txt")); + + const stat = fstatSync(fd, { bigint: true }); + strictEqual(stat.size, 0n); + + throws(() => writeSync(""), kErrInvalidArgType); + throws(() => writeSync(fd, "Hello World", ""), kErrInvalidArgType); + throws( + () => writeSync(fd, "Hello World", { position: "hello" }), + kErrInvalidArgType, + ); + throws(() => writeSync(123, "Hello World"), kErrEBadf); + throws(() => writeSync(fd, Buffer.alloc(2), { offset: 5 }), { + code: "ERR_BUFFER_OUT_OF_BOUNDS", + }); + throws(() => writeSync(fd, Buffer.alloc(2), { length: 5 }), { + code: "ERR_BUFFER_OUT_OF_BOUNDS", + }); + throws( + () => writeSync(fd, Buffer.alloc(2), { offset: "hello" }), + kErrInvalidArgType, + ); + + writeSync(fd, "Hello World"); + writeSync(fd, "!!!!"); + const stat2 = fstatSync(fd, { bigint: true }); + strictEqual(stat2.size, 15n); + + const dest = Buffer.alloc(Number(stat2.size)); + + // When we don't specify a position, it reads from the current position, + // which currently is the end of the file... so we get nothing here. + strictEqual(readSync(fd, dest), 0); + + // But when we do specify a position, we can read from the beginning... + strictEqual(readSync(fd, dest, 0, dest.byteLength, 0), dest.byteLength); + strictEqual(dest.toString(), "Hello World!!!!"); + + // Likewise, we can use an options object for the position + dest.fill(0); + strictEqual(dest.toString(), "\0".repeat(dest.byteLength)); + strictEqual(readSync(fd, dest, { position: 0 }), 15); + strictEqual(dest.toString(), "Hello World!!!!"); + + const dest2 = readFileSync("/tmp/test.txt"); + const dest3 = readFileSync(fd); + const dest4 = readFileSync(fd, { encoding: "utf8" }); + strictEqual(dest2.toString(), "Hello World!!!!"); + strictEqual(dest3.toString(), "Hello World!!!!"); + strictEqual(dest4, "Hello World!!!!"); + + closeSync(fd); + }, +}; + +export const writeAsyncCallbackTest = { + async test() { + const fd = openSync("/tmp/test.txt", "w+"); + ok(existsSync("/tmp/test.txt")); + + const stat = fstatSync(fd, { bigint: true }); + strictEqual(stat.size, 0n); + + function mustNotCall() { + throw new Error("This function must not be called"); + } + + throws(() => write(fd, "Hello World"), kErrInvalidArgType); + throws(() => write(fd, "Hello World", "", mustNotCall), kErrInvalidArgType); + throws( + () => write(fd, "Hello World", { position: "hello" }, mustNotCall), + kErrInvalidArgType, + ); + throws(() => write(fd, Buffer.alloc(2), { offset: 5 }, mustNotCall), { + code: "ERR_BUFFER_OUT_OF_BOUNDS", + }); + throws(() => write(fd, Buffer.alloc(2), { length: 5 }, mustNotCall), { + code: "ERR_BUFFER_OUT_OF_BOUNDS", + }); + throws( + () => write(fd, Buffer.alloc(2), { offset: "hello" }, mustNotCall), + kErrInvalidArgType, + ); + + await new Promise((resolve, reject) => { + write(fd, "Hello World", (err, written) => { + if (err) return reject(err); + strictEqual(written, 11); + resolve(); + }); + }); + + await new Promise((resolve, reject) => { + write(fd, "!!!!", (err, written) => { + if (err) return reject(err); + strictEqual(written, 4); + resolve(); + }); + }); + + await rejects( + new Promise((resolve, reject) => { + write(123, "Hello World", { position: 0 }, (err) => { + if (err) return reject(err); + resolve(); + }); + }), + kErrEBadf, + ); + + const stat2 = fstatSync(fd, { bigint: true }); + strictEqual(stat2.size, 15n); + + const dest = Buffer.alloc(Number(stat2.size)); + + // When we don't specify a position, it reads from the current position, + // which currently is the end of the file... so we get nothing here. + strictEqual(readSync(fd, dest), 0); + + // But when we do specify a position, we can read from the beginning... + strictEqual(readSync(fd, dest, 0, dest.byteLength, 0), dest.byteLength); + strictEqual(dest.toString(), "Hello World!!!!"); + + closeSync(fd); + }, +}; + +export const writeSyncTest2 = { + test() { + const fd = openSync("/tmp/test.txt", "w+"); + ok(existsSync("/tmp/test.txt")); + + const stat = fstatSync(fd, { bigint: true }); + strictEqual(stat.size, 0n); + + strictEqual(writeSync(fd, "Hello World", 2n), 11); + + // Writing to a position beyond max uint32_t is not allowed. + throws(() => writeSync(fd, "Hello World", 2n ** 32n), { + code: "EINVAL", + }); + throws(() => writeSync(fd, "Hello World", 2 ** 32), { + code: "ERR_OUT_OF_RANGE", + }); + + strictEqual(writeSync(fd, "aa", 0, "ascii"), 2); + + const stat2 = fstatSync(fd); + strictEqual(stat2.size, 13); + + const dest = Buffer.alloc(stat2.size); + strictEqual(readSync(fd, dest, 0, dest.byteLength, 0), 13); + strictEqual(dest.toString(), "aaHello World"); + + closeSync(fd); + }, +}; + +export const writeSyncTest3 = { + test() { + const fd = openSync("/tmp/test.txt", "w+"); + ok(existsSync("/tmp/test.txt")); + + const stat = fstatSync(fd, { bigint: true }); + strictEqual(stat.size, 0n); + + writeSync(fd, Buffer.from("Hello World")); + const stat2 = fstatSync(fd, { bigint: true }); + strictEqual(stat2.size, 11n); + + closeSync(fd); + }, +}; + +export const writeSyncTest4 = { + test() { + const fd = openSync("/tmp/test.txt", "w+"); + ok(existsSync("/tmp/test.txt")); + + const stat = fstatSync(fd, { bigint: true }); + strictEqual(stat.size, 0n); + + // Writing a partial buffer works + writeSync(fd, Buffer.from("Hello World"), 1, 3, 1); + + // Specifying an offset or length beyond the buffer size is not allowed. + throws(() => writeSync(fd, Buffer.from("Hello World"), 100, 3), { + message: /outside of buffer bounds/, + }); + // Specifying an offset or length beyond the buffer size is not allowed. + throws(() => writeSync(fd, Buffer.from("Hello World"), 0, 100), { + message: /outside of buffer bounds/, + }); + + throws(() => writeSync(fd, Buffer.from("hello world"), "a"), { + code: "ERR_INVALID_ARG_TYPE", + }); + + throws(() => writeSync(fd, Buffer.from("hello world"), 1n), { + code: "ERR_INVALID_ARG_TYPE", + }); + + throws(() => writeSync(fd, Buffer.from("hello world"), 0, "a"), { + code: "ERR_INVALID_ARG_TYPE", + }); + + throws(() => writeSync(fd, Buffer.from("hello world"), 1, 1n), { + code: "ERR_INVALID_ARG_TYPE", + }); + + throws(() => writeSync(fd, 123), { + code: "ERR_INVALID_ARG_TYPE", + }); + + const stat2 = fstatSync(fd, { bigint: true }); + strictEqual(stat2.size, 4n); + + closeSync(fd); + }, +}; + +export const writeReadOffsetBeyondLength = { + async test() { + const fd = openSync("/tmp/test.txt", "w+"); + const heap = new Uint8Array(64); + for (let i = 0; i < heap.length; i++) heap[i] = i; + + // offset > length is valid as long as offset + length <= byteLength + strictEqual(writeSync(fd, heap, 10, 2), 2); + strictEqual(writeSync(fd, heap, 20, 3, 2), 3); + strictEqual(writeSync(fd, heap, { offset: 30, length: 1, position: 5 }), 1); + strictEqual(writeSync(fd, heap, { offset: 62, position: 6 }), 2); + strictEqual(writeSync(fd, heap, 63, undefined, 8), 1); + strictEqual(writeSync(fd, heap, 64, 0, 9), 0); + strictEqual(fstatSync(fd).size, 9); + + // A view into a larger backing buffer: offset is relative to the view. + const view = new Uint8Array(heap.buffer, 8, 16); + strictEqual(writeSync(fd, view, 12, 4, 9), 4); + strictEqual(fstatSync(fd).size, 13); + + throws(() => writeSync(fd, heap, 65, 0), { + code: "ERR_BUFFER_OUT_OF_BOUNDS", + }); + throws(() => writeSync(fd, heap, 60, 5), { + code: "ERR_BUFFER_OUT_OF_BOUNDS", + }); + throws(() => writeSync(fd, view, 12, 5), { + code: "ERR_BUFFER_OUT_OF_BOUNDS", + }); + throws(() => writeSync(fd, view, 17), { + code: "ERR_BUFFER_OUT_OF_BOUNDS", + }); + + const dest = new Uint8Array(64); + strictEqual(readSync(fd, dest, 40, 13, 0), 13); + deepStrictEqual( + [...dest.subarray(40, 53)], + [10, 11, 20, 21, 22, 30, 62, 63, 63, 20, 21, 22, 23], + ); + strictEqual(readSync(fd, dest, 50, 2, 0), 2); + strictEqual(readSync(fd, dest, { offset: 60, position: 0 }), 4); + deepStrictEqual([...dest.subarray(60)], [10, 11, 20, 21]); + strictEqual(readSync(fd, dest, 64, 0, 0), 0); + + const destView = new Uint8Array(dest.buffer, 8, 16); + destView.fill(0); + strictEqual(readSync(fd, destView, 12, 4, 0), 4); + deepStrictEqual([...destView.subarray(12)], [10, 11, 20, 21]); + deepStrictEqual([...dest.subarray(24, 26)], [0, 0]); + + // Zero-length reads are a no-op regardless of offset, as in Node. + strictEqual(readSync(fd, dest, 65, 0, 0), 0); + strictEqual(readSync(fd, destView, 17, 0, 0), 0); + throws(() => readSync(fd, dest, 60, 5, 0), kErrOutOfRange); + throws(() => readSync(fd, destView, 12, 5, 0), kErrOutOfRange); + throws(() => readSync(fd, dest, 65, 1, 0), kErrOutOfRange); + + // Callback and promise variants share the same validation. + await new Promise((resolve, reject) => { + write(fd, heap, 10, 2, 13, (err, written) => { + if (err) return reject(err); + strictEqual(written, 2); + resolve(); + }); + }); + await new Promise((resolve, reject) => { + read(fd, dest, 40, 2, 13, (err, bytesRead) => { + if (err) return reject(err); + strictEqual(bytesRead, 2); + deepStrictEqual([...dest.subarray(40, 42)], [10, 11]); + resolve(); + }); + }); + await new Promise((resolve, reject) => { + read(fd, dest, 65, 0, 0, (err, bytesRead) => { + if (err) return reject(err); + strictEqual(bytesRead, 0); + resolve(); + }); + }); + closeSync(fd); + + const handle = await promises.open("/tmp/test.txt", "r+"); + strictEqual((await handle.write(heap, 10, 2, 15)).bytesWritten, 2); + strictEqual((await handle.read(dest, 40, 2, 15)).bytesRead, 2); + strictEqual((await handle.read(dest, 62)).bytesRead, 2); + strictEqual((await handle.read(destView, 12, 4, 15)).bytesRead, 2); + strictEqual((await handle.read(dest, 65, 0, 0)).bytesRead, 0); + await handle.close(); + }, +}; + +export const writeSyncAppend = { + test() { + const fd = openSync("/tmp/test.txt", "a"); + ok(existsSync("/tmp/test.txt")); + + const stat = fstatSync(fd, { bigint: true }); + strictEqual(stat.size, 0n); + + // In append mode, the position is ignored. + + writeSync(fd, "Hello World", 1000); + const stat2 = fstatSync(fd, { bigint: true }); + strictEqual(stat2.size, 11n); + + writeSync(fd, "!!!!", 2000); + const stat3 = fstatSync(fd, { bigint: true }); + strictEqual(stat3.size, 15n); + + closeSync(fd); + }, +}; + +export const writevSyncTest = { + test() { + const fd = openSync("/tmp/test.txt", "w+"); + ok(existsSync("/tmp/test.txt")); + + const stat = fstatSync(fd, { bigint: true }); + strictEqual(stat.size, 0n); + + writevSync(fd, [Buffer.from("Hello World"), Buffer.from("!!!!")]); + + throws(() => writevSync(fd, [1, 2]), { + code: /ERR_INVALID_ARG_TYPE/, + }); + + throws(() => writevSync(100, [Buffer.from("")]), { + message: "bad file descriptor", + code: "EBADF", + }); + + const stat2 = fstatSync(fd, { bigint: true }); + strictEqual(stat2.size, 15n); + + const dest1 = Buffer.alloc(5); + const dest2 = Buffer.alloc(10); + const dest3 = Buffer.alloc(5); + let read = readvSync(fd, [dest1, dest2, dest3], 0); + strictEqual(read, 15); + let dest = Buffer.concat([dest1, dest2, dest3]); + strictEqual(dest.toString("utf8", 0, read), "Hello World!!!!"); + + dest1.fill(0); + dest2.fill(0); + dest3.fill(0); + read = readvSync(fd, [dest1, dest2, dest3], 1); + strictEqual(read, 14); + dest = Buffer.concat([dest1, dest2, dest3]); + strictEqual(dest.toString("utf8", 0, read), "ello World!!!!"); + + // Reading from a position beyond the end of the file returns nothing. + strictEqual(readvSync(fd, [dest1], 100), 0); + + closeSync(fd); + }, +}; + +export const writevAsyncCallbackTest = { + async test() { + const fd = openSync("/tmp/test.txt", "w+"); + ok(existsSync("/tmp/test.txt")); + + const stat = fstatSync(fd, { bigint: true }); + strictEqual(stat.size, 0n); + + throws(() => writev("hello"), kErrInvalidArgType); + throws(() => writev(0, 123), kErrInvalidArgType); + throws(() => writev(fd, [Buffer.from("")], 123), kErrInvalidArgType); + + await new Promise((resolve, reject) => { + writev( + fd, + [Buffer.from("Hello World"), Buffer.from("!!!!")], + (err, written) => { + if (err) return reject(err); + strictEqual(written, 15); + resolve(); + }, + ); + }); + + throws( + () => + writev(fd, [1, 2], (err) => { + if (err) throw err; + }), + { + code: /ERR_INVALID_ARG_TYPE/, + }, + ); + + await rejects( + new Promise((resolve, reject) => { + writev(100, [Buffer.from("")], (err) => { + if (err) return reject(err); + resolve(); + }); + }), + { + code: "EBADF", + }, + ); + + const stat2 = fstatSync(fd, { bigint: true }); + strictEqual(stat2.size, 15n); + + const dest1 = Buffer.alloc(5); + const dest2 = Buffer.alloc(10); + const dest3 = Buffer.alloc(5); + let read = readvSync(fd, [dest1, dest2, dest3], 0); + strictEqual(read, 15); + let dest = Buffer.concat([dest1, dest2, dest3]); + strictEqual(dest.toString("utf8", 0, read), "Hello World!!!!"); + + dest1.fill(0); + dest2.fill(0); + dest3.fill(0); + read = readvSync(fd, [dest1, dest2, dest3], 1); + strictEqual(read, 14); + dest = Buffer.concat([dest1, dest2, dest3]); + strictEqual(dest.toString("utf8", 0, read), "ello World!!!!"); + + // Reading from a position beyond the end of the file returns nothing. + strictEqual(readvSync(fd, [dest1], 100), 0); + + closeSync(fd); + }, +}; + +export const writeFileSyncTest = { + test() { + ok(!existsSync("/tmp/test.txt")); + strictEqual(writeFileSync("/tmp/test.txt", "Hello World"), 11); + ok(existsSync("/tmp/test.txt")); + let stat = statSync("/tmp/test.txt"); + strictEqual(stat.size, 11); + strictEqual(readFileSync("/tmp/test.txt").toString(), "Hello World"); + + strictEqual(appendFileSync("/tmp/test.txt", "!!!!"), 4); + stat = statSync("/tmp/test.txt"); + strictEqual(stat.size, 15); + strictEqual(readFileSync("/tmp/test.txt").toString(), "Hello World!!!!"); + + // We can also use a file descriptor + const fd = openSync("/tmp/test.txt", "a+"); + writeFileSync(fd, "##"); + strictEqual(readFileSync(fd).toString(), "Hello World!!!!##"); + closeSync(fd); + }, +}; + +export const appendFileSyncFlush = { + test() { + ok(!existsSync("/tmp/test.txt")); + + // The flush option really is not supported in any particular way in + // our implementation but let's verify it. + appendFileSync("/tmp/test.txt", "hello world", { flush: true }); + + ["no", {}, null, -1].forEach((i) => { + throws( + () => appendFileSync("/tmp/test.txt", "hello world", { flush: "no" }), + { + code: "ERR_INVALID_ARG_TYPE", + }, + ); + }); + + ok(existsSync("/tmp/test.txt")); + strictEqual(readFileSync("/tmp/test.txt").toString(), "hello world"); + }, +}; + +export const writeFileAsyncCallbackTest = { + async test() { + ok(!existsSync("/tmp/test.txt")); + + throws(() => writeFile({}), kErrInvalidArgType); + throws(() => writeFile("", 123), kErrInvalidArgType); + throws(() => writeFile("", "", 123), kErrInvalidArgType); + throws(() => writeFile("", "", {}), kErrInvalidArgType); + + await new Promise((resolve, reject) => { + writeFile("/tmp/test.txt", "Hello World", (err) => { + if (err) return reject(err); + ok(existsSync("/tmp/test.txt")); + resolve(); + }); + }); + + let stat = statSync("/tmp/test.txt"); + strictEqual(stat.size, 11); + strictEqual(readFileSync("/tmp/test.txt").toString(), "Hello World"); + + await new Promise((resolve, reject) => { + appendFile("/tmp/test.txt", "!!!!", (err) => { + strictEqual( + readFileSync("/tmp/test.txt").toString(), + "Hello World!!!!", + ); + if (err) return reject(err); + resolve(); + }); + }); + + stat = statSync("/tmp/test.txt"); + strictEqual(stat.size, 15); + strictEqual(readFileSync("/tmp/test.txt").toString(), "Hello World!!!!"); + + // We can also use a file descriptor + const fd = openSync("/tmp/test.txt", "a+"); + await new Promise((resolve, reject) => { + writeFile(fd, "##", "utf8", (err) => { + if (err) return reject(err); + resolve(); + }); + }); + strictEqual(readFileSync(fd).toString(), "Hello World!!!!##"); + closeSync(fd); + + // We can use the promise API as well. + await promises.appendFile("/tmp/test.txt", "!!!"); + strictEqual( + readFileSync("/tmp/test.txt").toString(), + "Hello World!!!!##!!!", + ); + }, +}; + +export const appendFileCases = { + async test() { + ok(!existsSync("/tmp/test.txt")); + // It accepts bufers + appendFileSync("/tmp/test.txt", Buffer.from("Hello World")); + ok(existsSync("/tmp/test.txt")); + strictEqual(readFileSync("/tmp/test.txt").toString(), "Hello World"); + + // With the callback API also + const { promise, resolve, reject } = Promise.withResolvers(); + appendFile("/tmp/test.txt", Buffer.from("!!!!"), (err) => { + if (err) return reject(err); + strictEqual(readFileSync("/tmp/test.txt").toString(), "Hello World!!!!"); + resolve(); + }); + await promise; + + // And the promises API + await promises.appendFile("/tmp/test.txt", Buffer.from("!!!")); + strictEqual(readFileSync("/tmp/test.txt").toString(), "Hello World!!!!!!!"); + + // But invalid types throw errors + [123, {}, null, []].forEach((data) => { + throws(() => appendFileSync("/tmp/test.txt", data), { + code: "ERR_INVALID_ARG_TYPE", + }); + }); + }, +}; + +export const readAsyncCallbackTest = { + async test() { + const fd = openSync("/tmp/test.txt", "w+"); + ok(existsSync("/tmp/test.txt")); + + throws(() => read("hello"), kErrInvalidArgType); + throws(() => read(0, 123), kErrInvalidArgType); + throws(() => read(0, Buffer.alloc(1), 123), kErrInvalidArgType); + throws(() => read(0, Buffer.alloc(1), {}), kErrInvalidArgType); + + const stat = fstatSync(fd, { bigint: true }); + strictEqual(stat.size, 0n); + + writeSync(fd, "Hello World"); + writeSync(fd, "!!!!"); + const stat2 = fstatSync(fd, { bigint: true }); + strictEqual(stat2.size, 15n); + + const dest = Buffer.alloc(15); + let bytesRead = await new Promise((resolve, reject) => { + read(fd, dest, 0, dest.byteLength, 0, (err, bytesRead) => { + if (err) return reject(err); + resolve(bytesRead); + }); + }); + + strictEqual(bytesRead, 15); + strictEqual(dest.toString(), "Hello World!!!!"); + + closeSync(fd); + }, +}; + +export const readvAsyncCallbackTest = { + async test() { + const fd = openSync("/tmp/test.txt", "w+"); + ok(existsSync("/tmp/test.txt")); + + throws(() => readv("hello"), kErrInvalidArgType); + throws(() => readv(0, 123), kErrInvalidArgType); + throws(() => readv(0, [Buffer.alloc(1)], 123), kErrInvalidArgType); + throws(() => readv(0, [Buffer.alloc(1)], {}), kErrInvalidArgType); + + const stat = fstatSync(fd, { bigint: true }); + strictEqual(stat.size, 0n); + + writeSync(fd, "Hello World"); + writeSync(fd, "!!!!"); + const stat2 = fstatSync(fd, { bigint: true }); + strictEqual(stat2.size, 15n); + + const dest1 = Buffer.alloc(5); + const dest2 = Buffer.alloc(10); + let bytesRead = await new Promise((resolve, reject) => { + readv(fd, [dest1, dest2], 0, (err, bytesRead) => { + if (err) return reject(err); + resolve(bytesRead); + }); + }); + + strictEqual(bytesRead, 15); + strictEqual(Buffer.concat([dest1, dest2]).toString(), "Hello World!!!!"); + + closeSync(fd); + }, +}; + +export const readFileAsyncCallbackTest = { + async test() { + const fd = openSync("/tmp/test.txt", "w+"); + ok(existsSync("/tmp/test.txt")); + + throws(() => readFile("hello"), kErrInvalidArgType); + throws(() => readFile(0, 123), kErrInvalidArgType); + throws(() => readFile(0, {}), kErrInvalidArgType); + + const stat = fstatSync(fd, { bigint: true }); + strictEqual(stat.size, 0n); + + writeSync(fd, "Hello World"); + writeSync(fd, "!!!!"); + const stat2 = fstatSync(fd, { bigint: true }); + strictEqual(stat2.size, 15n); + + const content = await new Promise((resolve, reject) => { + readFile(fd, (err, content) => { + if (err) return reject(err); + resolve(content); + }); + }); + + strictEqual(content.toString(), "Hello World!!!!"); + + closeSync(fd); + }, +}; + +export const copyAndRenameSyncTest = { + test() { + ok(!existsSync("/tmp/test.txt")); + ok(!existsSync("/tmp/test2.txt")); + writeFileSync("/tmp/test.txt", "Hello World"); + ok(existsSync("/tmp/test.txt")); + ok(!existsSync("/tmp/test2.txt")); + + copyFileSync("/tmp/test.txt", "/tmp/test2.txt"); + // Both files exist + ok(existsSync("/tmp/test.txt")); + ok(existsSync("/tmp/test2.txt")); + + strictEqual( + readFileSync("/tmp/test.txt").toString(), + readFileSync("/tmp/test2.txt").toString(), + ); + + copyFileSync("/tmp/test.txt", "/tmp/test4.txt", 0); + // Both files exist + ok(existsSync("/tmp/test.txt")); + ok(existsSync("/tmp/test4.txt")); + + strictEqual( + readFileSync("/tmp/test.txt").toString(), + readFileSync("/tmp/test4.txt").toString(), + ); + + throws( + () => copyFileSync("/tmp/test.txt", "/tmp/test4.txt", COPYFILE_EXCL), + { + code: "EEXIST", + }, + ); + + throws( + () => + copyFileSync( + "/tmp/test.txt", + "/tmp/nope.txt", + constants.COPYFILE_FICLONE_FORCE, + ), + { + message: /unsupported/, + }, + ); + + copyFileSync("/tmp/test.txt", "/tmp/test5.txt", constants.COPYFILE_FICLONE); + // Both files exist + ok(existsSync("/tmp/test.txt")); + ok(existsSync("/tmp/test5.txt")); + + strictEqual( + readFileSync("/tmp/test.txt").toString(), + readFileSync("/tmp/test5.txt").toString(), + ); + + [false, 1, {}, [], null, undefined].forEach((i) => { + throws(() => copyFileSync(i, "/tmp/nope.txt"), { + code: "ERR_INVALID_ARG_TYPE", + }); + throws(() => copyFileSync("/tmp/test.txt", i), { + code: "ERR_INVALID_ARG_TYPE", + }); + }); + [false, {}, [], null].forEach((i) => { + throws(() => copyFileSync("/tmp/test.txt", "/tmp/test2.txt", i), { + code: "ERR_INVALID_ARG_VALUE", + }); + }); + + // We can modify one of the files and the other remains unchanged + writeFileSync("/tmp/test.txt", "Hello World 2"); + strictEqual(readFileSync("/tmp/test.txt").toString(), "Hello World 2"); + strictEqual(readFileSync("/tmp/test2.txt").toString(), "Hello World"); + + // Renaming the files work + renameSync("/tmp/test.txt", "/tmp/test3.txt"); + ok(!existsSync("/tmp/test.txt")); + ok(existsSync("/tmp/test3.txt")); + strictEqual(readFileSync("/tmp/test3.txt").toString(), "Hello World 2"); + }, +}; + +export const renameReplaceTest = { + test() { + // Renaming onto an existing file replaces it. + writeFileSync("/tmp/src.txt", "new"); + writeFileSync("/tmp/dest.txt", "old"); + renameSync("/tmp/src.txt", "/tmp/dest.txt"); + ok(!existsSync("/tmp/src.txt")); + strictEqual(readFileSync("/tmp/dest.txt", "utf8"), "new"); + + // copyFile without COPYFILE_EXCL overwrites too. + writeFileSync("/tmp/src.txt", "newer"); + copyFileSync("/tmp/src.txt", "/tmp/dest.txt"); + strictEqual(readFileSync("/tmp/dest.txt", "utf8"), "newer"); + strictEqual(readFileSync("/tmp/src.txt", "utf8"), "newer"); + + // Renaming onto itself is a no-op, including via URL aliases that differ + // only in search or fragment. + renameSync("/tmp/dest.txt", "/tmp/dest.txt"); + strictEqual(readFileSync("/tmp/dest.txt", "utf8"), "newer"); + renameSync( + new URL("file:///tmp/dest.txt#one"), + new URL("file:///tmp/dest.txt?two"), + ); + strictEqual(readFileSync("/tmp/dest.txt", "utf8"), "newer"); + + // Renaming onto an existing symlink replaces the link, not its target. + symlinkSync("/tmp/dest.txt", "/tmp/link"); + renameSync("/tmp/src.txt", "/tmp/link"); + ok(lstatSync("/tmp/link").isFile()); + strictEqual(readFileSync("/tmp/dest.txt", "utf8"), "newer"); + unlinkSync("/tmp/link"); + + // A file cannot replace a directory, and a directory cannot replace a file. + mkdirSync("/tmp/dir"); + throws(() => renameSync("/tmp/dest.txt", "/tmp/dir"), { code: "EISDIR" }); + throws(() => renameSync("/tmp/dir", "/tmp/dest.txt"), { code: "ENOTDIR" }); + throws(() => copyFileSync("/tmp/dest.txt", "/tmp/dir"), { code: "EISDIR" }); + ok(existsSync("/tmp/dest.txt")); + ok(statSync("/tmp/dir").isDirectory()); + + // A directory replaces an empty directory but not a populated one. + mkdirSync("/tmp/dir2"); + writeFileSync("/tmp/dir/a.txt", "a"); + renameSync("/tmp/dir", "/tmp/dir2"); + ok(!existsSync("/tmp/dir")); + strictEqual(readFileSync("/tmp/dir2/a.txt", "utf8"), "a"); + mkdirSync("/tmp/dir"); + throws(() => renameSync("/tmp/dir", "/tmp/dir2"), { code: "ENOTEMPTY" }); + ok(existsSync("/tmp/dir")); + + // A file over a populated directory is EISDIR, not ENOTEMPTY, and a + // missing source is ENOENT regardless of the destination. + throws(() => renameSync("/tmp/dest.txt", "/tmp/dir2"), { code: "EISDIR" }); + throws(() => renameSync("/tmp/missing", "/tmp/dir2"), { code: "ENOENT" }); + throws(() => copyFileSync("/tmp/missing", "/tmp/dir2"), { code: "ENOENT" }); + + // A directory cannot be moved into itself or a descendant. + mkdirSync("/tmp/dir/sub"); + throws(() => renameSync("/tmp/dir", "/tmp/dir/sub"), { code: "EINVAL" }); + throws(() => renameSync("/tmp/dir", "/tmp/dir/sub/x"), { code: "EINVAL" }); + throws(() => renameSync("/tmp/dir/", "/tmp/dir/x"), { code: "EINVAL" }); + ok(statSync("/tmp/dir/sub").isDirectory()); + strictEqual(readdirSync("/tmp/dir").length, 1); + + rmSync("/tmp/dir", { recursive: true }); + rmSync("/tmp/dir2", { recursive: true }); + unlinkSync("/tmp/dest.txt"); + }, +}; + +export const copyAndRenameAsyncCallbackTest = { + async test() { + ok(!existsSync("/tmp/test.txt")); + ok(!existsSync("/tmp/test2.txt")); + writeFileSync("/tmp/test.txt", "Hello World"); + ok(existsSync("/tmp/test.txt")); + ok(!existsSync("/tmp/test2.txt")); + + throws(() => copyFile(123), kErrInvalidArgType); + throws(() => copyFile("/tmp/test.txt", 123), kErrInvalidArgType); + throws( + () => copyFile("/tmp/test.txt", "/tmp/test2.txt", 123), + kErrInvalidArgValue, + ); + throws( + () => copyFile("/tmp/test.txt", "/tmp/test2.txt", 0), + kErrInvalidArgType, + ); + + throws(() => rename(123), kErrInvalidArgType); + throws(() => rename("/tmp/test.txt", 123), kErrInvalidArgType); + throws( + () => rename("/tmp/test.txt", "/tmp/test2.txt", 0), + kErrInvalidArgType, + ); + + await new Promise((resolve, reject) => { + copyFile("/tmp/test.txt", "/tmp/test2.txt", (err) => { + if (err) return reject(err); + resolve(); + }); + }); + + // Test the exclusive option fails when the file already exists + await rejects( + new Promise((resolve, reject) => { + copyFile("/tmp/test.txt", "/tmp/test2.txt", COPYFILE_EXCL, (err) => { + if (err) return reject(err); + resolve(); + }); + }), + kErrEExist, + ); + + // Both files exist + ok(existsSync("/tmp/test.txt")); + ok(existsSync("/tmp/test2.txt")); + + strictEqual( + readFileSync("/tmp/test.txt").toString(), + readFileSync("/tmp/test2.txt").toString(), + ); + + // We can modify one of the files and the other remains unchanged + writeFileSync("/tmp/test.txt", "Hello World 2"); + strictEqual(readFileSync("/tmp/test.txt").toString(), "Hello World 2"); + strictEqual(readFileSync("/tmp/test2.txt").toString(), "Hello World"); + + // Renaming the files work + await new Promise((resolve, reject) => { + rename("/tmp/test.txt", "/tmp/test3.txt", (err) => { + if (err) return reject(err); + resolve(); + }); + }); + + ok(!existsSync("/tmp/test.txt")); + ok(existsSync("/tmp/test3.txt")); + strictEqual(readFileSync("/tmp/test3.txt").toString(), "Hello World 2"); + }, +}; + +export const fsCwdTest = { + test() { + process.chdir("/bundle"); + + throws( + () => { + writeFileSync("test-cwd.txt", "Hello from original cwd"); + }, + { code: "EPERM" }, + ); + + process.chdir("/tmp"); + + writeFileSync("test-cwd.txt", "Hello from /tmp"); + ok(existsSync("test-cwd.txt")); + ok(existsSync("/tmp/test-cwd.txt")); + + ok(existsSync("test-cwd.txt")); + ok(!existsSync(`/bundle/test-cwd.txt`)); + + strictEqual(readFileSync("test-cwd.txt").toString(), "Hello from /tmp"); + strictEqual( + readFileSync("/tmp/test-cwd.txt").toString(), + "Hello from /tmp", + ); + + process.chdir("/bundle"); + + ok(!existsSync("test-cwd.txt")); + throws( + () => { + readFileSync("test-cwd.txt"); + }, + { code: "ENOENT", path: "/bundle/test-cwd.txt" }, + ); + + unlinkSync("/tmp/test-cwd.txt"); + }, +}; + +export const readBadEncoding = { + test() { + const kErrorObj = { + code: "ERR_INVALID_ARG_VALUE", + }; + throws(() => readFileSync("/tmp/test.txt", "bad-encoding"), kErrorObj); + throws( + () => appendFileSync("/tmp/test.txt", "data", "bad-encoding"), + kErrorObj, + ); + throws(() => readdirSync("/tmp/test.txt", "bad-encoding"), kErrorObj); + throws(() => readlinkSync("/tmp/test.txt", "bad-encoding"), kErrorObj); + throws( + () => writeFileSync("/tmp/test.txt", "data", "bad-encoding"), + kErrorObj, + ); + throws( + () => appendFileSync("/tmp/test.txt", "data", "bad-encoding"), + kErrorObj, + ); + throws(() => realpathSync("/tmp/test.txt", "bad-encoding"), kErrorObj); + throws(() => mkdtempSync("/tmp/test.txt", "bad-encoding"), kErrorObj); + throws(() => ReadStream("/tmp/test.txt", "bad-encoding"), kErrorObj); + throws(() => WriteStream("/tmp/test.txt", "bad-encoding"), kErrorObj); + + throws( + () => writeFile("/tmp/test.txt", "data", "bad-encoding", mustNotCall), + kErrorObj, + ); + throws( + () => appendFile("/tmp/test.txt", "data", "bad-encoding", mustNotCall), + kErrorObj, + ); + + function mustNotCall() { + throw new Error("This function must not be called"); + } + + throws( + () => readFile("/tmp/test.txt", "bad-encoding", mustNotCall), + kErrorObj, + ); + throws( + () => readdir("/tmp/test.txt", "bad-encoding", mustNotCall), + kErrorObj, + ); + throws( + () => readlink("/tmp/test.txt", "bad-encoding", mustNotCall), + kErrorObj, + ); + throws( + () => realpath("/tmp/test.txt", "bad-encoding", mustNotCall), + kErrorObj, + ); + throws( + () => mkdtemp("/tmp/test.txt", "bad-encoding", mustNotCall), + kErrorObj, + ); + }, +}; + +export const fsConstantsTest = { + test() { + // Check if the two constants accepted by chmod() on Windows are defined. + notStrictEqual(constants.S_IRUSR, undefined); + notStrictEqual(constants.S_IWUSR, undefined); + + // Check null prototype. + strictEqual(Object.getPrototypeOf(constants), null); + + const knownFsConstantNames = [ + "UV_FS_SYMLINK_DIR", + "UV_FS_SYMLINK_JUNCTION", + "O_RDONLY", + "O_WRONLY", + "O_RDWR", + "UV_DIRENT_UNKNOWN", + "UV_DIRENT_FILE", + "UV_DIRENT_DIR", + "UV_DIRENT_LINK", + "UV_DIRENT_FIFO", + "UV_DIRENT_SOCKET", + "UV_DIRENT_CHAR", + "UV_DIRENT_BLOCK", + "S_IFMT", + "S_IFREG", + "S_IFDIR", + "S_IFCHR", + "S_IFBLK", + "S_IFIFO", + "S_IFLNK", + "S_IFSOCK", + "O_CREAT", + "O_EXCL", + "UV_FS_O_FILEMAP", + "O_NOCTTY", + "O_TRUNC", + "O_APPEND", + "O_DIRECTORY", + "O_EXCL", + "O_NOATIME", + "O_NOFOLLOW", + "O_SYNC", + "O_DSYNC", + "O_SYMLINK", + "O_DIRECT", + "O_NONBLOCK", + "S_IRWXU", + "S_IRUSR", + "S_IWUSR", + "S_IXUSR", + "S_IRWXG", + "S_IRGRP", + "S_IWGRP", + "S_IXGRP", + "S_IRWXO", + "S_IROTH", + "S_IWOTH", + "S_IXOTH", + "F_OK", + "R_OK", + "W_OK", + "X_OK", + "UV_FS_COPYFILE_EXCL", + "COPYFILE_EXCL", + "UV_FS_COPYFILE_FICLONE", + "COPYFILE_FICLONE", + "UV_FS_COPYFILE_FICLONE_FORCE", + "COPYFILE_FICLONE_FORCE", + "EXTENSIONLESS_FORMAT_JAVASCRIPT", + "EXTENSIONLESS_FORMAT_WASM", + ]; + + const fsConstantNames = Object.keys(constants); + const unknownFsConstantNames = fsConstantNames.filter((constant) => { + return !knownFsConstantNames.includes(constant); + }); + deepStrictEqual( + unknownFsConstantNames, + [], + `Unknown fs.constants: ${unknownFsConstantNames.join(", ")}`, + ); + + strictEqual(typeof constants.COPYFILE_EXCL, "number"); + strictEqual(typeof constants.COPYFILE_FICLONE, "number"); + strictEqual(typeof constants.COPYFILE_FICLONE_FORCE, "number"); + strictEqual(typeof constants.UV_FS_COPYFILE_EXCL, "number"); + strictEqual(typeof constants.UV_FS_COPYFILE_FICLONE, "number"); + strictEqual(typeof constants.UV_FS_COPYFILE_FICLONE_FORCE, "number"); + strictEqual(constants.COPYFILE_EXCL, constants.UV_FS_COPYFILE_EXCL); + strictEqual(constants.COPYFILE_FICLONE, constants.UV_FS_COPYFILE_FICLONE); + strictEqual( + constants.COPYFILE_FICLONE_FORCE, + constants.UV_FS_COPYFILE_FICLONE_FORCE, + ); + }, +}; + +export const fmapIgnoredTest = { + test() { + const filename = "/tmp/foo"; + const text = "Memory File Mapping Test"; + + const mw = + constants.UV_FS_O_FILEMAP | + constants.O_TRUNC | + constants.O_CREAT | + constants.O_WRONLY; + const mr = constants.UV_FS_O_FILEMAP | constants.O_RDONLY; + + writeFileSync(filename, text, { flag: mw }); + const r1 = readFileSync(filename, { encoding: "utf8", flag: mr }); + strictEqual(r1, text); + }, +}; + +export const fileNamesWithNullBytesTest = { + test() { + const filename = "/tmp/test\0.txt"; + throws(() => writeFileSync(filename, "Hello World"), { + code: "ERR_INVALID_ARG_VALUE", + }); + }, +}; + +export const fileNamesWithSurrogatePairsTest = { + test() { + const tempdir = mkdtempSync("/tmp/emoji-fruit-🍇 🍈 🍉 🍊 🍋"); + ok(existsSync(tempdir)); + const filename = "🚀🔥🛸.txt"; + const content = "Test content"; + writeFileSync(join(tempdir, filename), content); + const readContent = readFileSync(join(tempdir, filename), "utf8"); + strictEqual(readContent, content); + }, +}; + +export const testFsWithZlib = { + async test() { + const imgBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII"; + + const imageStream = Readable.from(Buffer.from(imgBase64, "base64")); + await new Promise((resolve, reject) => { + const outStream = createWriteStream(`/tmp/image-stream.png.gz`); + outStream.on("close", resolve).on("error", reject); + imageStream.pipe(zlib.createGzip()).pipe(outStream); + }); + + doesNotThrow(() => { + zlib + .gunzipSync(readFileSync("/tmp/image-stream.png.gz")) + .toString("base64"); + }); + }, +}; diff --git a/src/compat/fs/upstream-readstream.fixture.mjs b/src/compat/fs/upstream-readstream.fixture.mjs new file mode 100644 index 0000000..669ab69 --- /dev/null +++ b/src/compat/fs/upstream-readstream.fixture.mjs @@ -0,0 +1,1057 @@ +// Copyright (c) 2025 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +import { + ReadStream, + createReadStream, + statSync, + writeFileSync, + openSync, + open as openAsync, + close as closeAsync, + read as readAsync, + promises, +} from "./index.js"; + +import { ok, strictEqual, throws } from "node:assert"; +import { mock } from "node:test"; +import * as process from "../stream/process.js"; + +strictEqual(typeof ReadStream, "function"); +strictEqual(typeof createReadStream, "function"); + +export const simpleReadStreamTest = { + async test() { + const largeData = "abc".repeat(100_000); + writeFileSync("/tmp/foo", largeData); + + const stream = createReadStream("/tmp/foo", { + encoding: "utf8", + highWaterMark: 10_000, + }); + + let data = ""; + for await (const chunk of stream) { + data += chunk; + } + strictEqual(data, largeData); + }, +}; + +function prepareFile() { + const path = "/tmp/elipses.txt"; + writeFileSync(path, "…".repeat(10000)); + return path; +} + +async function runTest(options) { + let paused = false; + let bytesRead = 0; + + const path = prepareFile(); + + const file = createReadStream(path, options); + const fileSize = statSync(path).size; + + strictEqual(file.bytesRead, 0); + + const promises = []; + + const { promise: openPromise, resolve: openResolve } = + Promise.withResolvers(); + const { promise: endPromise, resolve: endResolve } = Promise.withResolvers(); + const { promise: closePromise, resolve: closeResolve } = + Promise.withResolvers(); + promises.push(openPromise); + promises.push(endPromise); + promises.push(closePromise); + + const onOpen = mock.fn((fd) => { + file.length = 0; + strictEqual(typeof fd, "number"); + strictEqual(file.bytesRead, 0); + ok(file.readable); + file.pause(); + file.resume(); + file.pause(); + file.resume(); + openResolve(); + }); + + const onData = mock.fn((data) => { + ok(data instanceof Buffer); + ok(data.byteOffset % 8 === 0); + ok(!paused); + file.length += data.length; + + bytesRead += data.length; + strictEqual(file.bytesRead, bytesRead); + + paused = true; + file.pause(); + + setTimeout(function () { + paused = false; + file.resume(); + }, 10); + }); + + const onEnd = mock.fn(() => { + strictEqual(bytesRead, fileSize); + strictEqual(file.bytesRead, fileSize); + endResolve(); + }); + + const onClose = mock.fn(() => { + strictEqual(bytesRead, fileSize); + strictEqual(file.bytesRead, fileSize); + closeResolve(); + }); + + file.once("open", onOpen); + file.once("end", onEnd); + file.once("close", onClose); + file.on("data", onData); + + await Promise.all(promises); + + strictEqual(file.length, 30000); + strictEqual(onOpen.mock.callCount(), 1); + strictEqual(onEnd.mock.callCount(), 1); + strictEqual(onClose.mock.callCount(), 1); + strictEqual(onData.mock.callCount(), 1); +} + +export const readStreamTest1 = { + async test() { + await runTest({}); + }, +}; + +export const readStreamTest2 = { + async test() { + const customFs = { + open: mock.fn((...args) => openAsync(...args)), + read: mock.fn((...args) => readAsync(...args)), + close: mock.fn((...args) => closeAsync(...args)), + }; + await runTest({ + fs: customFs, + }); + strictEqual(customFs.open.mock.callCount(), 1); + strictEqual(customFs.read.mock.callCount(), 2); + strictEqual(customFs.close.mock.callCount(), 1); + }, +}; + +export const readStreamTest3 = { + async test() { + const path = prepareFile(); + const file = createReadStream(path, { encoding: "utf8" }); + file.length = 0; + file.on("data", function (data) { + strictEqual(typeof data, "string"); + file.length += data.length; + + for (let i = 0; i < data.length; i++) { + // http://www.fileformat.info/info/unicode/char/2026/index.htm + strictEqual(data[i], "\u2026"); + } + }); + + const { promise, resolve } = Promise.withResolvers(); + file.on("close", resolve); + + await promise; + + strictEqual(file.length, 10000); + }, +}; + +export const readStreamTest4 = { + async test() { + const path = "/tmp/x.txt"; + writeFileSync(path, "xyz"); + const file = createReadStream(path, { bufferSize: 1, start: 1, end: 2 }); + let contentRead = ""; + file.on("data", function (data) { + contentRead += data.toString("utf-8"); + }); + const { promise, resolve } = Promise.withResolvers(); + file.on("end", function (data) { + strictEqual(contentRead, "yz"); + resolve(); + }); + await promise; + }, +}; + +export const readStreamTest5 = { + async test() { + const path = "/tmp/x.txt"; + writeFileSync(path, "xyz\n"); + const file = createReadStream(path, { bufferSize: 1, start: 1 }); + file.data = ""; + file.on("data", function (data) { + file.data += data.toString("utf-8"); + }); + const { promise, resolve } = Promise.withResolvers(); + file.on("end", function () { + strictEqual(file.data, "yz\n"); + resolve(); + }); + await promise; + }, +}; + +export const readStreamTest6 = { + async test() { + // Ref: https://github.com/nodejs/node-v0.x-archive/issues/2320 + const path = "/tmp/x.txt"; + writeFileSync(path, "xyz\n"); + const file = createReadStream(path, { bufferSize: 1.23, start: 1 }); + file.data = ""; + file.on("data", function (data) { + file.data += data.toString("utf-8"); + }); + const { promise, resolve } = Promise.withResolvers(); + file.on("end", function () { + strictEqual(file.data, "yz\n"); + resolve(); + }); + await promise; + }, +}; + +export const readStreamTest7 = { + test() { + const path = "/tmp/x.txt"; + writeFileSync(path, "xyz\n"); + throws(() => createReadStream(path, { start: 10, end: 2 }), { + code: "ERR_OUT_OF_RANGE", + name: "RangeError", + }); + }, +}; + +export const readStreamTest8 = { + async test() { + const path = "/tmp/x.txt"; + writeFileSync(path, "xyz\n"); + const stream = createReadStream(path, { start: 0, end: 0 }); + stream.data = ""; + + stream.on("data", function (chunk) { + stream.data += chunk; + }); + const { promise, resolve } = Promise.withResolvers(); + stream.on("end", function () { + strictEqual(stream.data, "x"); + resolve(); + }); + await promise; + }, +}; + +export const readStreamTest9 = { + async test() { + // Verify that end works when start is not specified. + const path = "/tmp/x.txt"; + writeFileSync(path, "xyz\n"); + const stream = new createReadStream(path, { end: 1 }); + stream.data = ""; + + stream.on("data", function (chunk) { + stream.data += chunk; + }); + + const { promise, resolve } = Promise.withResolvers(); + stream.on("end", function () { + strictEqual(stream.data, "xy"); + resolve(); + }); + await promise; + }, +}; + +export const readStreamTest10 = { + async test() { + const path = "/tmp/x.txt"; + writeFileSync(path, "xyz\n"); + let file = createReadStream(path, { autoClose: false }); + let data = ""; + file.on("data", function (chunk) { + data += chunk; + }); + const { promise, resolve, reject } = Promise.withResolvers(); + file.on("end", function () { + strictEqual(data, "xyz\n"); + process.nextTick(function () { + ok(!file.closed); + ok(!file.destroyed); + fileNext().then(resolve, reject); + }); + }); + + await promise; + + async function fileNext() { + // This will tell us if the fd is usable again or not. + file = createReadStream(null, { fd: file.fd, start: 0 }); + file.data = ""; + file.on("data", function (data) { + file.data += data; + }); + const { promise, resolve } = Promise.withResolvers(); + const { promise: endPromise, resolve: endResolve } = + Promise.withResolvers(); + file.on("end", function (err) { + strictEqual(file.data, "xyz\n"); + endResolve(); + }); + file.on("close", resolve); + await Promise.all([promise, endPromise]); + ok(file.closed); + ok(file.destroyed); + } + }, +}; + +export const readStreamTest11 = { + async test() { + // Just to make sure autoClose won't close the stream because of error. + const { promise, resolve, reject } = Promise.withResolvers(); + const file = createReadStream(null, { fd: 13337, autoClose: false }); + file.on("data", () => reject(new Error("should not be called"))); + file.on("error", resolve); + await promise; + ok(!file.closed); + ok(!file.destroyed); + ok(file.fd); + }, +}; + +export const readStreamTest12 = { + async test() { + // Make sure stream is destroyed when file does not exist. + const file = createReadStream("/path/to/file/that/does/not/exist"); + const { promise, resolve, reject } = Promise.withResolvers(); + file.on("data", () => reject(new Error("should not be called"))); + file.on("error", resolve); + await promise; + ok(file.closed); + ok(file.destroyed); + }, +}; + +export const readStreamTest13 = { + async test() { + const example = "/tmp/x.txt"; + writeFileSync(example, "xyz\n"); + createReadStream(example, undefined); + createReadStream(example, null); + createReadStream(example, "utf8"); + createReadStream(example, { encoding: "utf8" }); + + const createReadStreamErr = (path, opt, error) => { + throws(() => createReadStream(path, opt), error); + }; + + const typeError = { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + }; + + const rangeError = { + code: "ERR_OUT_OF_RANGE", + name: "RangeError", + }; + + [123, 0, true, false].forEach((opts) => + createReadStreamErr(example, opts, typeError), + ); + + // Case 0: Should not throw if either start or end is undefined + [{}, { start: 0 }, { end: Infinity }].forEach((opts) => + createReadStream(example, opts), + ); + + // Case 1: Should throw TypeError if either start or end is not of type 'number' + [ + { start: "invalid" }, + { end: "invalid" }, + { start: "invalid", end: "invalid" }, + ].forEach((opts) => createReadStreamErr(example, opts, typeError)); + + // Case 2: Should throw RangeError if either start or end is NaN + [{ start: NaN }, { end: NaN }, { start: NaN, end: NaN }].forEach((opts) => + createReadStreamErr(example, opts, rangeError), + ); + + // Case 3: Should throw RangeError if either start or end is negative + [{ start: -1 }, { end: -1 }, { start: -1, end: -1 }].forEach((opts) => + createReadStreamErr(example, opts, rangeError), + ); + + // Case 4: Should throw RangeError if either start or end is fractional + [{ start: 0.1 }, { end: 0.1 }, { start: 0.1, end: 0.1 }].forEach((opts) => + createReadStreamErr(example, opts, rangeError), + ); + + // Case 5: Should not throw if both start and end are whole numbers + createReadStream(example, { start: 1, end: 5 }); + + // Case 6: Should throw RangeError if start is greater than end + createReadStreamErr(example, { start: 5, end: 1 }, rangeError); + + // Case 7: Should throw RangeError if start or end is not safe integer + const NOT_SAFE_INTEGER = 2 ** 53; + [ + { start: NOT_SAFE_INTEGER, end: Infinity }, + { start: 0, end: NOT_SAFE_INTEGER }, + ].forEach((opts) => createReadStreamErr(example, opts, rangeError)); + }, +}; + +export const readStreamTest14 = { + async test() { + const path = "/tmp/x.txt"; + writeFileSync(path, "xyz\n"); + let data = ""; + let first = true; + + const stream = createReadStream(path); + stream.setEncoding("utf8"); + stream.on("data", function (chunk) { + data += chunk; + if (first) { + first = false; + stream.resume(); + } + }); + + const { promise, resolve } = Promise.withResolvers(); + + process.nextTick(function () { + stream.pause(); + setTimeout(function () { + stream.resume(); + resolve(); + }, 100); + }); + + await promise; + strictEqual(data, "xyz\n"); + }, +}; + +export const readStreamTest15 = { + async test() { + const { promise, resolve } = Promise.withResolvers(); + ReadStream.prototype.open = resolve; + createReadStream("asd"); + await promise; + delete ReadStream.prototype.open; + }, +}; + +// const fn = fixtures.path('elipses.txt'); +// const rangeFile = fixtures.path('x.txt'); + +export const readStreamTest16 = { + async test() { + let paused = false; + const path = prepareFile(); + + const file = ReadStream(path); + + const promises = []; + const { promise: openPromise, resolve: openResolve } = + Promise.withResolvers(); + const { promise: endPromise, resolve: endResolve } = + Promise.withResolvers(); + const { promise: closePromise, resolve: closeResolve } = + Promise.withResolvers(); + promises.push(openPromise); + promises.push(endPromise); + promises.push(closePromise); + + file.on("open", function (fd) { + file.length = 0; + strictEqual(typeof fd, "number"); + ok(file.readable); + + // GH-535 + file.pause(); + file.resume(); + file.pause(); + file.resume(); + openResolve(); + }); + + file.on("data", function (data) { + ok(data instanceof Buffer); + ok(!paused); + file.length += data.length; + + paused = true; + file.pause(); + + setTimeout(function () { + paused = false; + file.resume(); + }, 10); + }); + + file.on("end", endResolve); + + file.on("close", function () { + strictEqual(file.length, 30000); + closeResolve(); + }); + + await Promise.all(promises); + }, +}; + +export const readStreamTest17 = { + async test() { + const path = prepareFile(); + const file = createReadStream(path, { __proto__: { encoding: "utf8" } }); + file.length = 0; + file.on("data", function (data) { + strictEqual(typeof data, "string"); + file.length += data.length; + + for (let i = 0; i < data.length; i++) { + // http://www.fileformat.info/info/unicode/char/2026/index.htm + strictEqual(data[i], "\u2026"); + } + }); + + const { promise, resolve } = Promise.withResolvers(); + file.on("close", function () { + strictEqual(file.length, 10000); + resolve(); + }); + + await promise; + }, +}; + +export const readStreamTest18 = { + async test() { + const path = "/tmp/x.txt"; + writeFileSync(path, "xyz\n"); + const options = { __proto__: { bufferSize: 1, start: 1, end: 2 } }; + const file = createReadStream(path, options); + strictEqual(file.start, 1); + strictEqual(file.end, 2); + let contentRead = ""; + file.on("data", function (data) { + contentRead += data.toString("utf-8"); + }); + + const { promise, resolve } = Promise.withResolvers(); + file.on("end", function () { + strictEqual(contentRead, "yz"); + resolve(); + }); + await promise; + }, +}; + +export const readStreamTest19 = { + async test() { + const path = "/tmp/x.txt"; + writeFileSync(path, "xyz\n"); + const options = { __proto__: { bufferSize: 1, start: 1 } }; + const file = createReadStream(path, options); + strictEqual(file.start, 1); + file.data = ""; + file.on("data", function (data) { + file.data += data.toString("utf-8"); + }); + const { promise, resolve } = Promise.withResolvers(); + file.on("end", function () { + strictEqual(file.data, "yz\n"); + resolve(); + }); + await promise; + }, +}; + +// https://github.com/joyent/node/issues/2320 +export const readStreamTest20 = { + async test() { + const path = "/tmp/x.txt"; + writeFileSync(path, "xyz\n"); + const options = { __proto__: { bufferSize: 1.23, start: 1 } }; + const file = createReadStream(path, options); + strictEqual(file.start, 1); + file.data = ""; + file.on("data", function (data) { + file.data += data.toString("utf-8"); + }); + const { promise, resolve } = Promise.withResolvers(); + file.on("end", function () { + strictEqual(file.data, "yz\n"); + resolve(); + }); + await promise; + }, +}; + +export const readStreamTest21 = { + test() { + const path = "/tmp/x.txt"; + throws(() => createReadStream(path, { __proto__: { start: 10, end: 2 } }), { + code: "ERR_OUT_OF_RANGE", + name: "RangeError", + }); + }, +}; + +export const readStreamTest22 = { + async test() { + const path = "/tmp/x.txt"; + writeFileSync(path, "xyz\n"); + const options = { __proto__: { start: 0, end: 0 } }; + const stream = createReadStream(path, options); + strictEqual(stream.start, 0); + strictEqual(stream.end, 0); + stream.data = ""; + + stream.on("data", function (chunk) { + stream.data += chunk; + }); + + const { promise, resolve } = Promise.withResolvers(); + stream.on("end", function () { + strictEqual(stream.data, "x"); + resolve(); + }); + await promise; + }, +}; + +export const readStreamTest23 = { + async test() { + const path = "/tmp/x.txt"; + let output = ""; + writeFileSync(path, "hello world"); + const fd = openSync(path, "r"); + const stream = createReadStream(null, { fd: fd, encoding: "utf8" }); + strictEqual(stream.path, undefined); + stream.on("data", (data) => { + output += data; + }); + const { promise, resolve } = Promise.withResolvers(); + stream.on("close", resolve); + await promise; + strictEqual(output, "hello world"); + }, +}; + +export const readStreamTest24 = { + async test() { + const path = "/tmp/x.txt"; + writeFileSync(path, "hello world"); + const stream = createReadStream(path); + const { promise: promise1, resolve: resolve1 } = Promise.withResolvers(); + const { promise: promise2, resolve: resolve2 } = Promise.withResolvers(); + stream.close(resolve1); + stream.close(resolve2); + await Promise.all([promise1, promise2]); + }, +}; + +export const readStreamTest25 = { + async test() { + const path = "/tmp/x.txt"; + writeFileSync(path, "hello world"); + const stream = createReadStream(path); + const { promise: promise1, resolve: resolve1 } = Promise.withResolvers(); + const { promise: promise2, resolve: resolve2 } = Promise.withResolvers(); + stream.destroy(null, resolve1); + stream.destroy(null, resolve2); + await Promise.all([promise1, promise2]); + }, +}; + +export const readStreamTest26 = { + async test() { + const path = "/tmp/x.txt"; + writeFileSync(path, "hello world"); + const fh = await promises.open(path, "r"); + const { promise: closePromise, resolve: closeResolve } = + Promise.withResolvers(); + fh.on("close", closeResolve); + const stream = createReadStream(null, { fd: fh, encoding: "utf8" }); + let data = ""; + stream.on("data", (chunk) => (data += chunk)); + const { promise, resolve } = Promise.withResolvers(); + stream.on("end", () => resolve()); + await Promise.all([promise, closePromise]); + strictEqual(data, "hello world"); + strictEqual(fh.fd, undefined); + }, +}; + +export const readStreamTest27 = { + async test() { + const path = "/tmp/x.txt"; + writeFileSync(path, "xyz\n"); + const handle = await promises.open(path, "r"); + const { promise, resolve, reject } = Promise.withResolvers(); + const { promise: closePromise, resolve: closeResolve } = + Promise.withResolvers(); + handle.on("close", resolve); + const stream = createReadStream(null, { fd: handle }); + stream.on("data", reject); + stream.on("close", closeResolve); + handle.close(); + await Promise.all([promise, closePromise]); + }, +}; + +export const readStreamTest28 = { + async test() { + const path = "/tmp/x.txt"; + writeFileSync(path, "xyz\n"); + const handle = await promises.open(path, "r"); + const { promise: handlePromise, resolve: handleResolve } = + Promise.withResolvers(); + const { promise: streamPromise, resolve: streamResolve } = + Promise.withResolvers(); + handle.on("close", handleResolve); + const stream = createReadStream(null, { fd: handle }); + stream.on("close", streamResolve); + stream.on("data", () => handle.close()); + await Promise.all([handlePromise, streamPromise]); + }, +}; + +export const readStreamTest29 = { + async test() { + const path = "/tmp/x.txt"; + writeFileSync(path, "xyz\n"); + const handle = await promises.open(path, "r"); + const { promise: handlePromise, resolve: handleResolve } = + Promise.withResolvers(); + const { promise: streamPromise, resolve: streamResolve } = + Promise.withResolvers(); + handle.on("close", handleResolve); + const stream = createReadStream(null, { fd: handle }); + stream.on("close", streamResolve); + stream.close(); + await Promise.all([handlePromise, streamPromise]); + }, +}; + +export const readStreamTest30 = { + async test() { + const path = "/tmp/x.txt"; + writeFileSync(path, "xyz\n"); + const handle = await promises.open(path, "r"); + throws(() => createReadStream(null, { fd: handle, fs: {} }), { + code: "ERR_METHOD_NOT_IMPLEMENTED", + }); + handle.close(); + }, +}; + +export const readStreamTest31 = { + async test() { + // AbortSignal option test + const path = "/tmp/x.txt"; + writeFileSync(path, "xyz\n"); + const handle = await promises.open(path, "r"); + const controller = new AbortController(); + const { signal } = controller; + const stream = handle.createReadStream({ signal }); + + stream.on("data", () => { + throw new Error("boom"); + }); + stream.on("end", () => { + throw new Error("boom"); + }); + + const { promise: errorPromise, resolve: errorResolve } = + Promise.withResolvers(); + const { promise: closePromise, resolve: closeResolve } = + Promise.withResolvers(); + stream.on("error", (err) => { + strictEqual(err.name, "AbortError"); + errorResolve(); + }); + + handle.on("close", closeResolve); + stream.on("close", () => handle.close()); + + controller.abort(); + + await Promise.all([errorPromise, closePromise]); + }, +}; + +export const readStreamTest32 = { + async test() { + // Already-aborted signal test + const path = "/tmp/x.txt"; + writeFileSync(path, "xyz\n"); + const handle = await promises.open(path, "r"); + + const signal = AbortSignal.abort(); + const stream = handle.createReadStream({ signal }); + + stream.on("data", () => { + throw new Error("boom"); + }); + stream.on("end", () => { + throw new Error("boom"); + }); + + const { promise: errorPromise, resolve: errorResolve } = + Promise.withResolvers(); + const { promise: closePromise, resolve: closeResolve } = + Promise.withResolvers(); + + stream.on("error", (err) => { + strictEqual(err.name, "AbortError"); + errorResolve(); + }); + + handle.on("close", closeResolve); + stream.on("close", () => handle.close()); + + await Promise.all([errorPromise, closePromise]); + }, +}; + +export const readStreamTest33 = { + async test() { + // Invalid signal type test + const path = "/tmp/x.txt"; + writeFileSync(path, "xyz\n"); + const handle = await promises.open(path, "r"); + + for (const signal of [ + 1, + {}, + [], + "", + NaN, + 1n, + () => {}, + Symbol(), + false, + true, + ]) { + throws(() => handle.createReadStream({ signal }), { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + }); + } + handle.close(); + }, +}; + +export const readStreamTest34 = { + async test() { + // Custom abort reason test + const path = "/tmp/x.txt"; + writeFileSync(path, "xyz\n"); + const handle = await promises.open(path, "r"); + const controller = new AbortController(); + const { signal } = controller; + const reason = new Error("some silly abort reason"); + const stream = handle.createReadStream({ signal }); + + const { promise: errorPromise, resolve: errorResolve } = + Promise.withResolvers(); + const { promise: closePromise, resolve: closeResolve } = + Promise.withResolvers(); + + stream.on("error", (err) => { + strictEqual(err.name, "AbortError"); + strictEqual(err.cause, reason); + errorResolve(); + }); + + handle.on("close", closeResolve); + stream.on("close", () => handle.close()); + + controller.abort(reason); + + await Promise.all([errorPromise, closePromise]); + }, +}; + +export const emptyReadStreamTest = { + async test() { + writeFileSync("/tmp/empty.txt", ""); + const stream = createReadStream("/tmp/empty.txt"); + const { promise, resolve, reject } = Promise.withResolvers(); + stream.once("data", () => { + reject(new Error("should not emit data")); + }); + stream.once("end", resolve); + await promise; + strictEqual(stream.bytesRead, 0); + }, +}; + +export const fileHandleReadableWebStreamTest = { + async test() { + writeFileSync("/tmp/stream.txt", "abcde".repeat(1000)); + const fh = await promises.open("/tmp/stream.txt", "r"); + const stream = fh.readableWebStream(); + const _enc = new TextEncoder(); + let data = ""; + for await (const chunk of stream) { + strictEqual(chunk instanceof Uint8Array, true); + data += new TextDecoder().decode(chunk); + } + strictEqual(data, "abcde".repeat(1000)); + strictEqual(fh.fd, undefined); + + // Should throw if the stream is closed. + throws(() => fh.readableWebStream(), { + code: "EBADF", + }); + + const fh2 = await promises.open("/tmp/stream.txt", "r"); + const stream2 = fh2.readableWebStream({ autoClose: false }); + await fh2.close(); + const res = await stream2.getReader().read(); + strictEqual(res.done, true); + strictEqual(res.value, undefined); + }, +}; + +/** + * TODO(node-fs): Revisit + * Temporarily comment out. These are larger tests causing timeouts + * In CI. Will move them out to separate tests in a follow on PR +export const readStreamTest98 = { + async test() { + const path = prepareFile(); + const content = readFileSync(path); + + const N = 20; + let started = 0; + let done = 0; + + const arrayBuffers = new Set(); + const promises = []; + + async function startRead() { + ++started; + const chunks = []; + const promises = []; + const { promise, resolve } = Promise.withResolvers(); + promises.push(promise); + createReadStream(path) + .on('data', (chunk) => { + chunks.push(chunk); + arrayBuffers.add(chunk.buffer); + }) + .on('end', () => { + if (started < N) promises.push(startRead()); + deepStrictEqual(Buffer.concat(chunks), content); + if (++done === N) { + const retainedMemory = [...arrayBuffers] + .map((ab) => ab.byteLength) + .reduce((a, b) => a + b); + ok( + retainedMemory / (N * content.length) <= 3, + `Retaining ${retainedMemory} bytes in ABs for ${N} ` + + `chunks of size ${content.length}` + ); + } + resolve(); + }); + await Promise.all(promises); + } + + // Don’t start the reads all at once – that way we would have to allocate + // a large amount of memory upfront. + for (let i = 0; i < 6; ++i) { + promises.push(startRead()); + } + await Promise.all(promises); + }, +}; + +export const readStreamTest99 = { + async test() { + const path = '/tmp/read_stream_pos_test.txt'; + writeFileSync(path, ''); + + let counter = 0; + + const writeInterval = setInterval(() => { + counter = counter + 1; + const line = `hello at ${counter}\n`; + writeFileSync(path, line, { flag: 'a' }); + }, 1); + + const hwm = 10; + let bufs = []; + let isLow = false; + let cur = 0; + let stream; + + const readInterval = setInterval(() => { + if (stream) return; + + stream = createReadStream(path, { + highWaterMark: hwm, + start: cur, + }); + stream.on('data', (chunk) => { + cur += chunk.length; + bufs.push(chunk); + if (isLow) { + const brokenLines = Buffer.concat(bufs) + .toString() + .split('\n') + .filter((line) => { + const s = 'hello at'.slice(0, line.length); + if (line && !line.startsWith(s)) { + return true; + } + return false; + }); + strictEqual(brokenLines.length, 0); + exitTest(); + return; + } + if (chunk.length !== hwm) { + isLow = true; + } + }); + stream.on('end', () => { + stream = null; + isLow = false; + bufs = []; + }); + }, 10); + + // Time longer than 10 seconds to exit safely + await scheduler.wait(5_000); + + clearInterval(readInterval); + clearInterval(writeInterval); + + if (stream && !stream.destroyed) { + const { promise, resolve } = Promise.withResolvers(); + stream.on('close', resolve); + stream.destroy(); + await promise; + } + }, +}; +**/ diff --git a/src/compat/fs/upstream-readsync-byteoffset.fixture.mjs b/src/compat/fs/upstream-readsync-byteoffset.fixture.mjs new file mode 100644 index 0000000..5c3d7ec --- /dev/null +++ b/src/compat/fs/upstream-readsync-byteoffset.fixture.mjs @@ -0,0 +1,97 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +import { strictEqual } from "node:assert"; +import { openSync, closeSync, readSync, writeFileSync } from "./index.js"; + +// Regression test for AUTOVULN-CLOUDFLARE-WORKERD-13 +export const readSyncByteOffsetRegressionTest = { + test() { + writeFileSync("/tmp/byteoffset-test.txt", "ATTACK"); + const ab = new ArrayBuffer(13); + const full = new Uint8Array(ab); + full.set([83, 69, 67, 82, 69, 84, 124, 80, 85, 66, 76, 73, 67]); + const view = new Uint8Array(ab, 7, 6); + const fd = openSync("/tmp/byteoffset-test.txt", "r"); + try { + readSync(fd, view, { offset: 0, length: 6, position: 0 }); + } finally { + closeSync(fd); + } + strictEqual(new TextDecoder().decode(view), "ATTACK"); + strictEqual(full[0], 83); + strictEqual(full[6], 124); + }, +}; + +export const readSyncNumericOffsetTest = { + test() { + writeFileSync("/tmp/numoffset-test.txt", "HELLO"); + const ab = new ArrayBuffer(16); + new Uint8Array(ab).fill(65); + const view = new Uint8Array(ab, 8, 5); + const fd = openSync("/tmp/numoffset-test.txt", "r"); + try { + readSync(fd, view, 0, 5, 0); + } finally { + closeSync(fd); + } + strictEqual(new TextDecoder().decode(view), "HELLO"); + strictEqual(new Uint8Array(ab, 0, 8)[0], 65); + }, +}; + +// Nonzero numeric offset argument + undefined length: length should default to +// (buffer.byteLength - offset) of the *view*, and the read should land within +// the view starting at the given offset, not corrupting bytes outside. +export const readSyncNonzeroOffsetUndefinedLengthTest = { + test() { + writeFileSync("/tmp/nz-offset-undef-len-test.txt", "XYZ"); + const ab = new ArrayBuffer(16); + new Uint8Array(ab).fill(65); // 'A' everywhere + const view = new Uint8Array(ab, 4, 8); // view spans ab[4..12) + const fd = openSync("/tmp/nz-offset-undef-len-test.txt", "r"); + try { + // offset=2 within the view, length omitted (undefined), position=0. + readSync(fd, view, 2, undefined, 0); + } finally { + closeSync(fd); + } + // Data should land at view[2..5) == ab[6..9). + const full = new Uint8Array(ab); + // Bytes outside the view must remain 'A'. + for (let i = 0; i < 4; i++) strictEqual(full[i], 65); + for (let i = 12; i < 16; i++) strictEqual(full[i], 65); + // Bytes inside the view before the offset must remain 'A'. + strictEqual(full[4], 65); + strictEqual(full[5], 65); + // Data 'XYZ' should appear at ab[6..9). + strictEqual(full[6], 0x58); // 'X' + strictEqual(full[7], 0x59); // 'Y' + strictEqual(full[8], 0x5a); // 'Z' + }, +}; + +// Same scenario via the options-object form: nonzero offset, length omitted. +export const readSyncNonzeroOffsetUndefinedLengthOptionsTest = { + test() { + writeFileSync("/tmp/nz-offset-undef-len-opts-test.txt", "XYZ"); + const ab = new ArrayBuffer(16); + new Uint8Array(ab).fill(65); + const view = new Uint8Array(ab, 4, 8); + const fd = openSync("/tmp/nz-offset-undef-len-opts-test.txt", "r"); + try { + readSync(fd, view, { offset: 2, position: 0 }); + } finally { + closeSync(fd); + } + const full = new Uint8Array(ab); + for (let i = 0; i < 4; i++) strictEqual(full[i], 65); + for (let i = 12; i < 16; i++) strictEqual(full[i], 65); + strictEqual(full[4], 65); + strictEqual(full[5], 65); + strictEqual(full[6], 0x58); + strictEqual(full[7], 0x59); + strictEqual(full[8], 0x5a); + }, +}; diff --git a/src/compat/fs/upstream-stat.fixture.mjs b/src/compat/fs/upstream-stat.fixture.mjs new file mode 100644 index 0000000..40f48d1 --- /dev/null +++ b/src/compat/fs/upstream-stat.fixture.mjs @@ -0,0 +1,451 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +import { deepStrictEqual, ok, rejects, strictEqual, throws } from "node:assert"; + +import { + openSync, + closeSync, + symlinkSync, + statSync, + lstatSync, + fstatSync, + stat, + lstat, + fstat, + promises, + statfsSync, + statfs, + Stats, +} from "./index.js"; + +strictEqual(typeof openSync, "function"); +strictEqual(typeof closeSync, "function"); +strictEqual(typeof statSync, "function"); +strictEqual(typeof lstatSync, "function"); +strictEqual(typeof fstatSync, "function"); +strictEqual(typeof stat, "function"); +strictEqual(typeof lstat, "function"); +strictEqual(typeof fstat, "function"); +strictEqual(typeof symlinkSync, "function"); +strictEqual(typeof statfsSync, "function"); +strictEqual(typeof statfs, "function"); +strictEqual(typeof promises.stat, "function"); +strictEqual(typeof promises.lstat, "function"); + +const kInvalidArgTypeError = { code: "ERR_INVALID_ARG_TYPE" }; + +function checkRootStat(stat, bigint = false) { + ok(stat instanceof Stats); + strictEqual(stat.isDirectory(), true); + strictEqual(stat.isFile(), false); + strictEqual(stat.isBlockDevice(), false); + strictEqual(stat.isCharacterDevice(), false); + strictEqual(stat.isSymbolicLink(), false); + strictEqual(stat.isFIFO(), false); + strictEqual(stat.isSocket(), false); + strictEqual(stat.dev, bigint ? 0n : 0); + strictEqual(stat.ino, bigint ? 0n : 0); + strictEqual(stat.mode, bigint ? 16676n : 16676); + strictEqual(stat.nlink, bigint ? 1n : 1); + strictEqual(stat.uid, bigint ? 0n : 0); + strictEqual(stat.gid, bigint ? 0n : 0); + strictEqual(stat.rdev, bigint ? 0n : 0); + strictEqual(stat.size, bigint ? 0n : 0); + strictEqual(stat.blksize, bigint ? 0n : 0); + strictEqual(stat.blocks, bigint ? 0n : 0); + strictEqual(stat.atime.getTime(), 0); + strictEqual(stat.mtime.getTime(), 0); + strictEqual(stat.ctime.getTime(), 0); + strictEqual(stat.birthtime.getTime(), 0); + strictEqual(stat.atimeMs, bigint ? 0n : 0); + strictEqual(stat.mtimeMs, bigint ? 0n : 0); + strictEqual(stat.ctimeMs, bigint ? 0n : 0); + strictEqual(stat.birthtimeMs, bigint ? 0n : 0); + if (bigint) { + strictEqual(stat.atimeNs, 0n); + strictEqual(stat.mtimeNs, 0n); + strictEqual(stat.ctimeNs, 0n); + strictEqual(stat.birthtimeNs, 0n); + } +} + +function checkFileStat(stat, bigint = false) { + ok(stat instanceof Stats); + strictEqual(stat.isDirectory(), false); + strictEqual(stat.isFile(), true); + strictEqual(stat.isBlockDevice(), false); + strictEqual(stat.isCharacterDevice(), false); + strictEqual(stat.isSymbolicLink(), false); + strictEqual(stat.isFIFO(), false); + strictEqual(stat.isSocket(), false); + strictEqual(stat.dev, bigint ? 0n : 0); + strictEqual(stat.ino, bigint ? 0n : 0); + strictEqual(stat.mode, bigint ? 33060n : 33060); + strictEqual(stat.nlink, bigint ? 1n : 1); + strictEqual(stat.uid, bigint ? 0n : 0); + strictEqual(stat.gid, bigint ? 0n : 0); + strictEqual(stat.rdev, bigint ? 0n : 0); + ok(stat.size > 0n || stat.size > 0); + strictEqual(stat.blksize, bigint ? 0n : 0); + strictEqual(stat.blocks, bigint ? 0n : 0); + // The size is going to be constantly changing for this test file, + // so let's just check that it's not zero. +} + +function checkCharacterDeviceStat(stat, bigint = false) { + ok(stat instanceof Stats); + strictEqual(stat.isDirectory(), false); + strictEqual(stat.isFile(), false); + strictEqual(stat.isBlockDevice(), false); + strictEqual(stat.isCharacterDevice(), true); + strictEqual(stat.isSymbolicLink(), false); + strictEqual(stat.isFIFO(), false); + strictEqual(stat.isSocket(), false); + strictEqual(stat.dev, bigint ? 1n : 1); + strictEqual(stat.ino, bigint ? 0n : 0); + strictEqual(stat.mode, bigint ? 8630n : 8630); + strictEqual(stat.nlink, bigint ? 1n : 1); + strictEqual(stat.uid, bigint ? 0n : 0); + strictEqual(stat.gid, bigint ? 0n : 0); + strictEqual(stat.rdev, bigint ? 0n : 0); +} + +function checkLinkStat(stat, bigint = false) { + ok(stat instanceof Stats); + strictEqual(stat.isDirectory(), false); + strictEqual(stat.isFile(), false); + strictEqual(stat.isBlockDevice(), false); + strictEqual(stat.isCharacterDevice(), false); + strictEqual(stat.isSymbolicLink(), true); + strictEqual(stat.isFIFO(), false); + strictEqual(stat.isSocket(), false); + strictEqual(stat.dev, bigint ? 0n : 0); + strictEqual(stat.ino, bigint ? 0n : 0); + strictEqual(stat.mode, bigint ? 41252n : 41252); + strictEqual(stat.nlink, bigint ? 1n : 1); + strictEqual(stat.uid, bigint ? 0n : 0); + strictEqual(stat.gid, bigint ? 0n : 0); + strictEqual(stat.rdev, bigint ? 0n : 0); +} + +export const statSyncTest = { + async test() { + throws(() => statSync(), kInvalidArgTypeError); + throws(() => statSync("/", "foo"), kInvalidArgTypeError); + throws(() => statSync("/", { bigint: "yes" }), kInvalidArgTypeError); + throws(() => lstatSync(), kInvalidArgTypeError); + throws(() => lstatSync("/", "foo"), kInvalidArgTypeError); + throws(() => lstatSync("/", { bigint: "yes" }), kInvalidArgTypeError); + throws(() => fstatSync(), kInvalidArgTypeError); + throws(() => fstatSync(123, "foo"), kInvalidArgTypeError); + throws(() => fstatSync(123, { bigint: "yes" }), kInvalidArgTypeError); + + symlinkSync("/", "/tmp/link-root"); + symlinkSync("/bundle/worker", "/tmp/link-bundle-worker"); + symlinkSync("/dev/null", "/tmp/link-dev-null"); + + const fd1 = openSync("/", "r"); + const fd2 = openSync("/bundle/worker", "r"); + const fd3 = openSync("/dev/null", "r"); + + checkRootStat(statSync("/")); + checkRootStat(statSync("/", { bigint: true }), true); + checkRootStat(lstatSync("/")); + checkRootStat(lstatSync("/", { bigint: true }), true); + checkRootStat(fstatSync(fd1)); + checkRootStat(fstatSync(fd1, { bigint: true }), true); + + checkFileStat(statSync("/bundle/worker")); + checkFileStat(statSync("/bundle/worker", { bigint: true }), true); + checkFileStat(lstatSync("/bundle/worker")); + checkFileStat(lstatSync("/bundle/worker", { bigint: true }), true); + checkFileStat(fstatSync(fd2)); + + checkCharacterDeviceStat(statSync("/dev/null")); + checkCharacterDeviceStat(statSync("/dev/null", { bigint: true }), true); + checkCharacterDeviceStat(lstatSync("/dev/null")); + checkCharacterDeviceStat(lstatSync("/dev/null", { bigint: true }), true); + checkCharacterDeviceStat(fstatSync(fd3)); + + // Check that symlinks are followed by default with statSync. + checkRootStat(statSync("/tmp/link-root")); + checkRootStat(statSync("/tmp/link-root", { bigint: true }), true); + checkFileStat(statSync("/tmp/link-bundle-worker")); + checkFileStat(statSync("/tmp/link-bundle-worker", { bigint: true }), true); + checkCharacterDeviceStat(statSync("/tmp/link-dev-null")); + checkCharacterDeviceStat( + statSync("/tmp/link-dev-null", { bigint: true }), + true, + ); + + // But lstatSync does not follow symlinks. + checkLinkStat(lstatSync("/tmp/link-root")); + checkLinkStat(lstatSync("/tmp/link-root", { bigint: true }), true); + checkLinkStat(lstatSync("/tmp/link-bundle-worker")); + checkLinkStat(lstatSync("/tmp/link-bundle-worker", { bigint: true }), true); + checkLinkStat(lstatSync("/tmp/link-dev-null")); + checkLinkStat(lstatSync("/tmp/link-dev-null", { bigint: true }), true); + + // Non-existent paths and file descriptors + throws(() => statSync("/non-existent-path"), { code: "ENOENT" }); + throws(() => lstatSync("/non-existent-path"), { code: "ENOENT" }); + throws(() => fstatSync(123), { code: "EBADF" }); + + strictEqual( + statSync("/non-existent-path", { throwIfNoEntry: false }), + undefined, + ); + strictEqual( + lstatSync("/non-existent-path", { throwIfNoEntry: false }), + undefined, + ); + + closeSync(fd1); + closeSync(fd2); + closeSync(fd3); + }, +}; + +export const statTest = { + async test() { + throws(() => stat(), kInvalidArgTypeError); + throws(() => stat("/", "foo"), kInvalidArgTypeError); + throws(() => stat("/", { bigint: "yes" }), kInvalidArgTypeError); + throws(() => stat("/"), kInvalidArgTypeError); + throws(() => lstat(), kInvalidArgTypeError); + throws(() => lstat("/", "foo"), kInvalidArgTypeError); + throws(() => lstat("/", { bigint: "yes" }), kInvalidArgTypeError); + throws(() => lstat("/"), kInvalidArgTypeError); + throws(() => fstat(), kInvalidArgTypeError); + throws(() => fstat(123, "foo"), kInvalidArgTypeError); + throws(() => fstat(123, { bigint: "yes" }), kInvalidArgTypeError); + throws(() => fstat(123), kInvalidArgTypeError); + + symlinkSync("/", "/tmp/link-root"); + symlinkSync("/bundle/worker", "/tmp/link-bundle-worker"); + symlinkSync("/dev/null", "/tmp/link-dev-null"); + + const fd1 = openSync("/", "r"); + const fd2 = openSync("/bundle/worker", "r"); + const fd3 = openSync("/dev/null", "r"); + + const doStatTest = async (statFn, path, bigint, checkFn) => { + const { promise, resolve, reject } = Promise.withResolvers(); + statFn(path, { bigint }, (err, stat) => { + if (err) { + reject(err); + } else { + try { + checkFn(stat, bigint); + resolve(); + } catch (e) { + reject(e); + } + } + }); + await promise; + }; + + const doFailTest = async (statFn, path, code) => { + const { promise, resolve, reject } = Promise.withResolvers(); + statFn(path, (err) => { + if (err) { + strictEqual(err.code, code); + resolve(); + } else { + reject(new Error(`Expected error for path: ${path}`)); + } + }); + await promise; + }; + + await doStatTest(stat, "/", false, checkRootStat); + await doStatTest(stat, "/", true, checkRootStat); + await doStatTest(stat, "/bundle/worker", false, checkFileStat); + await doStatTest(stat, "/bundle/worker", true, checkFileStat); + await doStatTest(stat, "/dev/null", false, checkCharacterDeviceStat); + await doStatTest(stat, "/dev/null", true, checkCharacterDeviceStat); + + await doStatTest(stat, "/tmp/link-root", false, checkRootStat); + await doStatTest(stat, "/tmp/link-root", true, checkRootStat); + await doStatTest(stat, "/tmp/link-bundle-worker", false, checkFileStat); + await doStatTest(stat, "/tmp/link-bundle-worker", true, checkFileStat); + await doStatTest( + stat, + "/tmp/link-dev-null", + false, + checkCharacterDeviceStat, + ); + await doStatTest( + stat, + "/tmp/link-dev-null", + true, + checkCharacterDeviceStat, + ); + + await doStatTest(lstat, "/", false, checkRootStat); + await doStatTest(lstat, "/", true, checkRootStat); + await doStatTest(lstat, "/bundle/worker", false, checkFileStat); + await doStatTest(lstat, "/bundle/worker", true, checkFileStat); + await doStatTest(lstat, "/dev/null", false, checkCharacterDeviceStat); + await doStatTest(lstat, "/dev/null", true, checkCharacterDeviceStat); + + await doStatTest(lstat, "/tmp/link-root", false, checkLinkStat); + await doStatTest(lstat, "/tmp/link-root", true, checkLinkStat); + await doStatTest(lstat, "/tmp/link-bundle-worker", false, checkLinkStat); + await doStatTest(lstat, "/tmp/link-bundle-worker", true, checkLinkStat); + await doStatTest(lstat, "/tmp/link-dev-null", false, checkLinkStat); + await doStatTest(lstat, "/tmp/link-dev-null", true, checkLinkStat); + + await doStatTest(fstat, fd1, false, checkRootStat); + await doStatTest(fstat, fd1, true, checkRootStat); + await doStatTest(fstat, fd2, false, checkFileStat); + await doStatTest(fstat, fd2, true, checkFileStat); + await doStatTest(fstat, fd3, false, checkCharacterDeviceStat); + await doStatTest(fstat, fd3, true, checkCharacterDeviceStat); + + // // Non-existent paths and file descriptors + await doFailTest(stat, "/non-existent-path", "ENOENT"); + await doFailTest(lstat, "/non-existent-path", "ENOENT"); + await doFailTest(fstat, 123, "EBADF"); + + closeSync(fd1); + closeSync(fd2); + closeSync(fd3); + }, +}; + +export const statPromisesTest = { + async test() { + await rejects(promises.stat(), kInvalidArgTypeError); + await rejects(promises.stat("/", "foo"), kInvalidArgTypeError); + await rejects(promises.stat("/", { bigint: "yes" }), kInvalidArgTypeError); + await rejects(promises.lstat(), kInvalidArgTypeError); + await rejects(promises.lstat("/", "foo"), kInvalidArgTypeError); + await rejects(promises.lstat("/", { bigint: "yes" }), kInvalidArgTypeError); + + symlinkSync("/", "/tmp/link-root"); + symlinkSync("/bundle/worker", "/tmp/link-bundle-worker"); + symlinkSync("/dev/null", "/tmp/link-dev-null"); + + await checkRootStat(await promises.stat("/")); + await checkRootStat(await promises.stat("/", { bigint: true }), true); + await checkRootStat(await promises.lstat("/")); + await checkRootStat(await promises.lstat("/", { bigint: true }), true); + + await checkFileStat(await promises.stat("/bundle/worker")); + await checkFileStat( + await promises.stat("/bundle/worker", { bigint: true }), + true, + ); + await checkFileStat(await promises.lstat("/bundle/worker")); + await checkFileStat( + await promises.lstat("/bundle/worker", { bigint: true }), + true, + ); + + await checkCharacterDeviceStat(await promises.stat("/dev/null")); + await checkCharacterDeviceStat( + await promises.stat("/dev/null", { bigint: true }), + true, + ); + await checkCharacterDeviceStat(await promises.lstat("/dev/null")); + await checkCharacterDeviceStat( + await promises.lstat("/dev/null", { bigint: true }), + true, + ); + + await checkRootStat(await promises.stat("/tmp/link-root")); + await checkRootStat( + await promises.stat("/tmp/link-root", { bigint: true }), + true, + ); + await checkFileStat(await promises.stat("/tmp/link-bundle-worker")); + await checkFileStat( + await promises.stat("/tmp/link-bundle-worker", { bigint: true }), + true, + ); + await checkCharacterDeviceStat(await promises.stat("/tmp/link-dev-null")); + await checkCharacterDeviceStat( + await promises.stat("/tmp/link-dev-null", { bigint: true }), + true, + ); + + await checkLinkStat(await promises.lstat("/tmp/link-root")); + await checkLinkStat( + await promises.lstat("/tmp/link-root", { bigint: true }), + true, + ); + await checkLinkStat(await promises.lstat("/tmp/link-bundle-worker")); + await checkLinkStat( + await promises.lstat("/tmp/link-bundle-worker", { bigint: true }), + true, + ); + await checkLinkStat(await promises.lstat("/tmp/link-dev-null")); + await checkLinkStat( + await promises.lstat("/tmp/link-dev-null", { bigint: true }), + true, + ); + }, +}; + +export const statfsTest = { + async test() { + const check = { + type: 0, + bsize: 0, + blocks: 0, + bfree: 0, + bavail: 0, + files: 0, + ffree: 0, + }; + const checkBn = { + type: 0n, + bsize: 0n, + blocks: 0n, + bfree: 0n, + bavail: 0n, + files: 0n, + ffree: 0n, + }; + + deepStrictEqual(statfsSync("/"), check); + deepStrictEqual(statfsSync("/bundle", { bigint: true }), checkBn); + + async function callStatfs(path, fn, bigint = false) { + const { promise, resolve, reject } = Promise.withResolvers(); + statfs(path, { bigint }, (err, stat) => { + if (err) return reject(err); + try { + fn(stat); + resolve(); + } catch (err) { + reject(err); + } + }); + await promise; + } + + await callStatfs("/", (stat) => { + deepStrictEqual(stat, check); + }); + await callStatfs( + "/", + (stat) => { + deepStrictEqual(stat, checkBn); + }, + true /* bigint */, + ); + + // Throws if the path is invalid / wrong type + throws(() => statfs(123), { + code: /ERR_INVALID_ARG_TYPE/, + }); + + throws(() => statfs("/", { bigint: "yes" }), { + code: /ERR_INVALID_ARG_TYPE/, + }); + }, +}; diff --git a/src/compat/fs/upstream-utimes.fixture.mjs b/src/compat/fs/upstream-utimes.fixture.mjs new file mode 100644 index 0000000..f3204d6 --- /dev/null +++ b/src/compat/fs/upstream-utimes.fixture.mjs @@ -0,0 +1,186 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +import { ok, match, rejects, strictEqual, throws } from "node:assert"; + +import { + openSync, + existsSync, + closeSync, + statSync, + fstatSync, + utimesSync, + futimesSync, + lutimesSync, + utimes, + futimes, + lutimes, + promises, +} from "./index.js"; + +strictEqual(typeof openSync, "function"); +strictEqual(typeof closeSync, "function"); +strictEqual(typeof statSync, "function"); +strictEqual(typeof utimesSync, "function"); +strictEqual(typeof futimesSync, "function"); +strictEqual(typeof lutimesSync, "function"); +strictEqual(typeof utimes, "function"); +strictEqual(typeof futimes, "function"); +strictEqual(typeof lutimes, "function"); +strictEqual(typeof promises.utimes, "function"); +strictEqual(typeof promises.lutimes, "function"); + +const kInvalidArgTypeError = { code: "ERR_INVALID_ARG_TYPE" }; + +function checkStat(path, mtimeMsCheck) { + const bigint = typeof mtimeMsCheck === "bigint"; + const { atimeMs, mtimeMs, ctimeMs, birthtimeMs } = + typeof path === "number" + ? fstatSync(path, { bigint }) + : statSync(path, { bigint }); + strictEqual(mtimeMs, mtimeMsCheck); + strictEqual(ctimeMs, mtimeMsCheck); + strictEqual(atimeMs, bigint ? 0n : 0); + strictEqual(birthtimeMs, bigint ? 0n : 0); +} + +export const utimesTest = { + async test() { + throws(() => utimesSync(123), kInvalidArgTypeError); + throws(() => utimesSync("", {}), kInvalidArgTypeError); + throws(() => utimesSync("", 0, {}), kInvalidArgTypeError); + throws(() => lutimesSync(123), kInvalidArgTypeError); + throws(() => lutimesSync("", {}), kInvalidArgTypeError); + throws(() => lutimesSync("", 0, {}), kInvalidArgTypeError); + throws(() => futimesSync(""), kInvalidArgTypeError); + throws(() => futimesSync(0, {}), kInvalidArgTypeError); + throws(() => futimesSync(0, 0, {}), kInvalidArgTypeError); + throws(() => utimes(0), kInvalidArgTypeError); + throws(() => utimes("", {}), kInvalidArgTypeError); + throws(() => utimes("", 0, {}), kInvalidArgTypeError); + throws(() => utimes("", 0, 0), kInvalidArgTypeError); + throws(() => utimes(""), kInvalidArgTypeError); + throws(() => lutimes(0), kInvalidArgTypeError); + throws(() => lutimes("", {}), kInvalidArgTypeError); + throws(() => lutimes("", 0, {}), kInvalidArgTypeError); + throws(() => lutimes("", 0, 0), kInvalidArgTypeError); + throws(() => lutimes(""), kInvalidArgTypeError); + throws(() => futimes(""), kInvalidArgTypeError); + throws(() => futimes(0, {}), kInvalidArgTypeError); + throws(() => futimes(0, 0, {}), kInvalidArgTypeError); + throws(() => futimes(0, 0, 0), kInvalidArgTypeError); + throws(() => futimes(0), kInvalidArgTypeError); + await rejects(promises.utimes(123), kInvalidArgTypeError); + await rejects(promises.utimes("", {}), kInvalidArgTypeError); + await rejects(promises.utimes("", 0, {}), kInvalidArgTypeError); + await rejects(promises.lutimes(123), kInvalidArgTypeError); + await rejects(promises.lutimes("", {}), kInvalidArgTypeError); + await rejects(promises.lutimes("", 0, {}), kInvalidArgTypeError); + + throws( + () => { + utimesSync("/tmp/test.txt", 1000, new Date("not a valid date")); + }, + { + message: /^The value cannot be converted/, + name: "TypeError", + }, + ); + + const fd = openSync("/tmp/test.txt", "w+"); + ok(existsSync("/tmp/test.txt")); + + checkStat(fd, 0n); + + utimesSync("/tmp/test.txt", 1000, 2000); + checkStat(fd, 2000n); + + utimesSync("/tmp/test.txt", 1000, new Date(0)); + checkStat(fd, 0); + + utimesSync("/tmp/test.txt", 3000, "1970-01-01T01:00:00.000Z"); + checkStat(fd, 3600000n); + + lutimesSync("/tmp/test.txt", 3000, 4000); + checkStat(fd, 4000n); + + lutimesSync("/tmp/test.txt", 1000, new Date(0)); + checkStat(fd, 0); + + lutimesSync("/tmp/test.txt", 3000, "1970-01-01T01:00:00.000Z"); + checkStat(fd, 3600000n); + + futimesSync(fd, 5000, 6000); + checkStat(fd, 6000n); + + futimesSync(fd, 1000, new Date(0)); + checkStat(fd, 0); + + futimesSync(fd, 3000, "1970-01-01T01:00:00.000Z"); + checkStat(fd, 3600000n); + + { + const { promise, resolve, reject } = Promise.withResolvers(); + utimes("/tmp/test.txt", 8000, new Date("not a valid date"), (err) => { + try { + ok(err); + strictEqual(err.name, "TypeError"); + match(err.message, /The value cannot be converted/); + resolve(); + } catch (err) { + reject(err); + } + }); + await promise; + } + + { + const { promise, resolve, reject } = Promise.withResolvers(); + utimes("/tmp/test.txt", 8000, 9000, (err) => { + if (err) return reject(err); + try { + checkStat(fd, 9000n); + resolve(); + } catch (err) { + reject(err); + } + }); + await promise; + } + + { + const { promise, resolve, reject } = Promise.withResolvers(); + lutimes("/tmp/test.txt", 8000, 10000, (err) => { + if (err) return reject(err); + try { + checkStat(fd, 10000n); + resolve(); + } catch (err) { + reject(err); + } + }); + await promise; + } + + { + const { promise, resolve, reject } = Promise.withResolvers(); + futimes(fd, 7000, 11000, (err) => { + if (err) return reject(err); + try { + checkStat(fd, 11000n); + resolve(); + } catch (err) { + reject(err); + } + }); + await promise; + } + + await promises.utimes("/tmp/test.txt", 12000, 13000); + checkStat(fd, 13000n); + await promises.lutimes("/tmp/test.txt", 14000, 15000); + checkStat(fd, 15000n); + + closeSync(fd); + }, +}; diff --git a/src/compat/fs/upstream-writestream.fixture.mjs b/src/compat/fs/upstream-writestream.fixture.mjs new file mode 100644 index 0000000..eb87afd --- /dev/null +++ b/src/compat/fs/upstream-writestream.fixture.mjs @@ -0,0 +1,516 @@ +// Copyright (c) 2025 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +import { + WriteStream, + createWriteStream, + createReadStream, + readFileSync, + writeFileSync, + closeSync, + open as openAsync, + close as closeAsync, + write as writeAsync, + writev as writevAsync, + fsync as fsyncAsync, + promises, +} from "./index.js"; + +import { Writable } from "../stream/index.js"; + +import { + deepStrictEqual, + ok, + notStrictEqual, + strictEqual, + throws, +} from "node:assert"; +import { mock } from "node:test"; + +strictEqual(typeof WriteStream, "function"); +strictEqual(typeof createWriteStream, "function"); + +export const simpleWriteStreamTest = { + async test() { + const path = "/tmp/workerd-fs-writestream-test.txt"; + const data = "Hello, World!"; + const writeStream = createWriteStream(path, { flags: "w" }); + writeStream.write(data); + writeStream.end(); + const { promise, resolve } = Promise.withResolvers(); + const { promise: finishPromise, resolve: finishResolve } = + Promise.withResolvers(); + writeStream.on("finish", finishResolve); + writeStream.on("close", resolve); + await Promise.all([promise, finishPromise]); + const check = readFileSync(path, "utf8"); + strictEqual(check, data); + }, +}; + +export const writeStreamTest1 = { + async test() { + const path = "/tmp/workerd-fs-writestream-test1.txt"; + const { promise, resolve } = Promise.withResolvers(); + const stream = WriteStream(path, { + fs: { + close(fd) { + ok(fd); + closeSync(fd); + resolve(); + }, + }, + }); + stream.destroy(); + await promise; + }, +}; + +export const writeStreamTest2 = { + async test() { + const path = "/tmp/workerd-fs-writestream-test2.txt"; + const stream = createWriteStream(path); + + const { promise, resolve, reject } = Promise.withResolvers(); + + stream.on("drain", reject); + stream.on("close", resolve); + stream.destroy(); + await promise; + }, +}; + +export const writeStreamTest3 = { + async test() { + const path = "/tmp/workerd-fs-writestream-test3.txt"; + const stream = createWriteStream(path); + const { promise, resolve, reject } = Promise.withResolvers(); + stream.on("error", reject); + stream.on("close", resolve); + throws(() => stream.write(42), { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + }); + stream.destroy(); + await promise; + }, +}; + +export const writeStreamTest4 = { + test() { + const example = "/tmp/workerd-fs-writestream-test4.txt"; + createWriteStream(example, undefined).end(); + createWriteStream(example, null).end(); + createWriteStream(example, "utf8").end(); + createWriteStream(example, { encoding: "utf8" }).end(); + + const createWriteStreamErr = (path, opt) => { + throws(() => createWriteStream(path, opt), { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + }); + }; + + createWriteStreamErr(example, 123); + createWriteStreamErr(example, 0); + createWriteStreamErr(example, true); + createWriteStreamErr(example, false); + }, +}; + +export const writeStreamTest5 = { + async test() { + const { promise, resolve } = Promise.withResolvers(); + WriteStream.prototype.open = resolve; + createWriteStream("/tmp/test"); + await promise; + delete WriteStream.prototype.open; + }, +}; + +export const writeStreamTest6 = { + async test() { + const path = "/tmp/write-end-test0.txt"; + const fs = { + open: mock.fn(openAsync), + write: mock.fn(writeAsync), + close: mock.fn(closeAsync), + }; + const { promise, resolve } = Promise.withResolvers(); + const stream = createWriteStream(path, { fs }); + stream.on("close", resolve); + stream.end("asd"); + + await promise; + strictEqual(fs.open.mock.callCount(), 1); + strictEqual(fs.write.mock.callCount(), 1); + strictEqual(fs.close.mock.callCount(), 1); + }, +}; + +export const writeStreamTest7 = { + async test() { + const path = "/tmp/write-end-test1.txt"; + const fs = { + open: mock.fn(openAsync), + write: writeAsync, + writev: mock.fn(writevAsync), + close: mock.fn(closeAsync), + }; + const stream = createWriteStream(path, { fs }); + stream.write("asd"); + stream.write("asd"); + stream.write("asd"); + stream.end(); + const { promise, resolve } = Promise.withResolvers(); + stream.on("close", resolve); + await promise; + + strictEqual(fs.open.mock.callCount(), 1); + strictEqual(fs.writev.mock.callCount(), 1); + strictEqual(fs.close.mock.callCount(), 1); + }, +}; + +let cnt = 0; +function nextFile() { + return `/tmp/${cnt++}.out`; +} + +export const writeStreamTest8 = { + test() { + for (const flush of ["true", "", 0, 1, [], {}, Symbol()]) { + throws( + () => { + createWriteStream(nextFile(), { flush }); + }, + { code: "ERR_INVALID_ARG_TYPE" }, + ); + } + }, +}; + +export const writeStreamTest9 = { + async test() { + const fs = { + fsync: mock.fn(fsyncAsync), + }; + const stream = createWriteStream(nextFile(), { flush: true, fs }); + + const { promise, resolve, reject } = Promise.withResolvers(); + + stream.write("hello", (err) => { + if (err) return reject(); + stream.close((err) => { + if (err) return reject(err); + resolve(); + }); + }); + + await promise; + + strictEqual(fs.fsync.mock.callCount(), 1); + }, +}; + +export const writeStreamTest10 = { + async test() { + const values = [undefined, null, false]; + const fs = { + fsync: mock.fn(fsyncAsync), + }; + let cnt = 0; + + const { promise, resolve, reject } = Promise.withResolvers(); + + for (const flush of values) { + const file = nextFile(); + const stream = createWriteStream(file, { flush }); + stream.write("hello world", (err) => { + if (err) return reject(err); + stream.close((err) => { + if (err) return reject(err); + strictEqual(readFileSync(file, "utf8"), "hello world"); + cnt++; + if (cnt === values.length) { + strictEqual(fs.fsync.mock.callCount(), 0); + resolve(); + } + }); + }); + } + + await promise; + }, +}; + +export const writeStreamTest11 = { + async test() { + const file = nextFile(); + const handle = await promises.open(file, "w"); + const stream = handle.createWriteStream({ flush: true }); + + const { promise, resolve, reject } = Promise.withResolvers(); + + stream.write("hello", (err) => { + if (err) return reject(err); + stream.close((err) => { + if (err) return reject(err); + strictEqual(readFileSync(file, "utf8"), "hello"); + resolve(); + }); + }); + + await promise; + }, +}; + +export const writeStreamTest12 = { + async test() { + const file = nextFile(); + const handle = await promises.open(file, "w+"); + + const { promise, resolve } = Promise.withResolvers(); + handle.on("close", resolve); + const stream = createWriteStream(null, { fd: handle }); + + stream.end("hello"); + stream.on("close", () => { + const output = readFileSync(file, "utf-8"); + strictEqual(output, "hello"); + }); + + await promise; + }, +}; + +export const writeStreamTest13 = { + async test() { + const file = nextFile(); + const handle = await promises.open(file, "w+"); + let _calls = 0; + const { write: _originalWriteFunction, writev: _originalWritevFunction } = + handle; + handle.write = mock.fn(handle.write.bind(handle)); + handle.writev = mock.fn(handle.writev.bind(handle)); + const stream = createWriteStream(null, { fd: handle }); + stream.end("hello"); + const { promise, resolve } = Promise.withResolvers(); + stream.on("close", () => { + console.log("test"); + ok(handle.write.mock.callCount() + handle.writev.mock.callCount() > 0); + resolve(); + }); + await promise; + }, +}; + +export const writeStreamTest14 = { + async test() { + const path = "/tmp/out"; + + let writeCalls = 0; + const fs = { + write: mock.fn((...args) => { + switch (writeCalls++) { + case 0: { + return writeAsync(...args); + } + case 1: { + args[args.length - 1](new Error("BAM")); + break; + } + default: { + // It should not be called again! + throw new Error("BOOM!"); + } + } + }), + close: mock.fn(closeAsync), + }; + + const stream = createWriteStream(path, { + highWaterMark: 10, + fs, + }); + + const { promise: errorPromise, resolve: errorResolve } = + Promise.withResolvers(); + const { promise: writePromise, resolve: writeResolve } = + Promise.withResolvers(); + + stream.on("error", (err) => { + strictEqual(stream.fd, null); + strictEqual(err.message, "BAM"); + errorResolve(); + }); + + stream.write(Buffer.allocUnsafe(256), () => { + stream.write(Buffer.allocUnsafe(256), (err) => { + strictEqual(err.message, "BAM"); + writeResolve(); + }); + }); + + await Promise.all([errorPromise, writePromise]); + }, +}; + +export const writeStreamTest15 = { + async test() { + const file = "/tmp/write-end-test0.txt"; + const stream = createWriteStream(file); + stream.end(); + const { promise, resolve } = Promise.withResolvers(); + stream.on("close", resolve); + await promise; + }, +}; + +export const writeStreamTest16 = { + async test() { + const file = "/tmp/write-end-test1.txt"; + const stream = createWriteStream(file); + stream.end("a\n", "utf8"); + const { promise, resolve } = Promise.withResolvers(); + stream.on("close", () => { + const content = readFileSync(file, "utf8"); + strictEqual(content, "a\n"); + resolve(); + }); + await promise; + }, +}; + +export const writeStreamTest17 = { + async test() { + const file = "/tmp/write-end-test2.txt"; + const stream = createWriteStream(file); + stream.end(); + + const { promise: openPromise, resolve: openResolve } = + Promise.withResolvers(); + const { promise: finishPromise, resolve: finishResolve } = + Promise.withResolvers(); + stream.on("open", openResolve); + stream.on("finish", finishResolve); + await Promise.all([openPromise, finishPromise]); + }, +}; + +export const writeStreamTest18 = { + async test() { + const examplePath = "/tmp/a"; + const dummyPath = "/tmp/b"; + const firstEncoding = "base64"; + const secondEncoding = "latin1"; + + writeFileSync(examplePath, "xyz\n"); + const exampleReadStream = createReadStream(examplePath, { + encoding: firstEncoding, + }); + + const dummyWriteStream = createWriteStream(dummyPath, { + encoding: firstEncoding, + }); + + const { promise, resolve } = Promise.withResolvers(); + exampleReadStream.pipe(dummyWriteStream).on("finish", () => { + const assertWriteStream = new Writable({ + write: function (chunk, enc, next) { + const expected = Buffer.from("xyz\n"); + deepStrictEqual(expected, chunk); + next(); + }, + }); + assertWriteStream.setDefaultEncoding(secondEncoding); + createReadStream(dummyPath, { + encoding: secondEncoding, + }) + .pipe(assertWriteStream) + .on("close", resolve); + }); + + await promise; + }, +}; + +export const writeStreamTest19 = { + async test() { + const file = "/tmp/write-end-test3.txt"; + const stream = createWriteStream(file); + const { promise: closePromise1, resolve: closeResolve1 } = + Promise.withResolvers(); + const { promise: closePromise2, resolve: closeResolve2 } = + Promise.withResolvers(); + stream.close(closeResolve1); + stream.close(closeResolve2); + await Promise.all([closePromise1, closePromise2]); + }, +}; + +export const writeStreamTest20 = { + async test() { + const file = "/tmp/write-autoclose-opt1.txt"; + let stream = createWriteStream(file, { flags: "w+", autoClose: false }); + stream.write("Test1"); + stream.end(); + const { promise, resolve, reject } = Promise.withResolvers(); + stream.on("finish", () => { + stream.on("close", reject); + process.nextTick(() => { + strictEqual(stream.closed, false); + notStrictEqual(stream.fd, null); + resolve(); + }); + }); + await promise; + + const { promise: nextPromise, resolve: nextResolve } = + Promise.withResolvers(); + const stream2 = createWriteStream(null, { fd: stream.fd, start: 0 }); + stream2.write("Test2"); + stream2.end(); + stream2.on("finish", () => { + strictEqual(stream2.closed, false); + stream2.on("close", () => { + strictEqual(stream2.fd, null); + strictEqual(stream2.closed, true); + nextResolve(); + }); + }); + + await nextPromise; + + const data = readFileSync(file, "utf8"); + strictEqual(data, "Test2"); + }, +}; + +export const writeStreamTest21 = { + async test() { + // This is to test success scenario where autoClose is true + const file = "/tmp/write-autoclose-opt2.txt"; + const stream = createWriteStream(file, { autoClose: true }); + stream.write("Test3"); + stream.end(); + const { promise, resolve } = Promise.withResolvers(); + stream.on("finish", () => { + strictEqual(stream.closed, false); + stream.on("close", () => { + strictEqual(stream.fd, null); + strictEqual(stream.closed, true); + resolve(); + }); + }); + await promise; + }, +}; + +export const writeStreamTest22 = { + test() { + throws(() => WriteStream.prototype.autoClose, { + code: "ERR_INVALID_THIS", + }); + }, +}; diff --git a/src/compat/fs/writable.spec.ts b/src/compat/fs/writable.spec.ts index a582c4d..0a7ce4b 100644 --- a/src/compat/fs/writable.spec.ts +++ b/src/compat/fs/writable.spec.ts @@ -1,6 +1,57 @@ import { expect, test } from "vitest"; import * as fs from "./index.js"; -import { runWithTemporaryFiles } from "./writable.js"; +import { runWithTemporaryFiles, retainTemporaryFiles } from "./writable.js"; + +test("access uses Workers permissions for temporary and embedded files", async () => { + fs.registerVirtualFiles({ "/bundle/access-data": "read only" }); + await runWithTemporaryFiles(async () => { + fs.writeFileSync("/tmp/access-data", "writable"); + for (let mode = 0; mode < 8; mode++) { + for (const path of [ + "/tmp/access-data", + "/bundle/access-data", + "/tmp/missing", + ]) { + const fails = + !!(mode & fs.X_OK) || + (path.startsWith("/bundle") && !!(mode & fs.W_OK)) || + path.endsWith("missing"); + if (fails) { + expect(() => fs.accessSync(path, mode)).toThrow( + expect.objectContaining({ code: "ENOENT" }), + ); + await expect(fs.promises.access(path, mode)).rejects.toMatchObject({ + code: "ENOENT", + }); + } else { + expect(fs.accessSync(path, mode)).toBeUndefined(); + await expect(fs.promises.access(path, mode)).resolves.toBeUndefined(); + } + let returned = false; + const done = new Promise((resolve, reject) => { + fs.access(path, mode, (error: any) => { + try { + expect(returned).toBe(true); + expect(error?.code ?? null).toBe(fails ? "ENOENT" : null); + resolve(); + } catch (failure) { + reject(failure); + } + }); + }); + returned = true; + await done; + } + } + expect(() => fs.access("/tmp/access-data", 8, () => {})).toThrow( + expect.objectContaining({ code: "ERR_OUT_OF_RANGE" }), + ); + await expect( + // @ts-expect-error Exercise runtime rejection of a nonnumeric mode. + fs.promises.access("/tmp/access-data", "4"), + ).rejects.toMatchObject({ code: "ERR_INVALID_ARG_TYPE" }); + }); +}); test("temporary files are isolated between requests and from the fallback volume", () => { fs.clearVirtualFiles(); @@ -30,26 +81,40 @@ test("concurrent asynchronous requests retain separate temporary files", () => { return Promise.all([operation("first"), operation("second")]); }); -test("a temporary symlink cannot mutate embedded assets", () => { +test("a temporary symlink can read but cannot mutate embedded assets", async () => { fs.registerVirtualFiles({ "/bundle/secret": "original" }); - expect(() => fs.symlinkSync("/bundle/secret", "/tmp/link")).toThrow(); + fs.symlinkSync("/bundle/secret", "/tmp/link"); + expect(fs.readFileSync("/tmp/link", "utf8")).toBe("original"); + expect(() => fs.writeFileSync("/tmp/link", "changed")).toThrow( + expect.objectContaining({ code: "EPERM" }), + ); + await expect( + fs.promises.writeFile("/tmp/link", "changed"), + ).rejects.toMatchObject({ code: "EPERM" }); + expect(() => fs.accessSync("/tmp/link", fs.W_OK)).toThrow( + expect.objectContaining({ code: "ENOENT" }), + ); + expect(fs.statSync("/tmp/link").mode & 0o777).toBe(0o444); expect(() => fs.writeFileSync("/bundle/secret", "changed")).toThrow(); expect(fs.readFileSync("/bundle/secret", "utf8")).toBe("original"); }); -test("relative temporary symlinks resolve against their parent directory", async () => { +test("relative temporary symlinks resolve from the Workers bundle cwd", async () => { await runWithTemporaryFiles(async () => { fs.mkdirSync("/tmp/nested"); fs.writeFileSync("/tmp/nested/data", "local"); + fs.registerVirtualFiles({ "/bundle/data": "bundled" }); fs.symlinkSync("data", "/tmp/nested/link"); - expect(fs.readFileSync("/tmp/nested/link", "utf8")).toBe("local"); - await fs.promises.symlink("../nested/data", "/tmp/nested/async-link"); + expect(fs.readFileSync("/tmp/nested/link", "utf8")).toBe("bundled"); + await fs.promises.symlink("../tmp/nested/data", "/tmp/nested/async-link"); expect(await fs.promises.readFile("/tmp/nested/async-link", "utf8")).toBe( "local", ); - expect(() => - fs.symlinkSync("../../bundle/secret", "/tmp/nested/escape"), - ).toThrow(); + fs.symlinkSync("../../bundle/secret", "/tmp/nested/escape"); + expect(fs.readlinkSync("/tmp/nested/escape")).toBe("/bundle/secret"); + expect(() => fs.writeFileSync("/tmp/nested/escape", "changed")).toThrow( + expect.objectContaining({ code: "EPERM" }), + ); }); }); @@ -59,7 +124,7 @@ test("callback writes report read-only paths asynchronously", async () => { fs.writeFile("/bundle/secret", "changed", (error: any) => { try { expect(returned).toBe(true); - expect(error.code).toBe("EROFS"); + expect(error.code).toBe("EPERM"); resolve(); } catch (failure) { reject(failure); @@ -127,3 +192,129 @@ test("permission operations preserve file metadata and statfs is a Workers stub" ).toEqual(Array(7).fill(0n)); }); }); + +test("embedded descriptors, FileHandles and streams preserve read-only data", async () => { + fs.registerVirtualFiles({ "/bundle/readonly": "abcdef" }); + await runWithTemporaryFiles(async () => { + const fd = fs.openSync("/bundle/readonly", "r"); + const buffer = Buffer.alloc(3); + expect(fs.readSync(fd, buffer, 0, 3, 1)).toBe(3); + expect(buffer.toString()).toBe("bcd"); + expect(fs.fstatSync(fd).mode & 0o777).toBe(0o444); + expect(() => fs.writeSync(fd, "x")).toThrow( + expect.objectContaining({ code: "EPERM" }), + ); + expect(() => fs.ftruncateSync(fd, 0)).toThrow( + expect.objectContaining({ code: "EPERM" }), + ); + fs.closeSync(fd); + expect(() => fs.openSync("/bundle/readonly", "r+")).toThrow( + expect.objectContaining({ code: "EPERM" }), + ); + const handle = await fs.promises.open("/bundle/readonly", "r"); + expect(await handle.readFile("utf8")).toBe("abcdef"); + await expect(handle.writeFile("changed")).rejects.toMatchObject({ + code: "EPERM", + }); + await expect(handle.truncate(0)).rejects.toMatchObject({ code: "EPERM" }); + await handle.close(); + for (const stream of [ + fs.createReadStream("/bundle/readonly"), + new fs.ReadStream("/bundle/readonly"), + ]) { + let text = ""; + for await (const chunk of stream) text += chunk.toString(); + expect(text).toBe("abcdef"); + } + expect(fs.readFileSync("/bundle/readonly", "hex")).toBe("616263646566"); + expect(Buffer.isBuffer(fs.readFileSync("/bundle/readonly"))).toBe(true); + }); +}); + +test("copy and hard links preserve embedded inode write restrictions", async () => { + fs.registerVirtualFiles({ + "/bundle/tree/a": "alpha", + "/bundle/tree/sub/b": "beta", + }); + await runWithTemporaryFiles(async () => { + await fs.promises.cp("/bundle/tree", "/tmp/tree", { recursive: true }); + expect(fs.readFileSync("/tmp/tree/sub/b", "utf8")).toBe("beta"); + fs.writeFileSync("/tmp/tree/a", "copy changed"); + await fs.promises.link("/bundle/tree/a", "/tmp/hard"); + expect(fs.readFileSync("/tmp/hard", "utf8")).toBe("alpha"); + expect(() => fs.writeFileSync("/tmp/hard", "change")).toThrow( + expect.objectContaining({ code: "EPERM" }), + ); + await expect(fs.promises.open("/tmp/hard", "r+")).rejects.toMatchObject({ + code: "EPERM", + }); + expect(fs.statSync("/tmp/hard").mode & 0o777).toBe(0o444); + fs.unlinkSync("/tmp/hard"); + expect(fs.readFileSync("/bundle/tree/a", "utf8")).toBe("alpha"); + expect(() => + fs.cpSync("/bundle/tree/a", "/tmp/filter", { filter: () => true }), + ).toThrow(expect.objectContaining({ code: "ERR_UNSUPPORTED_OPERATION" })); + }); +}); + +test("FileHandle metadata retains its originating volume after request scope exits", async () => { + fs.registerVirtualFiles({ "/bundle/handle-origin": "original" }); + const handles = await runWithTemporaryFiles(async () => { + fs.writeFileSync("/tmp/handle-origin", "temporary"); + return [ + await fs.promises.open("/bundle/handle-origin", "r"), + await fs.promises.open("/tmp/handle-origin", "r"), + ]; + }); + try { + for (const handle of handles) await handle.utimes(1000, 2000); + expect((await handles[0].stat()).mtimeMs).toBe(0); + expect((await handles[0].stat()).mode & 0o777).toBe(0o444); + expect((await handles[1].stat()).mtimeMs).toBe(2000); + expect((await handles[1].stat()).mode & 0o777).toBe(0o666); + } finally { + await Promise.all(handles.map((handle) => handle.close())); + } +}); + +test("streams created in an ended request retain their volume when consumed in another request", async () => { + // Pinned workerd two-request probe: the first stream reads A and writes A; + // request B retains its independent B contents (fs-cross-request.json). + for (const kind of ["direct", "handle"]) { + const held = await runWithTemporaryFiles(async () => { + fs.writeFileSync("/tmp/lifetime-read", "A"); + fs.writeFileSync("/tmp/lifetime-write", "A"); + const run = retainTemporaryFiles(); + const read = + kind === "direct" + ? fs.createReadStream("/tmp/lifetime-read") + : ( + await fs.promises.open("/tmp/lifetime-read", "r") + ).createReadStream(); + const write = + kind === "direct" + ? fs.createWriteStream("/tmp/lifetime-write") + : ( + await fs.promises.open("/tmp/lifetime-write", "w") + ).createWriteStream(); + return { read, write, run }; + }); + await runWithTemporaryFiles(async () => { + fs.writeFileSync("/tmp/lifetime-read", "B"); + fs.writeFileSync("/tmp/lifetime-write", "B"); + const written = new Promise((resolve, reject) => { + held.write.once("error", reject); + held.write.end("changed", () => resolve()); + }); + let text = ""; + for await (const chunk of held.read) text += chunk.toString(); + await written; + expect(text).toBe("A"); + expect(fs.readFileSync("/tmp/lifetime-read", "utf8")).toBe("B"); + expect(fs.readFileSync("/tmp/lifetime-write", "utf8")).toBe("B"); + expect( + held.run(() => fs.readFileSync("/tmp/lifetime-write", "utf8")), + ).toBe("changed"); + }); + } +}); diff --git a/src/compat/fs/writable.ts b/src/compat/fs/writable.ts index a0db6b0..087d36c 100644 --- a/src/compat/fs/writable.ts +++ b/src/compat/fs/writable.ts @@ -1,3 +1,10 @@ +import { + currentDirectory, + runWithDirectory, + registerDirectoryValidator, +} from "../process/directory.js"; +export { currentDirectory, changeDirectory } from "../process/directory.js"; +import * as embedded from "./embedded.js"; import { fileMode } from "./mode.js"; import { registerRequestScope } from "../../shared/request-scope.js"; // Writable, memory-only /tmp. Embedded bundle files remain read-only. @@ -5,35 +12,301 @@ import { registerRequestScope } from "../../shared/request-scope.js"; import { Volume, createFsFromVolume } from "memfs"; import { Buffer } from "node:buffer"; import { posix } from "node:path"; -import { ERR_INVALID_ARG_TYPE } from "../net/errors.js"; +import { ERR_INVALID_ARG_TYPE, ERR_INVALID_ARG_VALUE } from "../net/errors.js"; import { AsyncLocalStorage } from "../async-hooks/index.js"; export function pathOf(value: any): string { - if (value instanceof URL) value = decodeURIComponent(value.pathname); - if (Buffer.isBuffer(value)) value = value.toString(); - if (typeof value !== "string") - throw new ERR_INVALID_ARG_TYPE("path", ["string", "Buffer", "URL"], value); - return posix.resolve("/bundle", value); + let url: URL; + if (value instanceof URL) url = value; + else { + if (Buffer.isBuffer(value)) value = value.toString(); + if (typeof value !== "string") + throw new ERR_INVALID_ARG_TYPE( + "path", + ["string", "Buffer", "URL"], + value, + ); + if (value.includes("\0")) + throw new ERR_INVALID_ARG_VALUE( + "path", + value, + "must not contain null bytes", + ); + url = new URL( + value.replace(/[#?]/g, encodeURIComponent).replace(/\/\//g, "/"), + "file://" + currentDirectory() + "/", + ); + } + if (url.protocol !== "file:") + throw new TypeError("File path must be a file: URL"); + if (url.host) throw new Error("File path must not have a host"); + const normalized = posix.normalize( + url.pathname.replace(/%([0-9a-f]{2})/gi, (_match, hex) => { + const char = String.fromCharCode(parseInt(hex, 16)); + return /[A-Za-z0-9._~-]/.test(char) ? char : "%" + hex.toUpperCase(); + }), + ); + if (Buffer.byteLength(normalized) > 4096) + throw new Error("File path is too long"); + if ((normalized.match(/\//g) || []).length > 48) + throw new Error("File path has too many segments"); + return normalized.length > 1 ? normalized.replace(/\/$/, "") : normalized; } + +const devices = new Set(["/dev/null", "/dev/zero", "/dev/full", "/dev/random"]); export function temporaryPath(value: any): boolean { if (typeof value === "number") return true; const path = pathOf(value); - return path === "/tmp" || path.startsWith("/tmp/"); + return ( + path === "/tmp" || + path.startsWith("/tmp/") || + path === "/dev" || + devices.has(path) + ); } export function writablePath(value: any, syscall: string) { if (typeof value === "number") return value; const path = pathOf(value); - if (!(path === "/tmp" || path.startsWith("/tmp/"))) + if ( + !( + path === "/tmp" || + path.startsWith("/tmp/") || + (devices.has(path) && + [ + "writeFile", + "appendFile", + "truncate", + "utimes", + "lutimes", + "createWriteStream", + "WriteStream", + ].includes(syscall)) + ) + ) throw Object.assign( - new Error(`EROFS: read-only file system, ${syscall} '${path}'`), - { code: "EROFS", errno: -30, syscall, path }, + new Error(`operation not permitted, ${syscall} '${path}'`), + { code: "EPERM", errno: -1, syscall, path }, ); return path; } function createTemporary() { const volume = new Volume(); + // memfs supplies storage, while Workers keeps timestamps unchanged on writes + // and caps every file at 128 MiB. Configure nodes at this owned boundary so + // descriptors, FileHandles and streams cannot bypass these rules. + const core = (volume as any)._core; + let nextFd = 3; + // Workers allocates monotonically within a volume. Reusing a closed number + // would let a stale FileHandle access or close an unrelated descriptor. + core.releasedFds.push = () => 0; + core.newFdNumber = () => { + if (nextFd === 2147483647) + throw Object.assign(new Error("Too many open files"), { code: "EMFILE" }); + return nextFd++; + }; + const createNode = core.createNode.bind(core); + core.createNode = (...args: any[]) => { + const node = createNode(...args); + node.mtime = node.btime = new Date(0); + node.touch = () => {}; + const write = node.write.bind(node), + truncate = node.truncate.bind(node), + set = node._setBuf.bind(node); + const limit = (size: number) => { + if (size > 134217728) + throw Object.assign(new Error("File size limit exceeded"), { + code: "EPERM", + }); + }; + node.write = ( + buffer: any, + offset = 0, + length = buffer.length, + position = 0, + ) => { + limit(position + length); + return write(buffer, offset, length, position); + }; + node.truncate = (length = 0) => { + limit(length); + return truncate(length); + }; + node._setBuf = (buffer: any) => { + limit(buffer.byteLength); + return set(buffer); + }; + return node; + }; + core.root.getNode().mtime = core.root.getNode().btime = new Date(0); volume.mkdirSync("/tmp"); const fs = createFsFromVolume(volume) as any; + function installDevices() { + fs.mkdirSync("/dev"); + for (const path of devices) { + fs.writeFileSync(path, ""); + const node = core.getResolvedLinkOrThrow(path, "open").getNode(); + node.mode = 0o20666; + const rejected = () => { + throw Object.assign(new Error("operation not permitted"), { + code: "EPERM", + }); + }; + node.write = + path === "/dev/full" || path === "/dev/random" + ? rejected + : (_buffer: any, _offset: number, length: number) => length; + node.truncate = + path === "/dev/full" || path === "/dev/random" ? rejected : () => {}; + node._setBuf = + path === "/dev/full" || path === "/dev/random" ? rejected : () => {}; + node.read = ( + buffer: Uint8Array, + offset = 0, + length = buffer.byteLength, + ) => { + if (path === "/dev/null") return 0; + const bytes = new Uint8Array( + buffer.buffer, + buffer.byteOffset + offset, + length, + ); + if (path === "/dev/random") + for (let n = 0; n < bytes.length; n += 65536) + bytes.set( + globalThis.crypto.getRandomValues( + new Uint8Array(Math.min(65536, bytes.length - n)), + ), + n, + ); + else bytes.fill(0); + return length; + }; + } + } + installDevices(); + for (let fd = 0; fd < 3; fd++) { + const path = "/tmp/.stdio-" + fd, + allocated = fs.openSync(path, "w+"); + const file = core.getFileByFdOrThrow(allocated, "stdio"); + delete core.fds[String(allocated)]; + file.fd = fd; + core.fds[String(fd)] = file; + fs.unlinkSync(path); + let pending = ""; + file.node.read = () => 0; + file.node.write = ( + buffer: Uint8Array, + offset = 0, + length = buffer.byteLength, + ) => { + pending += Buffer.from( + buffer.buffer, + buffer.byteOffset + offset, + length, + ).toString(); + const lines = pending.split("\n"); + pending = lines.pop()!; + for (const line of lines) (fd === 2 ? console.error : console.log)(line); + return length; + }; + } + nextFd = 3; + const closeSync = fs.closeSync.bind(fs), + close = fs.close.bind(fs); + fs.closeSync = (fd: any) => { + if (typeof fd === "number" && fd >= 0 && fd <= 2 && Number.isInteger(fd)) + return; + return closeSync(fd); + }; + fs.close = (fd: any, callback: any) => { + if (typeof fd === "number" && fd >= 0 && fd <= 2 && Number.isInteger(fd)) { + if (typeof callback !== "function") + throw new ERR_INVALID_ARG_TYPE("callback", "function", callback); + queueMicrotask(() => callback(null)); + return; + } + return close(fd, callback); + }; + const readonlyDescriptors = new Set(); + const readonlyInodes = new Set(); + const denied = (syscall: string, path?: string) => + Object.assign(new Error("operation not permitted"), { + code: "EPERM", + errno: -1, + syscall, + ...(path ? { path } : {}), + }); + function mount(path: string) { + if ( + temporaryPath(path) || + (fs.existsSync(path) && fs.statSync(path).isFile()) + ) + return; + let stat; + try { + stat = embedded.statSync(path); + } catch (error) { + if ((error as any).code === "ENOENT") return; + throw error; + } + if (stat.isDirectory()) { + fs.mkdirSync(path, { recursive: true }); + for (const name of embedded.readdirSync(path) as string[]) + mount(posix.join(path, name)); + } else { + fs.mkdirSync(posix.dirname(path), { recursive: true }); + fs.writeFileSync(path, embedded.readFileSync(path)); + readonlyInodes.add(fs.statSync(path).ino); + } + } + function resolved(path: string): string { + let parent = path; + const missing = []; + while (!fs.existsSync(parent) && parent !== "/") { + missing.unshift(posix.basename(parent)); + parent = posix.dirname(parent); + } + return posix.join(fs.realpathSync(parent), ...missing); + } + function mutable(value: any, syscall: string, follow = true) { + if (typeof value === "number") { + if (readonlyDescriptors.has(value)) throw denied(syscall); + return value; + } + const path = writablePath(value, syscall) as string; + const target = follow + ? resolved(path) + : posix.join(resolved(posix.dirname(path)), posix.basename(path)); + if ( + !temporaryPath(target) || + (follow && + fs.existsSync(target) && + readonlyInodes.has(fs.statSync(target).ino)) + ) + throw denied(syscall, path); + return path; + } + function openPath(value: any, flags: any = "r") { + const path = pathOf(value); + mount(path); + const target = resolved(path); + const writes = + typeof flags === "number" + ? !!( + flags & + (fs.constants.O_WRONLY | + fs.constants.O_RDWR | + fs.constants.O_CREAT | + fs.constants.O_TRUNC | + fs.constants.O_APPEND) + ) + : typeof flags === "string" && !["r", "rs", "sr"].includes(flags); + const readonly = + !temporaryPath(target) || + (fs.existsSync(target) && readonlyInodes.has(fs.statSync(target).ino)); + if (readonly && writes) throw denied("open", path); + return { path, readonly }; + } const mutating = new Set([ "appendFile", "writeFile", @@ -80,7 +353,7 @@ function createTemporary() { }; } if (base === "symlink") { - args[1] = writablePath(args[1], base); + args[1] = mutable(args[1], base, false); const target = Buffer.isBuffer(args[0]) ? args[0].toString() : args[0]; @@ -90,16 +363,22 @@ function createTemporary() { ["string", "Buffer"], target, ); - writablePath(posix.resolve(posix.dirname(args[1]), target), base); - } else if (["rename", "copyFile", "cp", "link"].includes(base)) { - args[0] = writablePath(args[0], base); - args[1] = writablePath(args[1], base); - } else if ( - mutating.has(base) || - base === "open" || - base === "createWriteStream" - ) - args[0] = writablePath(args[0], base); + const absolute = pathOf(target); + mount(absolute); + args[0] = absolute; + } else if (["copyFile", "cp", "link"].includes(base)) { + args[0] = pathOf(args[0]); + mount(args[0]); + args[1] = mutable(args[1], base, base !== "link"); + } else if (base === "rename") { + args[0] = mutable(args[0], base, false); + args[1] = mutable(args[1], base, false); + } else if (mutating.has(base) || base === "createWriteStream") + args[0] = mutable( + args[0], + base, + !["unlink", "rm", "rmdir", "mkdir"].includes(base), + ); else if ( typeof args[0] === "string" || args[0] instanceof URL || @@ -114,14 +393,159 @@ function createTemporary() { } throw error; } + if ( + ["write", "writev", "ftruncate"].includes(base) && + readonlyDescriptors.has(args[0]) + ) { + const error = denied(base); + const callback = args[args.length - 1]; + if (!name.endsWith("Sync") && typeof callback === "function") { + queueMicrotask(() => callback(error)); + return; + } + throw error; + } + if ( + base === "read" && + typeof args[2] === "number" && + args[3] === undefined && + args[1]?.byteLength !== undefined + ) + args[3] = args[1].byteLength - args[2]; + if (base === "close") readonlyDescriptors.delete(args[0]); return fs[name](...args); }; } + api.openSync = (path: any, flags: any = "r", mode?: any) => { + if (typeof flags === "number" && !(flags & 3)) flags &= ~512; + const entry = openPath(path, flags); + const fd = fs.openSync(entry.path, flags, mode); + if (entry.readonly) readonlyDescriptors.add(fd); + return fd; + }; + api.open = (path: any, flags: any, mode: any, callback?: any) => { + if (typeof mode === "function") { + callback = mode; + mode = undefined; + } + let fd: number | undefined, + error: unknown = null; + try { + fd = api.openSync(path, flags, mode); + } catch (failure) { + error = failure; + } + queueMicrotask(() => callback(error, fd)); + }; + api.createReadStream = (path: any, options?: any) => { + if (typeof options?.fd === "number") + return fs.createReadStream(path, options); + const entry = openPath(path, options?.flags); + const stream = fs.createReadStream(entry.path, options); + if (entry.readonly) { + stream.prependListener("open", (fd: number) => + readonlyDescriptors.add(fd), + ); + let fd: number | undefined; + stream.on("open", (value: number) => { + fd = value; + }); + stream.on("close", () => { + if (fd !== undefined) readonlyDescriptors.delete(fd); + }); + } + return stream; + }; + api.promises = { + ...fs.promises, + open: async (path: any, flags: any = "r", mode?: any) => { + if (typeof flags === "number" && !(flags & 3)) flags &= ~512; + const entry = openPath(path, flags); + const handle = await fs.promises.open(entry.path, flags, mode); + handle.readFile = async (options: any = {}) => { + const encoding = + typeof options === "string" ? options : options?.encoding; + const size = (await handle.stat()).size; + const buffer = Buffer.alloc(size); + const { bytesRead } = await handle.read(buffer, 0, size, 0); + core.getFileByFdOrThrow(handle.fd, "readFile").position = size; + return encoding + ? buffer.subarray(0, bytesRead).toString(encoding) + : buffer.subarray(0, bytesRead); + }; + for (const name of [ + "write", + "writev", + "writeFile", + "appendFile", + "truncate", + ]) { + const operation = handle[name].bind(handle); + handle[name] = async (...args: any[]) => { + await handle.stat(); + if (entry.readonly) throw denied(name); + return operation(...args); + }; + } + if (entry.readonly) { + readonlyDescriptors.add(handle.fd); + const close = handle.close.bind(handle); + handle.close = async () => { + readonlyDescriptors.delete(handle.fd); + return close(); + }; + } + return handle; + }, + }; + for (const name of [ + ...mutating, + "rename", + "copyFile", + "cp", + "link", + "symlink", + ]) + api.promises[name] = (...args: any[]) => + new Promise((resolve, reject) => + api[name](...args, (error: unknown, value: unknown) => + error ? reject(error) : resolve(value), + ), + ); return { + descriptorFlags(fd: number) { + return core.getFileByFdOrThrow(fd, "write").flags; + }, + readDescriptor(fd: number) { + const file = core.getFileByFdOrThrow(fd, "read"); + const data = Buffer.from(file.node.getBuffer()); + file.position = data.length; + return data; + }, api, + statEmbedded(path: any, options?: any) { + const normalized = pathOf(path); + mount(normalized); + return fs.statSync(normalized, options); + }, + readonly(value: any, follow = true) { + if (typeof value === "number") return readonlyDescriptors.has(value); + const path = pathOf(value); + const target = follow ? resolved(path) : path; + return ( + target === "/dev" || + !temporaryPath(target) || + (fs.existsSync(target) && + readonlyInodes.has((follow ? fs.statSync : fs.lstatSync)(target).ino)) + ); + }, clear() { - for (const name of fs.readdirSync("/tmp")) - fs.rmSync(`/tmp/${name}`, { recursive: true, force: true }); + for (const name of fs.readdirSync("/")) + fs.rmSync(`/${name}`, { recursive: true, force: true }); + fs.mkdirSync("/tmp"); + installDevices(); + readonlyDescriptors.clear(); + readonlyInodes.clear(); }, }; } @@ -147,7 +571,41 @@ export function clearTemporaryFiles() { active().clear(); } export function runWithTemporaryFiles(callback: () => T): T { - return contexts.run(createTemporary(), callback); + return runWithDirectory(() => contexts.run(createTemporary(), callback)); } registerRequestScope(runWithTemporaryFiles); + +/** Retain the originating virtual volume for descriptor operations. */ +export function retainTemporaryFiles() { + const volume = active(); + return (operation: () => T): T => contexts.run(volume, operation); +} + +export function readonlyFile(value: any, follow = true): boolean { + return active().readonly(value, follow); +} + +export function embeddedStat(path: any, options?: any): any { + return active().statEmbedded(path, options); +} + +registerDirectoryValidator((path) => { + const normalized = pathOf(path); + const stat = active().statEmbedded(normalized); + if (!stat.isDirectory()) + throw Object.assign(new Error("not a directory"), { + code: "ENOTDIR", + syscall: "chdir", + path: normalized, + }); + return normalized; +}); + +export function readDescriptor(fd: number): Buffer { + return active().readDescriptor(fd); +} + +export function descriptorFlags(fd: number): number { + return active().descriptorFlags(fd); +} diff --git a/src/compat/http-outgoing/index.ts b/src/compat/http-outgoing/index.ts index 65232d9..f3178d5 100644 --- a/src/compat/http-outgoing/index.ts +++ b/src/compat/http-outgoing/index.ts @@ -16,25 +16,8 @@ export { kUniqueHeaders, kHighWaterMark, }; -export function parseUniqueHeadersOption( - headers?: (string | string[])[], -): Set | null { - if (!Array.isArray(headers)) { - return null; - } - - const unique = new Set(); - for (const header of headers) { - if (Array.isArray(header)) { - for (const h of header) { - unique.add(h.toLowerCase()); - } - } else { - unique.add(header.toLowerCase()); - } - } - return unique; -} +import { parseUniqueHeadersOption } from "../http/unique-headers.js"; +export { parseUniqueHeadersOption }; export default { OutgoingMessage, diff --git a/src/compat/http-server/index.ts b/src/compat/http-server/index.ts index 1321f3c..65f515b 100644 --- a/src/compat/http-server/index.ts +++ b/src/compat/http-server/index.ts @@ -1,3 +1,4 @@ +import { storeHTTPOptions } from "../http/server-options.js"; // Copyright (c) 2017-2022 Cloudflare, Inc. // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 @@ -6,24 +7,13 @@ import { Server, ServerResponse, - IncomingMessage, STATUS_CODES, _connectionListener, } from "../http/index.js"; import { - kIncomingMessage, kServerResponse, kConnectionsCheckingInterval, } from "../http/symbols.js"; -import { validateInteger } from "../crypto/validation.js"; -import { validateBoolean } from "../net/validators.js"; -import { ERR_OUT_OF_RANGE } from "../net/errors.js"; -class ERR_OPTION_NOT_IMPLEMENTED extends Error { - code = "ERR_OPTION_NOT_IMPLEMENTED"; - constructor(option: string) { - super(`The ${option} option is not implemented`); - } -} export { Server, ServerResponse, @@ -38,99 +28,7 @@ export function setupConnectionsTracking(): never { { code: "ERR_METHOD_NOT_IMPLEMENTED" }, ); } -export function storeHTTPOptions(this: any, options: any): void { - this[kIncomingMessage] = options.IncomingMessage || IncomingMessage; - this[kServerResponse] = options.ServerResponse || ServerResponse; - - const maxHeaderSize = options.maxHeaderSize; - if (maxHeaderSize !== undefined) { - validateInteger(maxHeaderSize, "maxHeaderSize", 0); - throw new ERR_OPTION_NOT_IMPLEMENTED("maxHeaderSize"); - } - - const insecureHTTPParser = options.insecureHTTPParser; - if (insecureHTTPParser !== undefined) { - // If enabled it will use a HTTP parser with leniency flags enabled. - // Since our implementation does not use any http parser, and uses "fetch" API, - // it doesn't make sense to support this option. - validateBoolean(insecureHTTPParser, "options.insecureHTTPParser"); - } - - const requestTimeout = options.requestTimeout; - if (requestTimeout !== undefined) { - validateInteger(requestTimeout, "requestTimeout", 0); - this.requestTimeout = requestTimeout; - } else { - this.requestTimeout = 300_000; // 5 minutes - } - - const headersTimeout = options.headersTimeout; - if (headersTimeout !== undefined) { - validateInteger(headersTimeout, "headersTimeout", 0); - this.headersTimeout = headersTimeout; - } else { - this.headersTimeout = Math.min(60_000, this.requestTimeout); // Minimum between 60 seconds or requestTimeout - } - - if ( - this.requestTimeout > 0 && - this.headersTimeout > 0 && - this.headersTimeout > this.requestTimeout - ) { - throw new ERR_OUT_OF_RANGE( - "headersTimeout", - "<= requestTimeout", - headersTimeout, - ); - } - - const keepAliveTimeout = options.keepAliveTimeout; - if (keepAliveTimeout !== undefined) { - validateInteger(keepAliveTimeout, "keepAliveTimeout", 0); - throw new ERR_OPTION_NOT_IMPLEMENTED("keepAliveTimeout"); - } - - const keepAliveTimeoutBuffer = options.keepAliveTimeoutBuffer; - if (keepAliveTimeoutBuffer !== undefined) { - validateInteger(keepAliveTimeoutBuffer, "keepAliveTimeoutBuffer"); - throw new ERR_OPTION_NOT_IMPLEMENTED("keepAliveTimeoutBuffer"); - } - - const connectionsCheckingInterval = options.connectionsCheckingInterval; - if (connectionsCheckingInterval !== undefined) { - validateInteger( - connectionsCheckingInterval, - "connectionsCheckingInterval", - 0, - ); - throw new ERR_OPTION_NOT_IMPLEMENTED("connectionsCheckingInterval"); - } - - const requireHostHeader = options.requireHostHeader; - if (requireHostHeader !== undefined) { - validateBoolean(requireHostHeader, "options.requireHostHeader"); - this.requireHostHeader = requireHostHeader; - } else { - this.requireHostHeader = true; - } - - const joinDuplicateHeaders = options.joinDuplicateHeaders; - if (joinDuplicateHeaders !== undefined) { - validateBoolean(joinDuplicateHeaders, "options.joinDuplicateHeaders"); - } - this.joinDuplicateHeaders = joinDuplicateHeaders ?? false; - - const rejectNonStandardBodyWrites = options.rejectNonStandardBodyWrites; - if (rejectNonStandardBodyWrites !== undefined) { - validateBoolean( - rejectNonStandardBodyWrites, - "options.rejectNonStandardBodyWrites", - ); - this.rejectNonStandardBodyWrites = rejectNonStandardBodyWrites; - } else { - this.rejectNonStandardBodyWrites = false; - } -} +export { storeHTTPOptions }; export function httpServerPreClose(server: any) { server.closeIdleConnections(); @@ -144,6 +42,5 @@ export default { kConnectionsCheckingInterval, _connectionListener, setupConnectionsTracking, - storeHTTPOptions, httpServerPreClose, }; diff --git a/src/compat/http/index.ts b/src/compat/http/index.ts index 5de569c..f781a0f 100644 --- a/src/compat/http/index.ts +++ b/src/compat/http/index.ts @@ -1,10 +1,11 @@ +import { storeHTTPOptions } from "./server-options.js"; import { asyncDispose } from "../../shared/globals/symbols.js"; // Copyright (c) 2017-2026 Cloudflare, Inc. // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 // Copyright Joyent and Node contributors. All rights reserved. MIT license. // Adapted from workerd v1.20261012.1 node:http compatibility sources. -import { Readable, Writable } from "node:stream"; +import { Readable, Writable, getDefaultHighWaterMark } from "node:stream"; import { EventEmitter } from "node:events"; import { Buffer } from "node:buffer"; import { @@ -25,6 +26,22 @@ import type { ServerOptions, } from "node:http"; +import { + validateInteger, + validateString, + validateBoolean, + validateNumber, + validateObject, + validateOneOf, +} from "../net/validators.js"; +import { AbortError, ERR_INVALID_ARG_TYPE } from "../net/errors.js"; +import { + kIncomingMessage, + kServerResponse, + kUniqueHeaders, +} from "./symbols.js"; +import { parseUniqueHeadersOption } from "./unique-headers.js"; + export const METHODS = [ "ACL", "BIND", @@ -133,20 +150,7 @@ function coded(error: T, code: string): T { return Object.assign(error, { code }); } function validateType(value: unknown, type: string, name: string) { - if (typeof value !== type) - throw coded( - new TypeError(`${name} must be of type ${type}`), - "ERR_INVALID_ARG_TYPE", - ); -} -function validateInteger( - value: unknown, - name: string, - minimum = Number.MIN_SAFE_INTEGER, -) { - validateType(value, "number", name); - if (!Number.isSafeInteger(value) || (value as number) < minimum) - throw coded(new RangeError(`${name} is out of range`), "ERR_OUT_OF_RANGE"); + if (typeof value !== type) throw new ERR_INVALID_ARG_TYPE(name, type, value); } function unsupportedOption(name: string): never { throw coded( @@ -160,30 +164,6 @@ function notImplemented(name: string): Error { "ERR_METHOD_NOT_IMPLEMENTED", ); } -function validateString(value: unknown, name: string): asserts value is string { - if (typeof value !== "string") - throw coded( - new TypeError(`The "${name}" argument must be of type string.`), - "ERR_INVALID_ARG_TYPE", - ); -} -function validateBoolean( - value: unknown, - name: string, -): asserts value is boolean { - if (typeof value !== "boolean") - throw coded( - new TypeError(`The "${name}" argument must be of type boolean.`), - "ERR_INVALID_ARG_TYPE", - ); -} -function validateNumber(value: unknown, name: string): asserts value is number { - if (typeof value !== "number") - throw coded( - new TypeError(`The "${name}" argument must be of type number.`), - "ERR_INVALID_ARG_TYPE", - ); -} export function validateHeaderName( name: unknown, label = "Header name", @@ -216,6 +196,7 @@ export class OutgoingMessage extends Writable { strictContentLength = false; private bytesWritten = 0; private endingChunk = false; + #rejectNonStandardBodyWrites = false; chunkedEncoding = false; sendDate = false; shouldKeepAlive = true; @@ -241,9 +222,14 @@ export class OutgoingMessage extends Writable { constructor( _req?: IncomingMessage, - options: { highWaterMark?: number } = {}, + options: { + highWaterMark?: number; + rejectNonStandardBodyWrites?: boolean; + } = {}, ) { super({ autoDestroy: false, highWaterMark: options.highWaterMark }); + this.#rejectNonStandardBodyWrites = + options.rejectNonStandardBodyWrites ?? false; } get headersSent() { return this._header !== null || this._headerSent; @@ -372,6 +358,18 @@ export class OutgoingMessage extends Writable { ); this.bytesWritten = total; } + #checkBodyAllowed() { + if ( + !this._hasBody && + this.#rejectNonStandardBodyWrites && + !this.finished && + !this.destroyed + ) + throw coded( + new Error("Request with GET/HEAD method cannot have body"), + "ERR_HTTP_BODY_NOT_ALLOWED", + ); + } override write( chunk: unknown, encoding?: unknown, @@ -390,6 +388,7 @@ export class OutgoingMessage extends Writable { if (!this.endingChunk) this.#checkContentLength(chunk, encoding, false); if (!this._header && !this.finished && !this.destroyed) this._implicitHeader(); + this.#checkBodyAllowed(); return super.write(chunk as never, encoding as never, callback as never); } override destroy(error?: Error): this { @@ -453,10 +452,29 @@ export class OutgoingMessage extends Writable { !this.destroyed ) this._implicitHeader(); + if ( + chunk && + typeof chunk !== "function" && + !this.finished && + !this.destroyed + ) { + if (typeof chunk !== "string" && !(chunk instanceof Uint8Array)) + throw new ERR_INVALID_ARG_TYPE( + "chunk", + ["string", "Buffer", "Uint8Array"], + chunk, + ); + if (!this._header) this._implicitHeader(); + this.#checkBodyAllowed(); + } this.#checkContentLength(chunk, encoding, true); this.endingChunk = true; try { - super.end(chunk as never, encoding as never, callback as never); + super.end( + (chunk || undefined) as never, + encoding as never, + callback as never, + ); } finally { this.endingChunk = false; } @@ -722,7 +740,11 @@ export class IncomingMessage extends Readable { }, (error) => { this.reading = false; - this.destroy(error); + this.destroy( + this.idleTimer && error instanceof TypeError + ? new Error(error.message) + : error, + ); }, ); } @@ -764,6 +786,14 @@ export class ServerResponse< super(req, options); this.req = req; this.socket = req.socket; + this.once("finish", () => + queueMicrotask(() => { + if (this._closed) return; + this.destroyed = true; + this._closed = true; + this.emit("close"); + }), + ); this.body = new ReadableStream({ start: (controller) => { this.controller = controller; @@ -909,6 +939,7 @@ const captureRejection: typeof EventEmitter.captureRejectionSymbol = Symbol.for( "nodejs.rejection", )) as typeof EventEmitter.captureRejectionSymbol; + export class Server extends EventEmitter { httpAllowHalfOpen = false; timeout = 0; @@ -922,10 +953,11 @@ export class Server extends EventEmitter { rejectNonStandardBodyWrites = false; keepAliveTimeout = 5_000; keepAliveTimeoutBuffer = 1_000; - highWaterMark = 16 * 1024; + highWaterMark = getDefaultHighWaterMark(false); private port?: number; - private IncomingMessageClass: typeof IncomingMessage = IncomingMessage; - private ServerResponseClass: typeof ServerResponse = ServerResponse; + [kIncomingMessage]: typeof IncomingMessage = IncomingMessage; + [kServerResponse]: typeof ServerResponse = ServerResponse; + [kUniqueHeaders]: Set | null = null; constructor( options?: ServerOptions | RequestListener, @@ -953,73 +985,22 @@ export class Server extends EventEmitter { } return listeners.length !== 0; }) as typeof this.emit; - if (typeof options === "function") listener = options; - else if (options != null) { - if (typeof options !== "object") - throw coded( - new TypeError('The "options" argument must be of type object.'), - "ERR_INVALID_ARG_TYPE", - ); - if (options.maxHeaderSize !== undefined) { - validateInteger(options.maxHeaderSize, "maxHeaderSize", 0); - unsupportedOption("maxHeaderSize"); - } - for (const key of ["requestTimeout", "headersTimeout"] as const) { - if (options[key] !== undefined) { - validateInteger(options[key], key, 0); - this[key] = options[key]; - } - } - this.headersTimeout = - options.headersTimeout ?? Math.min(60_000, this.requestTimeout); - if ( - this.headersTimeout > 0 && - this.requestTimeout > 0 && - this.headersTimeout > this.requestTimeout - ) - throw coded( - new RangeError("headersTimeout must be <= requestTimeout"), - "ERR_OUT_OF_RANGE", - ); - for (const key of [ - "keepAliveTimeout", - "keepAliveTimeoutBuffer", - "connectionsCheckingInterval", - ] as const) { - if (options[key] !== undefined) { - validateInteger( - options[key], - key, - key === "keepAliveTimeoutBuffer" ? Number.MIN_SAFE_INTEGER : 0, - ); - unsupportedOption(key); - } - } - for (const key of [ - "insecureHTTPParser", - "requireHostHeader", - "joinDuplicateHeaders", - "rejectNonStandardBodyWrites", - ] as const) - if (options[key] !== undefined) - validateType(options[key], "boolean", key); - this.requireHostHeader = options.requireHostHeader ?? true; - this.joinDuplicateHeaders = options.joinDuplicateHeaders ?? false; - this.rejectNonStandardBodyWrites = - options.rejectNonStandardBodyWrites ?? false; - if (options.highWaterMark !== undefined) { - validateType(options.highWaterMark, "number", "highWaterMark"); - if (options.highWaterMark > 0) - this.highWaterMark = options.highWaterMark; - } - if (options.IncomingMessage) - this.IncomingMessageClass = - options.IncomingMessage as unknown as typeof IncomingMessage; - if (options.ServerResponse) - this.ServerResponseClass = - options.ServerResponse as unknown as typeof ServerResponse; + if (options != null) storeHTTPOptions.call(this, options); + const values = options as ServerOptions | undefined; + if (values?.highWaterMark !== undefined) { + validateNumber(values.highWaterMark, "options.highWaterMark"); + if (values.highWaterMark > 0) this.highWaterMark = values.highWaterMark; + } + if (typeof options === "function") { + listener = options; + options = {}; + } else if (options == null) { + options = {}; + } else { + validateObject(options, "options"); } if (listener) this.on("request", listener); + this[kUniqueHeaders] = parseUniqueHeadersOption(options.uniqueHeaders); } [captureRejection]( error: unknown, @@ -1074,8 +1055,8 @@ export class Server extends EventEmitter { setHandler(port, { server: this, createRequestResponse: () => ({ - IncomingMessage: this.IncomingMessageClass, - ServerResponse: this.ServerResponseClass, + IncomingMessage: this[kIncomingMessage], + ServerResponse: this[kServerResponse], }), }); const callback = @@ -1168,20 +1149,9 @@ export class Agent extends EventEmitter { this.maxSockets = this.options.maxSockets || Agent.defaultMaxSockets; this.maxFreeSockets = this.options.maxFreeSockets || 256; this.scheduling = this.options.scheduling || "lifo"; - if (!(["fifo", "lifo"] as unknown[]).includes(this.scheduling)) - throw coded( - new TypeError("options.scheduling is invalid"), - "ERR_INVALID_ARG_VALUE", - ); + validateOneOf(this.scheduling, "scheduling", ["fifo", "lifo"]); const maximum = this.options.maxTotalSockets; - if (maximum !== undefined) { - validateNumber(maximum, "maxTotalSockets"); - if (Number.isNaN(maximum) || maximum < 1) - throw coded( - new RangeError("maxTotalSockets is out of range"), - "ERR_OUT_OF_RANGE", - ); - } + if (maximum !== undefined) validateNumber(maximum, "maxTotalSockets", 1); this.maxTotalSockets = maximum === undefined ? Infinity : maximum; } createConnection() { @@ -1231,8 +1201,7 @@ export class ClientRequest extends OutgoingMessage { timeout?: number; _ended = false; private abortController = new AbortController(); - private upload?: TransformStream; - private uploadWriter?: WritableStreamDefaultWriter; + private upload: Uint8Array[] = []; private response?: IncomingMessage; private timer?: ReturnType; private sent = false; @@ -1375,13 +1344,12 @@ export class ClientRequest extends OutgoingMessage { `Basic ${Buffer.from(values.auth).toString("base64")}`, ); if (values.signal) { - if (values.signal.aborted) this.destroy(values.signal.reason); - else - values.signal.addEventListener( - "abort", - () => this.destroy(values.signal!.reason), - { once: true }, + const onAbort = () => + this.destroy( + new AbortError(undefined, { cause: values.signal!.reason }), ); + if (values.signal.aborted) onAbort(); + else values.signal.addEventListener("abort", onAbort, { once: true }); } if (values.timeout !== undefined) this.setTimeout(values.timeout); if (callback) this.once("response", callback); @@ -1397,7 +1365,11 @@ export class ClientRequest extends OutgoingMessage { encoding?: unknown, callback?: unknown, ): boolean { - return super.write(chunk, encoding, callback); + return super.write( + chunk instanceof Uint8Array ? Buffer.from(chunk) : chunk, + encoding, + callback, + ); } override _write( chunk: Buffer | string | Uint8Array, @@ -1408,36 +1380,13 @@ export class ClientRequest extends OutgoingMessage { callback(); return; } - try { - if (!this.upload) { - this.upload = new TransformStream(); - this.uploadWriter = this.upload.writable.getWriter(); - } - this.#send(); - void this.uploadWriter!.write(Buffer.from(chunk as never, encoding)).then( - () => callback(), - (error) => { - this.destroy(error); - callback(error); - }, - ); - } catch (error) { - this.destroy(error as Error); - callback(error as Error); - } + this.upload.push(Buffer.from(chunk as never, encoding)); + callback(); } override _final = (callback: (error?: Error | null) => void) => { try { this.#send(); - if (this.uploadWriter) - void this.uploadWriter.close().then( - () => callback(), - (error) => { - this.destroy(error); - callback(error); - }, - ); - else callback(); + callback(); } catch (error) { this.destroy(error as Error); callback(error as Error); @@ -1445,11 +1394,6 @@ export class ClientRequest extends OutgoingMessage { }; override flushHeaders = () => { super.flushHeaders(); - if (this.method !== "GET" && this.method !== "HEAD" && !this.upload) { - this.upload = new TransformStream(); - this.uploadWriter = this.upload.writable.getWriter(); - } - this.#send(); }; #send() { if (this.destroyed || this.sent) return; @@ -1482,7 +1426,9 @@ export class ClientRequest extends OutgoingMessage { : value : [value]) headers.push([name, String(item)]); - const body = this.upload?.readable ?? null; + const body = this.upload.length + ? new Blob(this.upload as BlobPart[]) + : null; const response = missingHost ? Promise.resolve( new Response(null, { @@ -1564,10 +1510,8 @@ export class ClientRequest extends OutgoingMessage { this.destroy(coded(new Error("The operation was aborted"), "ABORT_ERR")); }, this.timeout); } - #releaseUpload(error = new Error("Request closed")) { - void this.uploadWriter?.abort(error).catch(() => {}); - this.uploadWriter = undefined; - this.upload = undefined; + #releaseUpload(_error?: Error) { + this.upload = []; } #emitClose() { if (!this._closed) { diff --git a/src/compat/http/network.spec.mjs b/src/compat/http/network.spec.mjs new file mode 100644 index 0000000..6c14cc4 --- /dev/null +++ b/src/compat/http/network.spec.mjs @@ -0,0 +1,158 @@ +// Shared between pinned workerd and the installed native adapter. +export async function runNetworkLifecycle({ http, https }, ports) { + const results = {}; + const failure = (error) => ({ name: error.name, code: error.code ?? null }); + for (const [scheme, api, port] of [ + ["http", http, ports.http], + ["https", https, ports.https], + ]) { + const base = `${scheme}://127.0.0.1:${port}`; + for (const kind of [ + "head", + "204", + "304", + "premature", + "redirect", + "upload", + "upload-snapshot", + "upload-cork-snapshot", + ...(scheme === "https" ? ["hostname-mismatch"] : []), + ]) { + results[`${scheme}/${kind}`] = await new Promise((resolve, reject) => { + let body = "", + response; + const events = []; + const request = api.request( + `${kind === "hostname-mismatch" ? `https://127.0.0.1:${ports.mismatchHttps}` : base}/${kind}`, + { + method: + kind === "head" + ? "HEAD" + : kind.startsWith("upload") + ? "POST" + : "GET", + }, + (incoming) => { + response = incoming; + incoming.on("aborted", () => events.push("aborted")); + incoming.on("data", (chunk) => { + body += chunk.toString(); + }); + incoming.on("error", (error) => events.push(failure(error))); + incoming.on("close", () => { + clearTimeout(timer); + resolve({ + status: incoming.statusCode, + body, + complete: incoming.complete, + aborted: incoming.aborted, + cookies: incoming.headers["set-cookie"] ?? null, + distinctCookies: incoming.headersDistinct["set-cookie"] ?? null, + rawCookies: incoming.rawHeaders.filter( + (value, index) => + index % 2 === 0 && value.toLowerCase() === "set-cookie", + ).length, + location: incoming.headers.location ?? null, + events, + }); + }); + }, + ); + const timer = setTimeout(() => { + request.destroy(); + response?.destroy(); + reject(new Error(`Network lifecycle timeout: ${scheme}/${kind}`)); + }, 8000); + request.on("error", (error) => { + if (response) events.push(failure(error)); + else { + clearTimeout(timer); + resolve({ error: failure(error) }); + } + }); + if (kind === "upload") request.write("one"); + if (kind.endsWith("snapshot")) { + if (kind === "upload-cork-snapshot") request.cork(); + const chunk = new Uint8Array([65]); + request.write(chunk); + chunk[0] = 66; + if (kind === "upload-cork-snapshot") request.uncork(); + } + request.end( + kind === "upload" + ? "two" + : kind === "upload-snapshot" + ? "C" + : undefined, + ); + }); + } + for (const phase of ["before", "upload", "response"]) { + results[`${scheme}/abort-${phase}`] = await new Promise( + (resolve, reject) => { + const controller = new AbortController(), + reason = new Error("controlled-abort"), + events = []; + let response, + writeCallbacks = 0; + if (phase === "before") controller.abort(reason); + const request = api.request( + `${base}/${phase === "response" ? "pending-response" : "pending-upload"}`, + { + method: phase === "upload" ? "POST" : "GET", + signal: controller.signal, + }, + (incoming) => { + response = incoming; + incoming.on("error", (error) => + events.push(["response-error", failure(error)]), + ); + incoming.on("aborted", () => events.push(["aborted"])); + incoming.resume(); + if (phase === "response") controller.abort(reason); + }, + ); + const timer = setTimeout(() => { + request.destroy(); + response?.destroy(); + reject( + new Error( + `Abort lifecycle timeout: ${scheme}/${phase} ${JSON.stringify({ events, writeCallbacks, destroyed: request.destroyed, closed: request._closed, response: response ? { aborted: response.aborted, complete: response.complete, destroyed: response.destroyed } : null })}`, + ), + ); + }, 8000); + request.on("error", (error) => + events.push([ + "request-error", + failure(error), + error.cause === reason, + ]), + ); + request.on("close", () => { + clearTimeout(timer); + resolve({ + events, + writeCallbacks, + destroyed: request.destroyed, + response: response + ? { + aborted: response.aborted, + complete: response.complete, + destroyed: response.destroyed, + } + : null, + }); + }); + if (phase === "upload") { + request.write("first", (error) => { + writeCallbacks++; + events.push(["write", error ? failure(error) : null]); + }); + controller.abort(reason); + } else request.end(); + }, + ); + } + } + return results; +} diff --git a/src/compat/http/server-options.ts b/src/compat/http/server-options.ts new file mode 100644 index 0000000..fe8beca --- /dev/null +++ b/src/compat/http/server-options.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// Copyright Joyent and Node contributors. All rights reserved. MIT license. + +// Shared by node:http and node:_http_server; read options in pinned Workers order. +import { IncomingMessage, ServerResponse } from "./index.js"; +import { kIncomingMessage, kServerResponse } from "./symbols.js"; +import { validateInteger, validateBoolean } from "../net/validators.js"; +import { ERR_OUT_OF_RANGE } from "../net/errors.js"; +function unsupportedOption(name: string): never { + throw Object.assign(new Error(`The ${name} option is not implemented`), { + code: "ERR_OPTION_NOT_IMPLEMENTED", + }); +} +export function storeHTTPOptions(this: any, options: any): void { + this[kIncomingMessage] = options.IncomingMessage || IncomingMessage; + this[kServerResponse] = options.ServerResponse || ServerResponse; + + const maxHeaderSize = options.maxHeaderSize; + if (maxHeaderSize !== undefined) { + validateInteger(maxHeaderSize, "maxHeaderSize", 0); + unsupportedOption("maxHeaderSize"); + } + + const insecureHTTPParser = options.insecureHTTPParser; + if (insecureHTTPParser !== undefined) { + // If enabled it will use a HTTP parser with leniency flags enabled. + // Since our implementation does not use any http parser, and uses "fetch" API, + // it doesn't make sense to support this option. + validateBoolean(insecureHTTPParser, "options.insecureHTTPParser"); + } + + const requestTimeout = options.requestTimeout; + if (requestTimeout !== undefined) { + validateInteger(requestTimeout, "requestTimeout", 0); + this.requestTimeout = requestTimeout; + } else { + this.requestTimeout = 300_000; // 5 minutes + } + + const headersTimeout = options.headersTimeout; + if (headersTimeout !== undefined) { + validateInteger(headersTimeout, "headersTimeout", 0); + this.headersTimeout = headersTimeout; + } else { + this.headersTimeout = Math.min(60_000, this.requestTimeout); // Minimum between 60 seconds or requestTimeout + } + + if ( + this.requestTimeout > 0 && + this.headersTimeout > 0 && + this.headersTimeout > this.requestTimeout + ) { + throw new ERR_OUT_OF_RANGE( + "headersTimeout", + "<= requestTimeout", + headersTimeout, + ); + } + + const keepAliveTimeout = options.keepAliveTimeout; + if (keepAliveTimeout !== undefined) { + validateInteger(keepAliveTimeout, "keepAliveTimeout", 0); + unsupportedOption("keepAliveTimeout"); + } + + const keepAliveTimeoutBuffer = options.keepAliveTimeoutBuffer; + if (keepAliveTimeoutBuffer !== undefined) { + validateInteger(keepAliveTimeoutBuffer, "keepAliveTimeoutBuffer"); + unsupportedOption("keepAliveTimeoutBuffer"); + } + + const connectionsCheckingInterval = options.connectionsCheckingInterval; + if (connectionsCheckingInterval !== undefined) { + validateInteger( + connectionsCheckingInterval, + "connectionsCheckingInterval", + 0, + ); + unsupportedOption("connectionsCheckingInterval"); + } + + const requireHostHeader = options.requireHostHeader; + if (requireHostHeader !== undefined) { + validateBoolean(requireHostHeader, "options.requireHostHeader"); + this.requireHostHeader = requireHostHeader; + } else { + this.requireHostHeader = true; + } + + const joinDuplicateHeaders = options.joinDuplicateHeaders; + if (joinDuplicateHeaders !== undefined) { + validateBoolean(joinDuplicateHeaders, "options.joinDuplicateHeaders"); + } + this.joinDuplicateHeaders = joinDuplicateHeaders ?? false; + + const rejectNonStandardBodyWrites = options.rejectNonStandardBodyWrites; + if (rejectNonStandardBodyWrites !== undefined) { + validateBoolean( + rejectNonStandardBodyWrites, + "options.rejectNonStandardBodyWrites", + ); + this.rejectNonStandardBodyWrites = rejectNonStandardBodyWrites; + } else { + this.rejectNonStandardBodyWrites = false; + } +} diff --git a/src/compat/http/unique-headers.ts b/src/compat/http/unique-headers.ts new file mode 100644 index 0000000..f67b8f2 --- /dev/null +++ b/src/compat/http/unique-headers.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// Copyright Joyent and Node contributors. All rights reserved. MIT license. + +export function parseUniqueHeadersOption( + headers?: (string | string[])[], +): Set | null { + if (!Array.isArray(headers)) { + return null; + } + + const unique = new Set(); + for (const header of headers) { + if (Array.isArray(header)) { + for (const h of header) { + unique.add(h.toLowerCase()); + } + } else { + unique.add(header.toLowerCase()); + } + } + return unique; +} diff --git a/src/compat/http/websocket-network.spec.mjs b/src/compat/http/websocket-network.spec.mjs new file mode 100644 index 0000000..c78ee0f --- /dev/null +++ b/src/compat/http/websocket-network.spec.mjs @@ -0,0 +1,56 @@ +export async function runWebSocketLifecycle(WebSocket, ports) { + const results = {}; + for (const [scheme, port] of [ + ["ws", ports.http], + ["wss", ports.https], + ]) { + for (const closeBy of ["client", "server"]) { + results[`${scheme}/${closeBy}`] = await new Promise((resolve, reject) => { + const socket = new WebSocket( + `${scheme}://127.0.0.1:${port}/websocket`, + ["compat"], + ); + socket.binaryType = "arraybuffer"; + const messages = [], + events = []; + const timer = setTimeout(() => { + socket.close(); + reject(new Error(`WebSocket timeout: ${scheme}/${closeBy}`)); + }, 5000); + socket.addEventListener("open", () => { + events.push("open"); + socket.send("text"); + socket.send(new Uint8Array([1, 2, 255])); + }); + socket.addEventListener("message", (event) => { + messages.push( + typeof event.data === "string" + ? event.data + : Array.from(new Uint8Array(event.data)), + ); + if (messages.length === 2) { + if (closeBy === "client") socket.close(1000, "done"); + else socket.send("close-me"); + } + }); + socket.addEventListener("error", () => events.push("error")); + socket.addEventListener("close", (event) => { + clearTimeout(timer); + resolve({ + protocol: socket.protocol, + binaryType: socket.binaryType, + readyState: socket.readyState, + messages, + events, + close: { + code: event.code, + reason: event.reason, + clean: event.wasClean, + }, + }); + }); + }); + } + } + return results; +} diff --git a/src/compat/http/websocket-surface.spec.mjs b/src/compat/http/websocket-surface.spec.mjs new file mode 100644 index 0000000..331a981 --- /dev/null +++ b/src/compat/http/websocket-surface.spec.mjs @@ -0,0 +1,74 @@ +export function runWebSocketSurface() { + const attempt = (fn) => { + try { + const value = fn(); + return { ok: true, value: value === undefined ? "undefined" : value }; + } catch (e) { + return { + ok: false, + name: e.name, + message: e.message, + code: e.code ?? null, + }; + } + }; + const out = { + surface: { + constructor: typeof WebSocket, + pair: typeof WebSocketPair, + prototype: Object.getOwnPropertyNames(WebSocket.prototype).sort(), + methods: Object.fromEntries( + [ + "accept", + "send", + "close", + "serializeAttachment", + "deserializeAttachment", + ].map((k) => [k, typeof WebSocket.prototype[k]]), + ), + }, + constructors: [], + }; + for (const [id, args] of [ + ["invalid", ["not a url"]], + ["http", ["http://localhost/"]], + ["fragment", ["ws://localhost/#x"]], + ["duplicate", ["ws://localhost/", ["a", "a"]]], + ]) + out.constructors.push({ + id, + result: attempt(() => { + const ws = new WebSocket(...args); + ws.addEventListener("error", () => {}); + ws.close(); + return "created"; + }), + }); + out.pair = attempt(() => { + const p = new WebSocketPair(), + a = p[0], + b = p[1], + v = { stateBefore: [a.readyState, b.readyState], keys: Object.keys(p) }; + v.sendBefore = attempt(() => a.send("before")); + v.accept = attempt(() => a.accept()); + v.repeatAccept = attempt(() => a.accept()); + v.emptyAttachment = attempt(() => a.deserializeAttachment()); + const attachment = { x: { n: 1 }, bytes: new Uint8Array([1, 2]) }; + v.serialize = attempt(() => a.serializeAttachment(attachment)); + attachment.x.n = 2; + attachment.bytes[0] = 9; + const first = a.deserializeAttachment(); + v.clone = { n: first.x.n, bytes: Array.from(first.bytes) }; + first.x.n = 3; + v.cloneAgain = a.deserializeAttachment().x.n; + v.missing = attempt(() => a.serializeAttachment()); + v.afterMissing = attempt(() => a.deserializeAttachment()); + v.function = attempt(() => a.serializeAttachment(() => {})); + v.stateAfter = [a.readyState, b.readyState]; + a.close(); + b.accept(); + b.close(); + return v; + }); + return out; +} diff --git a/src/compat/https/index.ts b/src/compat/https/index.ts index 52328f0..b635a62 100644 --- a/src/compat/https/index.ts +++ b/src/compat/https/index.ts @@ -13,7 +13,8 @@ type ClientRequest = InstanceType; import { Agent, globalAgent } from "./agent.js"; import { ERR_METHOD_NOT_IMPLEMENTED } from "./errors.js"; import { Server } from "./server.js"; -import type { IncomingMessageCallback } from "node-internal:internal_http_util"; +import type { IncomingMessage } from "node:http"; +type IncomingMessageCallback = (message: IncomingMessage) => void; import type { RequestOptions, RequestListener } from "node:http"; import type { ServerOptions } from "node:https"; @@ -55,9 +56,14 @@ export function get( return req; } +export function createServer(handler?: RequestListener): Server; export function createServer( options: ServerOptions, - handler: RequestListener, + handler?: RequestListener, +): Server; +export function createServer( + options?: ServerOptions | RequestListener, + handler?: RequestListener, ): Server { if (!enableNodejsHttpServerModules) { throw new ERR_METHOD_NOT_IMPLEMENTED("createServer"); diff --git a/src/compat/net/sockets.spec.ts b/src/compat/net/sockets.spec.ts index fee471f..61bf7f2 100644 --- a/src/compat/net/sockets.spec.ts +++ b/src/compat/net/sockets.spec.ts @@ -162,7 +162,12 @@ it("preserves the TLS verification name when connecting to a resolved address", ).toMatchObject({ host: "127.0.0.1", port: 443, - tls: true, + tls: false, + }); + expect( + call.mock.calls.find((args) => args[1] === "socketStartTls")?.[2], + ).toMatchObject({ + id: call.mock.calls.find((args) => args[1] === "socketOpen")?.[2].id, servername: "example.com", }); await socket.close(); @@ -203,3 +208,43 @@ it("flushes an active plain write before TLS handoff and invalidates the old wri await tls.close(); expect(call.mock.calls.some(([, m]) => m === "socketClose")).toBe(true); }); + +it("holds direct TLS open and application IO until authentication completes", async () => { + let authenticated!: (value: unknown) => void; + const call = vi.fn(async (_, method) => { + if (method === "socketOpen") return { remoteAddress: "127.0.0.1:443" }; + if (method === "socketStartTls") + return new Promise((resolve) => { + authenticated = resolve; + }); + if (method === "socketRead" || method === "socketWatch") + return new Promise(() => {}); + return {}; + }); + restore = setNativeCall(call); + const socket = connect("127.0.0.1:443", { + secureTransport: "on", + expectedServerHostname: "verified.test", + }); + let opened = false; + void socket.opened.then(() => { + opened = true; + }); + const write = socket.writable.getWriter().write(new Uint8Array([1])); + await vi.waitFor(() => expect(authenticated).toBeTypeOf("function")); + expect(opened).toBe(false); + expect(call.mock.calls.some(([, method]) => method === "socketWrite")).toBe( + false, + ); + expect( + call.mock.calls.find(([, method]) => method === "socketOpen")?.[2], + ).toMatchObject({ tls: false }); + expect( + call.mock.calls.find(([, method]) => method === "socketStartTls")?.[2], + ).toMatchObject({ servername: "verified.test" }); + authenticated({}); + await socket.opened; + await write; + expect(opened).toBe(true); + await socket.close(); +}); diff --git a/src/compat/net/sockets.ts b/src/compat/net/sockets.ts index 7b858af..21915f6 100644 --- a/src/compat/net/sockets.ts +++ b/src/compat/net/sockets.ts @@ -59,16 +59,55 @@ export function connect( throw new TypeError("Invalid TCP address"); const id = crypto.randomUUID(); const host = url.hostname.replace(/^\[|\]$/g, ""); - return transport(call, id, host, options.secureTransport ?? "off", () => - call("WorkersCapacitor", "socketOpen", { - id, - host, - port, - tls: options.secureTransport === "on", - ...(options.expectedServerHostname - ? { servername: options.expectedServerHostname } - : {}), - }), + const directTls = options.secureTransport === "on"; + let secureCall = call; + return transport( + (...args) => { + const operation = secureCall(...args); + return directTls + ? operation.catch((error) => { + if ( + error instanceof Error && + error.message.includes( + "TLS connection closed without close_notify", + ) + ) + throw new Error("Network connection lost."); + throw error; + }) + : operation; + }, + id, + host, + options.secureTransport ?? "off", + async () => { + try { + const info = await call("WorkersCapacitor", "socketOpen", { + id, + host, + port, + tls: false, + }); + if (options.secureTransport === "on") { + const servername = options.expectedServerHostname ?? host; + const result = (await call("WorkersCapacitor", "socketStartTls", { + id, + servername, + })) as { tlsEngine?: string } | undefined; + // Direct TLS and STARTTLS share the authenticated record engine. On iOS + // this also preserves TLS close_notify information that NW TLS hides. + if (result?.tlsEngine === "openssl-wasm") + secureCall = await nativeTlsSession(call, id, servername); + } + return info; + } catch (error) { + if (directTls) + throw new Error( + "proxy request failed, cannot connect to the specified address", + ); + throw error; + } + }, ); } function transport( diff --git a/src/compat/net/validators.ts b/src/compat/net/validators.ts index eb819d3..ada47e5 100644 --- a/src/compat/net/validators.ts +++ b/src/compat/net/validators.ts @@ -91,6 +91,20 @@ export function validateObject( } } +export function validateInteger( + value: unknown, + name: string, + min = Number.MIN_SAFE_INTEGER, + max = Number.MAX_SAFE_INTEGER, +): asserts value is number { + if (typeof value !== "number") + throw new ERR_INVALID_ARG_TYPE(name, "number", value); + if (!Number.isInteger(value)) + throw new ERR_OUT_OF_RANGE(name, "an integer", value); + if (value < min || value > max) + throw new ERR_OUT_OF_RANGE(name, `>= ${min} && <= ${max}`, value); +} + export function validateInt32( value: unknown, name: string, diff --git a/src/compat/process/directory.ts b/src/compat/process/directory.ts new file mode 100644 index 0000000..ce9cc23 --- /dev/null +++ b/src/compat/process/directory.ts @@ -0,0 +1,35 @@ +import { AsyncLocalStorage } from "../async-hooks/index.js"; +import { registerRequestScope } from "../../shared/request-scope.js"; +const contexts = new AsyncLocalStorage<{ path: string }>(); +const fallback = { path: "/bundle" }; +const active = () => contexts.getStore() ?? fallback; +export const currentDirectory = () => active().path; +let validateDirectory = (path: string) => { + const normalized = + new URL(path, "file://" + currentDirectory() + "/").pathname.replace( + /\/$/, + "", + ) || "/"; + if (!["/", "/tmp", "/bundle", "/dev"].includes(normalized)) + throw Object.assign(new Error("no such file or directory"), { + code: "ENOENT", + syscall: "chdir", + path: normalized, + }); + return normalized; +}; +export function registerDirectoryValidator(validate: (path: string) => string) { + validateDirectory = validate; +} +export function changeDirectory(path: unknown) { + if (typeof path !== "string") + throw Object.assign( + new TypeError('The "directory" argument must be of type string.'), + { code: "ERR_INVALID_ARG_TYPE" }, + ); + active().path = validateDirectory(path); +} +export function runWithDirectory(callback: () => T): T { + return contexts.run({ path: "/bundle" }, callback); +} +registerRequestScope(runWithDirectory); diff --git a/src/compat/process/index.ts b/src/compat/process/index.ts index 63afd2e..1fd88af 100644 --- a/src/compat/process/index.ts +++ b/src/compat/process/index.ts @@ -1,3 +1,8 @@ +import { + currentDirectory as cwd, + changeDirectory as chdir, +} from "./directory.js"; +export { cwd, chdir }; import * as metadata from "./metadata.js"; export { arch, @@ -181,6 +186,8 @@ export const hrtime = Object.assign( ); Object.assign(process, { + cwd, + chdir, hrtime, ...metadata, on, diff --git a/src/compat/process/state.ts b/src/compat/process/state.ts index 0413983..94f19f0 100644 --- a/src/compat/process/state.ts +++ b/src/compat/process/state.ts @@ -1,3 +1,4 @@ +/// import original from "cf-workers-native:process-base"; // A dependency leaf keeps process.env available during events/util cycles. const process = { ...original }; diff --git a/src/compat/stream/destroy.ts b/src/compat/stream/destroy.ts index 93e3f7f..aa681b7 100644 --- a/src/compat/stream/destroy.ts +++ b/src/compat/stream/destroy.ts @@ -158,8 +158,29 @@ function _destroy( } } +// Workers consumes an unobserved internal TLS parent error at its async +// stream boundary. Direct EventEmitter.emit still throws to its caller. +const internalErrorInterruptions = new WeakMap(); +export function markInternalErrorInterruption(stream: object, error: unknown) { + internalErrorInterruptions.set(stream, error); + queueMicrotask(() => { + if (internalErrorInterruptions.get(stream) === error) + internalErrorInterruptions.delete(stream); + }); +} function emitErrorCloseNT(self: Readable | Writable, err: Error): void { - emitErrorNT(self, err); + try { + emitErrorNT(self, err); + } catch (error) { + if ( + internalErrorInterruptions.has(self) && + internalErrorInterruptions.get(self) === error + ) { + internalErrorInterruptions.delete(self); + return; + } + throw error; + } emitCloseNT(self); } diff --git a/src/compat/stream/readable.ts b/src/compat/stream/readable.ts index 0863074..21511a4 100644 --- a/src/compat/stream/readable.ts +++ b/src/compat/stream/readable.ts @@ -1,4 +1,5 @@ // @ts-nocheck +import { ReadableStream as WorkersReadableStream } from "../stream-web/runtime.js"; import { asyncDispose } from "../../shared/globals/symbols.js"; // Copyright (c) 2017-2022 Cloudflare, Inc. // Licensed under the Apache 2.0 license found in the LICENSE file or at: @@ -2371,7 +2372,9 @@ export function newReadableStreamFromStreamReadable( return new globalThis.ByteLengthQueuingStrategy({ highWaterMark }); }; - const strategy = evaluateStrategyOrFallback(options?.strategy); + const strategy = createTypeBytes + ? { highWaterMark } + : evaluateStrategyOrFallback(options?.strategy); let controller; let wasCanceled = false; @@ -2399,7 +2402,10 @@ export function newReadableStreamFromStreamReadable( // constructor rejects an invalid high-water mark, and a throw there must // leave the source as it was, not paused with this adapter's listeners // on it. The first pull() comes no earlier than a later microtask. - const stream = new globalThis.ReadableStream( + const StreamConstructor = createTypeBytes + ? WorkersReadableStream + : globalThis.ReadableStream; + const stream = new StreamConstructor( { start(c) { controller = c; @@ -2436,6 +2442,8 @@ export function newReadableStreamFromStreamReadable( return; } controller.close(); + if (createTypeBytes) + queueMicrotask(() => controller.byobRequest?.respond(0)); }); streamReadable.on("data", onData); diff --git a/src/compat/tls/README.md b/src/compat/tls/README.md index 8f25abf..b1946ed 100644 --- a/src/compat/tls/README.md +++ b/src/compat/tls/README.md @@ -13,15 +13,21 @@ rejection in both page and Dedicated Worker on iOS and Android. Dedicated Worker use the normal owned socket relay, including disposal. Trust overrides are confined to the independent debug test consumer and simulator CA store. -Existing native plaintext sockets upgrade on the same TCP connection, preserving -protocol state. iOS uses OpenSSL memory BIOs with mandatory SecTrust chain and -hostname validation; Android uses the existing Netty channel and platform -SSLEngine. Application reads and writes wait for authentication. The native suite +Direct TLS and upgrades use the same authenticated record engine. Upgrades +preserve the existing TCP connection and protocol state. iOS uses OpenSSL memory +BIOs with mandatory SecTrust chain and hostname validation; Android uses the +existing Netty channel and platform SSLEngine. Both distinguish TLS close_notify +from a truncated connection, including a peer reset. Application reads and writes wait for authentication. The native suite covers TLS 1.2 and 1.3, repeated write-half-close followed by response reads, and rejection of hostname mismatch and an untrusted root in both execution modes. -Custom Duplex wrapping remains unsupported. +Custom Duplex wrapping follows Workers' byte-forwarding path and does not itself +encrypt traffic. Shared contracts cover its EOF, destruction, invalid chunks, +write errors and listener ordering separately from the native TLS exchanges. -Whole-module acceptance remains open. Further socket lifecycle/prototype and -error-path coverage is also required. Workers itself rejects server mode, custom +Whole-module acceptance remains open. Workers can emit STARTTLS secureConnect +before its handshake finishes and can report an unclean STARTTLS EOF as a normal +end; this implementation waits for authentication and rejects the truncated TLS +stream. Handshake cancellation event timing also differs from the pinned Workers +baseline. These differences are not a completed compatibility profile. Workers itself rejects server mode, custom verification callbacks, ALPN negotiation options and several introspection APIs in the pinned profile; retain those explicit failures. diff --git a/src/compat/tls/jsstream.ts b/src/compat/tls/jsstream.ts index 752401b..2a70fc2 100644 --- a/src/compat/tls/jsstream.ts +++ b/src/compat/tls/jsstream.ts @@ -27,15 +27,16 @@ import { Buffer } from "node:buffer"; /* eslint-disable @typescript-eslint/no-redundant-type-constituents */ +import { markInternalErrorInterruption } from "../stream/destroy.js"; import { notStrictEqual } from "../assert/index.js"; import { Socket } from "../net/internal-net.js"; import { ERR_STREAM_WRAP } from "./errors.js"; import { Duplex, toBYOBWeb } from "../stream/duplex.js"; import type { SocketInfo, - Writer, - Socket as CloudflareSocket, -} from "node-internal:sockets"; + PlatformSocket as CloudflareSocket, +} from "../net/sockets.js"; +type Writer = WritableStream; const kCurrentWriteRequest = Symbol("kCurrentWriteRequest"); const kCurrentShutdownRequest = Symbol("kCurrentShutdownRequest"); @@ -99,8 +100,8 @@ export class JSStreamSocket extends Socket { writable: webStream.writable as unknown as Writer, }, // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - reader: new ReadableStreamBYOBReader(webStream.readable), - writer: new WritableStreamDefaultWriter(webStream.writable), + reader: webStream.readable.getReader({ mode: "byob" }), + writer: webStream.writable.getWriter(), options: { host: "0.0.0.0", port: 0, @@ -109,7 +110,15 @@ export class JSStreamSocket extends Socket { }; stream.pause(); - stream.on("error", (err) => this.emit("error", err)); + stream.on("error", (err) => { + const unobserved = this.listenerCount("error") === 0; + try { + this.emit("error", err); + } catch (error) { + if (unobserved) markInternalErrorInterruption(stream, error); + throw error; + } + }); const ondata = (chunk: string | Buffer): void => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare if (typeof chunk === "string" || stream.readableObjectMode === true) { @@ -117,14 +126,13 @@ export class JSStreamSocket extends Socket { stream.pause(); stream.removeListener("data", ondata); - this.emit("error", new ERR_STREAM_WRAP()); - return; + throw new ERR_STREAM_WRAP(); } // TODO(soon): We need to trigger read() result for _handle.reader.read(buf) call // in node:net. }; - stream.on("data", ondata); + stream.prependListener("data", ondata); stream.once("end", () => { closePromise.resolve(); }); diff --git a/src/compat/tls/network.spec.mjs b/src/compat/tls/network.spec.mjs new file mode 100644 index 0000000..71ceb40 --- /dev/null +++ b/src/compat/tls/network.spec.mjs @@ -0,0 +1,207 @@ +// Run unchanged through pinned workerd and installed native transports. +export async function runTlsLifecycle({ net, tls }, ports) { + const results = {}; + for (const mode of ["direct", "upgrade"]) { + for (const kind of [ + "abort-handshake", + "close-before", + "close-mid", + "truncated-after-data", + "reset-after-data", + "graceful-end", + "abort-read", + "timeout", + "repeat-destroy", + ]) { + results[`${mode}/${kind}`] = await new Promise((resolve, reject) => { + const controller = new AbortController(); + const events = []; + let secure = false, + body = "", + socket, + plain, + abortTimer, + observationTimer; + const timer = setTimeout(() => { + socket?.destroy(); + plain?.destroy(); + reject( + new Error( + `TLS lifecycle timeout: ${mode}/${kind} ${JSON.stringify(events)}`, + ), + ); + }, 5000); + const observePending = () => { + observationTimer = setTimeout(() => { + clearTimeout(timer); + const result = { + pending: true, + secure, + body, + events: events.slice(), + destroyed: socket.destroyed, + }; + socket.removeAllListeners("close"); + socket.destroy(); + plain?.destroy(); + resolve(result); + }, 100); + }; + const attach = () => { + socket.on("secureConnect", () => { + secure = true; + if (kind === "abort-read") { + controller.abort(new Error("stop-read")); + observePending(); + } else if (kind === "repeat-destroy") { + socket.destroy(); + socket.destroy(); + } else if (kind === "timeout") + socket.setTimeout(30, () => { + events.push("timeout"); + socket.destroy(); + }); + }); + socket.on("data", (chunk) => (body += chunk.toString())); + socket.on("error", (error) => + events.push({ + name: error.name, + code: error.code ?? null, + message: error.message, + }), + ); + socket.on("end", () => events.push("end")); + socket.on("close", () => { + clearTimeout(timer); + clearTimeout(abortTimer); + clearTimeout(observationTimer); + plain?.destroy(); + resolve({ secure, body, events, destroyed: socket.destroyed }); + }); + if (kind === "abort-handshake") + abortTimer = setTimeout(() => { + controller.abort(new Error("stop-handshake")); + observePending(); + }, 30); + }; + if (mode === "direct") { + const port = + kind === "abort-handshake" + ? ports.silentTcp + : kind === "close-before" + ? ports.eofTcp + : kind === "close-mid" + ? ports.badTcp + : kind === "reset-after-data" + ? ports.resetTls + : kind === "graceful-end" + ? ports.gracefulTls + : kind === "truncated-after-data" + ? ports.dataTls + : ports.idleTls; + socket = tls.connect({ + host: "127.0.0.1", + port, + servername: "localhost", + signal: controller.signal, + }); + attach(); + } else { + plain = net.connect({ + host: "127.0.0.1", + port: + kind === "graceful-end" + ? ports.gracefulUpgrade + : ports.lifecycleUpgrade, + }); + plain.on("error", reject); + plain.on("connect", () => plain.write(kind + "\n")); + let greeting = ""; + const read = (chunk) => { + greeting += chunk.toString(); + if (!greeting.endsWith("\n")) return; + plain.off("data", read); + socket = tls.connect({ + socket: plain, + servername: "localhost", + signal: controller.signal, + }); + attach(); + }; + plain.on("data", read); + } + }); + } + } + // These rows isolate SNI selection and paused reads from terminal-event parity. + for (const kind of [ + "sni-omitted", + "sni-ip", + "sni-host", + "paused-small-buffer", + "cancel-isolation", + ]) { + results[kind] = await new Promise((resolve, reject) => { + let other, + bytes = 0, + authorized = false, + encrypted = false; + const errors = []; + const socket = tls.connect({ + host: "127.0.0.1", + port: ports.bulkTls, + ...(kind === "sni-omitted" + ? {} + : { servername: kind === "sni-ip" ? "127.0.0.1" : "localhost" }), + highWaterMark: 64, + }); + const timer = setTimeout(() => { + socket.destroy(); + other?.destroy(); + reject(new Error("TLS lifecycle timeout: " + kind)); + }, 5000); + socket.on("secureConnect", () => { + authorized = socket.authorized; + encrypted = socket.encrypted; + if (kind === "paused-small-buffer") { + socket.pause(); + setTimeout(() => socket.resume(), 30); + } + if (kind === "cancel-isolation") { + other = tls.connect({ + host: "127.0.0.1", + port: ports.idleTls, + servername: "localhost", + }); + other.on("error", () => {}); + other.on("secureConnect", () => { + other.destroy(); + socket.end("bulk"); + }); + } else socket.end("bulk"); + }); + socket.on("data", (chunk) => { + bytes += chunk.length; + }); + socket.on("error", (error) => + errors.push({ + name: error.name, + code: error.code ?? null, + message: error.message, + }), + ); + socket.on("close", () => { + clearTimeout(timer); + other?.destroy(); + resolve({ + authorized, + encrypted, + bytes, + errors, + destroyed: socket.destroyed, + }); + }); + }); + } + return results; +} diff --git a/src/compat/tls/upgrade.spec.ts b/src/compat/tls/upgrade.spec.ts index 8e61798..2227303 100644 --- a/src/compat/tls/upgrade.spec.ts +++ b/src/compat/tls/upgrade.spec.ts @@ -108,3 +108,50 @@ it("closes the connection when destroyed during authentication", async () => { expect(call.mock.calls.some(([, m]) => m === "socketWrite")).toBe(false); plain.destroy(); }); + +import { Duplex } from "../stream/duplex.js"; +it("constructs the Workers custom Duplex wrapper and forwards bytes", async () => { + const writes: string[] = []; + const custom = Duplex({ + read() {}, + write(chunk: Uint8Array, _encoding: string, callback: () => void) { + writes.push(Buffer.from(chunk).toString()); + callback(); + }, + }); + const wrapped = new TLSSocket(custom); + wrapped.on("error", () => {}); + const data = new Promise((resolve) => + wrapped.once("data", (chunk: Uint8Array) => + resolve(Buffer.from(chunk).toString()), + ), + ); + await tick(); + wrapped.write("outbound"); + custom.push(Buffer.from("inbound")); + expect(await data).toBe("inbound"); + await tick(); + expect(writes).toEqual(["outbound"]); + wrapped.destroy(); + custom.destroy(); +}); +it("custom Duplex EOF emits end before close", async () => { + const custom = Duplex({ + read() {}, + write(_chunk: unknown, _encoding: unknown, callback: () => void) { + callback(); + }, + }); + const wrapped = new TLSSocket(custom); + const events: string[] = []; + wrapped.on("error", () => {}); + wrapped.on("data", () => {}); + wrapped.on("end", () => events.push("end")); + wrapped.on("close", () => events.push("close")); + await tick(); + custom.push(null); + await tick(); + expect(events).toEqual(["end", "close"]); + wrapped.destroy(); + custom.destroy(); +}); diff --git a/src/compat/tls/wrap.ts b/src/compat/tls/wrap.ts index 55c2992..71513b6 100644 --- a/src/compat/tls/wrap.ts +++ b/src/compat/tls/wrap.ts @@ -109,7 +109,7 @@ export declare class TLSSocket extends Socket { constructor( socket: Socket | Duplex | undefined, - opts: TLSSocket["_tlsOptions"], + opts?: TLSSocket["_tlsOptions"], ); prototype: TLSSocket; @@ -247,10 +247,6 @@ export function TLSSocket( } } - if (socket != null && !(socket instanceof Socket)) { - throw new ERR_METHOD_NOT_IMPLEMENTED("TLS upgrade of a custom Duplex"); - } - if ( socket instanceof Socket && (socket.destroyed || diff --git a/src/compat/util/inspect.ts b/src/compat/util/inspect.ts index 774124c..f751094 100644 --- a/src/compat/util/inspect.ts +++ b/src/compat/util/inspect.ts @@ -31,6 +31,7 @@ /* eslint-disable */ import internal from "./inspect-binding.js"; +import { isKeyObject } from "../crypto/key-state.js"; import { Buffer } from "node:buffer"; import { @@ -1456,7 +1457,11 @@ function formatRaw( return base; } } else { - if (keys.length === 0 && protoProps === undefined) { + // Workers keeps a key handle as an own symbol; this adapter keeps it in + // WeakMap state. Preserve depth truncation even though our own keys are empty. + const truncateKey = + isKeyObject(value) && ctx.depth !== null && recurseTimes > ctx.depth; + if (keys.length === 0 && protoProps === undefined && !truncateKey) { return `${getCtxStyle(value, constructor, tag)}{}`; } braces[0] = `${getCtxStyle(value, constructor, tag)}{`; diff --git a/src/compat/zlib/LICENSE-3RD-PARTY.md b/src/compat/zlib/LICENSE-3RD-PARTY.md new file mode 100644 index 0000000..5af4647 --- /dev/null +++ b/src/compat/zlib/LICENSE-3RD-PARTY.md @@ -0,0 +1,11 @@ +# Upstream notices + +This module ports Cloudflare workerd tag `v1.20261012.1`, revision +`5358e3d337336eaf696a48bc1563d808b10feba5`: + +- `src/node/_tls_common.ts` +- `src/node/internal/internal_tls_common.ts` + +Copyright Cloudflare, Inc.; Apache License 2.0. Node.js/Joyent MIT notices +are preserved in the source files. Imports, resource-management symbols and +host constructor references are adapted for the Capacitor runtime. diff --git a/src/compat/zlib/LICENSE-APACHE b/src/compat/zlib/LICENSE-APACHE new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/src/compat/zlib/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/src/compat/zlib/LICENSE.zlib b/src/compat/zlib/LICENSE.zlib new file mode 100644 index 0000000..ab8ee6f --- /dev/null +++ b/src/compat/zlib/LICENSE.zlib @@ -0,0 +1,22 @@ +Copyright notice: + + (C) 1995-2022 Jean-loup Gailly and Mark Adler + + This software is provided 'as-is', without any express or implied + warranty. In no event will the authors be held liable for any damages + arising from the use of this software. + + Permission is granted to anyone to use this software for any purpose, + including commercial applications, and to alter it and redistribute it + freely, subject to the following restrictions: + + 1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + 3. This notice may not be removed or altered from any source distribution. + + Jean-loup Gailly Mark Adler + jloup@gzip.org madler@alumni.caltech.edu diff --git a/src/compat/zlib/classic-base.cjs b/src/compat/zlib/classic-base.cjs new file mode 100644 index 0000000..6eafbbc --- /dev/null +++ b/src/compat/zlib/classic-base.cjs @@ -0,0 +1,778 @@ +// Derived from Cloudflare workerd 5358e3d337336eaf696a48bc1563d808b10feba5 +// Copyright (c) 2017-2022 Cloudflare, Inc. Apache-2.0. +// Copyright Joyent and Node contributors. MIT license. +// Native binding imports adapted for the WebView implementation. +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if ((from && typeof from === "object") || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { + get: () => from[key], + enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable, + }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => ( + (target = mod != null ? __create(__getProtoOf(mod)) : {}), + __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule + ? __defProp(target, "default", { value: mod, enumerable: true }) + : target, + mod, + ) +); +var __toCommonJS = (mod) => + __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var stdin_exports = {}; +__export(stdin_exports, { + Brotli: () => Brotli, + Zlib: () => Zlib, + ZlibBase: () => ZlibBase, + Zstd: () => Zstd, + kMaxZstdCParam: () => kMaxZstdCParam, + kMaxZstdDParam: () => kMaxZstdDParam, + owner_symbol: () => owner_symbol, + zstdInitCParamsArray: () => zstdInitCParamsArray, + zstdInitDParamsArray: () => zstdInitDParamsArray, +}); + +var import_classic_binding = __toESM(require("./classic-binding.js")); +var import_node_buffer = require("node:buffer"); +var import_validators = require("./validators.js"); +var import_errors = require("./errors.js"); +var import_node_stream = require("node:stream"); +var import_node_stream2 = require("node:stream"); +var import_types = require("node:util/types"); +var constants = __toESM(require("./constants.js")); +var import_node_assert = __toESM(require("node:assert")); +const { + CONST_INFLATE, + CONST_GUNZIP, + CONST_GZIP, + CONST_UNZIP, + CONST_Z_DEFAULT_CHUNK, + CONST_Z_DEFAULT_STRATEGY, + CONST_Z_DEFAULT_MEMLEVEL, + CONST_Z_DEFAULT_WINDOWBITS, + CONST_Z_DEFAULT_COMPRESSION, + CONST_Z_FIXED, + CONST_Z_MAX_LEVEL, + CONST_Z_MAX_MEMLEVEL, + CONST_Z_MAX_WINDOWBITS, + CONST_Z_MIN_LEVEL, + CONST_Z_MIN_MEMLEVEL, + CONST_Z_SYNC_FLUSH, + CONST_Z_NO_FLUSH, + CONST_Z_BLOCK, + CONST_Z_MIN_CHUNK, + CONST_Z_PARTIAL_FLUSH, + CONST_Z_FULL_FLUSH, + CONST_Z_FINISH, + CONST_BROTLI_ENCODE, + CONST_BROTLI_DECODE, + CONST_BROTLI_OPERATION_PROCESS, + CONST_BROTLI_OPERATION_EMIT_METADATA, + CONST_BROTLI_OPERATION_FINISH, + CONST_BROTLI_OPERATION_FLUSH, + CONST_ZSTD_ENCODE, + CONST_ZSTD_DECODE, + CONST_ZSTD_e_continue, + CONST_ZSTD_e_end, + CONST_ZSTD_e_flush, +} = import_classic_binding.default; +const owner_symbol = /* @__PURE__ */ Symbol("owner"); +const FLUSH_BOUND_IDX_NORMAL = 0; +const FLUSH_BOUND_IDX_BROTLI = 1; +const FLUSH_BOUND_IDX_ZSTD = 2; +const FLUSH_BOUND = [ + [CONST_Z_NO_FLUSH, CONST_Z_BLOCK], + [CONST_BROTLI_OPERATION_PROCESS, CONST_BROTLI_OPERATION_EMIT_METADATA], + [CONST_ZSTD_e_continue, CONST_ZSTD_e_end], +]; +const kFlushFlag = /* @__PURE__ */ Symbol("kFlushFlag"); +const kError = /* @__PURE__ */ Symbol("kError"); +function processCallback() { + const handle = this; + const self = this[owner_symbol]; + (0, import_node_assert.ok)(self, "Owner symbol should exist"); + const state = self._writeState; + if (self.destroyed) { + this.buffer = null; + this.cb(); + return; + } + const availOutAfter = state[0]; + const availInAfter = state[1]; + const inDelta = handle.availInBefore - availInAfter; + self.bytesWritten += inDelta; + const have = handle.availOutBefore - availOutAfter; + let streamBufferIsFull = false; + if (have > 0) { + const out = self._outBuffer.slice(self._outOffset, self._outOffset + have); + self._outOffset += have; + streamBufferIsFull = !self.push(out); + } else { + import_node_assert.default.strictEqual(have, 0, "have should not go down"); + } + if (self.destroyed) { + this.cb(); + return; + } + if (availOutAfter === 0 || self._outOffset >= self._chunkSize) { + handle.availOutBefore = self._chunkSize; + self._outOffset = 0; + self._outBuffer = import_node_buffer.Buffer.allocUnsafe(self._chunkSize); + } + if (availOutAfter === 0) { + handle.inOff += inDelta; + handle.availInBefore = availInAfter; + if (!streamBufferIsFull) { + (0, import_node_assert.ok)( + this.buffer, + "Buffer should not have been null", + ); + this.write( + handle.flushFlag, + this.buffer, + // in + handle.inOff, + // in_off + handle.availInBefore, + // in_len + self._outBuffer, + // out + self._outOffset, + // out_off + self._chunkSize, + ); + } else { + const oldRead = self._read; + self._read = (n) => { + (0, import_node_assert.ok)( + this.buffer, + "Buffer should not have been null", + ); + self._read = oldRead; + this.write( + handle.flushFlag, + this.buffer, + // in + handle.inOff, + // in_off + handle.availInBefore, + // in_len + self._outBuffer, + // out + self._outOffset, + // out_off + self._chunkSize, + // out_len + ); + self._read(n); + }; + } + return; + } + if (availInAfter > 0) { + self.push(null); + } + this.buffer = null; + this.cb(); +} +const flushiness = []; +const kFlushFlagList = [ + CONST_Z_NO_FLUSH, + CONST_Z_BLOCK, + CONST_Z_PARTIAL_FLUSH, + CONST_Z_SYNC_FLUSH, + CONST_Z_FULL_FLUSH, + CONST_Z_FINISH, +]; +for (let i = 0; i < kFlushFlagList.length; i++) { + flushiness[kFlushFlagList[i]] = i; +} +function maxFlush(a, b) { + return flushiness[a] > flushiness[b] ? a : b; +} +const kFlushBuffers = []; +{ + const dummyArrayBuffer = new ArrayBuffer(0); + for (const flushFlag of kFlushFlagList) { + const buf = import_node_buffer.Buffer.from(dummyArrayBuffer); + buf[kFlushFlag] = flushFlag; + kFlushBuffers[flushFlag] = buf; + } +} +function zlibOnError(errno, code, message) { + const self = this[owner_symbol]; + (0, import_node_assert.ok)(self, "Owner symbol should exist"); + const error = new import_errors.NodeError(code, message); + error.errno = errno; + self.destroy(error); + self[kError] = error; +} +function processChunkSync(self, chunk, flushFlag) { + let availInBefore = chunk.byteLength; + let availOutBefore = self._chunkSize - self._outOffset; + let inOff = 0; + let availOutAfter; + let availInAfter; + const buffers = []; + let nread = 0; + let inputRead = 0; + const state = self._writeState; + const handle = self._handle; + let buffer = self._outBuffer; + let offset = self._outOffset; + const chunkSize = self._chunkSize; + let error; + self.on("error", function onError(er) { + error = er; + }); + while (true) { + (0, import_node_assert.ok)(handle, "Handle should have been defined"); + handle.writeSync( + flushFlag, + chunk, + // in + inOff, + // in_off + availInBefore, + // in_len + buffer, + // out + offset, + // out_off + availOutBefore, + // out_len + ); + if (error) throw error; + else if (self[kError]) throw self[kError]; + [availOutAfter, availInAfter] = state; + const inDelta = availInBefore - availInAfter; + inputRead += inDelta; + const have = availOutBefore - availOutAfter; + if (have > 0) { + const out = buffer.slice(offset, offset + have); + offset += have; + buffers.push(out); + nread += out.byteLength; + if (nread > self._maxOutputLength) { + _close(self); + throw new import_errors.ERR_BUFFER_TOO_LARGE(self._maxOutputLength); + } + } else { + import_node_assert.default.strictEqual( + have, + 0, + "have should not go down", + ); + } + if (availOutAfter === 0 || offset >= chunkSize) { + availOutBefore = chunkSize; + offset = 0; + buffer = import_node_buffer.Buffer.allocUnsafe(chunkSize); + } + if (availOutAfter === 0) { + inOff += inDelta; + availInBefore = availInAfter; + } else { + break; + } + } + self.bytesWritten = inputRead; + _close(self); + if (nread === 0) return import_node_buffer.Buffer.alloc(0); + return buffers.length === 1 + ? buffers[0] + : import_node_buffer.Buffer.concat(buffers, nread); +} +function _close(engine) { + engine._handle?.close(); + engine._handle = null; +} +const zlibDefaultOptions = { + flush: CONST_Z_NO_FLUSH, + finishFlush: CONST_Z_FINISH, + fullFlush: CONST_Z_FULL_FLUSH, +}; +class ZlibBase extends import_node_stream.Transform { + bytesWritten = 0; + _maxOutputLength; + _outBuffer; + _outOffset = 0; + _chunkSize; + _defaultFlushFlag; + _finishFlushFlag; + _defaultFullFlushFlag; + _info; + _handle = null; + _writeState = new Uint32Array(2); + _writesInProgress = 0; + _hadWrites = false; + [kError]; + constructor( + opts, + mode, + handle, + { flush, finishFlush, fullFlush } = zlibDefaultOptions, + ) { + let chunkSize = CONST_Z_DEFAULT_CHUNK; + let maxOutputLength = import_node_buffer.kMaxLength; + let flushBoundIdx; + if (mode === CONST_BROTLI_ENCODE || mode === CONST_BROTLI_DECODE) { + flushBoundIdx = FLUSH_BOUND_IDX_BROTLI; + } else if (mode === CONST_ZSTD_ENCODE || mode === CONST_ZSTD_DECODE) { + flushBoundIdx = FLUSH_BOUND_IDX_ZSTD; + } else { + flushBoundIdx = FLUSH_BOUND_IDX_NORMAL; + } + if (opts) { + if (opts.chunkSize != null) { + chunkSize = opts.chunkSize; + } + if ( + !(0, import_validators.checkFiniteNumber)( + chunkSize, + "options.chunkSize", + ) + ) { + chunkSize = CONST_Z_DEFAULT_CHUNK; + } else if (chunkSize < CONST_Z_MIN_CHUNK) { + throw new import_errors.ERR_OUT_OF_RANGE( + "options.chunkSize", + `>= ${CONST_Z_MIN_CHUNK}`, + chunkSize, + ); + } + flush = (0, import_validators.checkRangesOrGetDefault)( + opts.flush, + "options.flush", + FLUSH_BOUND[flushBoundIdx]?.[0], + FLUSH_BOUND[flushBoundIdx]?.[1], + flush, + ); + finishFlush = (0, import_validators.checkRangesOrGetDefault)( + opts.finishFlush, + "options.finishFlush", + FLUSH_BOUND[flushBoundIdx]?.[0], + FLUSH_BOUND[flushBoundIdx]?.[1], + finishFlush, + ); + maxOutputLength = (0, import_validators.checkRangesOrGetDefault)( + opts.maxOutputLength, + "options.maxOutputLength", + 1, + import_node_buffer.kMaxLength, + import_node_buffer.kMaxLength, + ); + if (opts.encoding || opts.objectMode || opts.writableObjectMode) { + opts = { ...opts }; + opts.encoding = void 0; + opts.objectMode = false; + opts.writableObjectMode = false; + } + } + super({ autoDestroy: true, ...opts }); + handle.setErrorHandler(zlibOnError.bind(handle)); + handle[owner_symbol] = this; + this._handle = handle; + this._outBuffer = import_node_buffer.Buffer.allocUnsafe(chunkSize); + this._outOffset = 0; + this._chunkSize = chunkSize; + this._defaultFlushFlag = flush; + this._finishFlushFlag = finishFlush; + this._defaultFullFlushFlag = fullFlush; + this._info = Boolean(opts.info); + this._maxOutputLength = maxOutputLength; + } + // Note: This is intentionally a getter that shadows the property from Transform + // @ts-expect-error TS2611 Property/accessor mismatch with Transform._closed + get _closed() { + return this._handle == null; + } + // @deprecated Use `bytesWritten` instead. + get bytesRead() { + return this.bytesWritten; + } + // @deprecated Use `bytesWritten` instead. + set bytesRead(value) { + this.bytesWritten = value; + } + reset() { + (0, import_node_assert.ok)(this._handle, "zlib binding closed"); + this._handle.reset(); + } + // This is the _flush function called by the transform class, + // internally, when the last chunk has been written. + _flush(callback) { + if (this._writesInProgress > 0) { + queueMicrotask(() => this._flush(callback)); + return; + } + if (this._hadWrites) { + queueMicrotask(() => { + const chunk = import_node_buffer.Buffer.alloc(0); + chunk[kFlushFlag] = this._finishFlushFlag; + this._transform(chunk, "utf8", callback); + }); + } else { + const chunk = import_node_buffer.Buffer.alloc(0); + chunk[kFlushFlag] = this._finishFlushFlag; + this._transform(chunk, "utf8", callback); + } + } + // Force Transform compat behavior. + _final(callback) { + callback(); + } + flush(kind, callback) { + if (typeof kind === "function" || (kind === void 0 && !callback)) { + callback = kind; + kind = this._defaultFullFlushFlag; + } + if (this.writableFinished) { + if (callback) { + queueMicrotask(callback); + } + } else if (this.writableEnded) { + if (callback) { + this.once("end", callback); + } + } else { + this.write(kFlushBuffers[kind], "utf8", callback); + } + } + close(callback) { + if (callback) { + (0, import_node_stream2.finished)(this, callback); + } + this.destroy(); + } + _destroy(err, callback) { + _close(this); + callback(err); + } + _transform(chunk, _encoding, cb) { + let flushFlag = this._defaultFlushFlag; + if (typeof chunk[kFlushFlag] === "number") { + flushFlag = chunk[kFlushFlag]; + } + if (this.writableEnded && this.writableLength === chunk.byteLength) { + flushFlag = maxFlush(flushFlag, this._finishFlushFlag); + } + this.#processChunk(chunk, flushFlag, cb); + } + _processChunk(chunk, flushFlag, cb) { + if (cb != null && typeof cb === "function") { + this.#processChunk(chunk, flushFlag, cb); + return; + } + return processChunkSync(this, chunk, flushFlag); + } + #processChunk(chunk, flushFlag, cb) { + if (!this._handle) { + queueMicrotask(cb); + return; + } + if (chunk.byteLength > 0) { + this._hadWrites = true; + } + this._writesInProgress++; + const originalCb = cb; + const wrappedCb = () => { + this._writesInProgress--; + originalCb(); + }; + this._handle.buffer = chunk; + this._handle.cb = wrappedCb; + this._handle.availOutBefore = this._chunkSize - this._outOffset; + this._handle.availInBefore = chunk.byteLength; + this._handle.inOff = 0; + this._handle.flushFlag = flushFlag; + this._handle.write( + flushFlag, + chunk, + // in + 0, + // in_off + this._handle.availInBefore, + // in_len + this._outBuffer, + // out + this._outOffset, + // out_off + this._handle.availOutBefore, + // out_len + ); + } +} +class Zlib extends ZlibBase { + _level = CONST_Z_DEFAULT_COMPRESSION; + _strategy = CONST_Z_DEFAULT_STRATEGY; + constructor(options, mode) { + let windowBits = CONST_Z_DEFAULT_WINDOWBITS; + let level = CONST_Z_DEFAULT_COMPRESSION; + let memLevel = CONST_Z_DEFAULT_MEMLEVEL; + let strategy = CONST_Z_DEFAULT_STRATEGY; + let dictionary; + if (options != null) { + if ( + (options.windowBits == null || options.windowBits === 0) && + (mode === CONST_INFLATE || + mode === CONST_GUNZIP || + mode === CONST_UNZIP) + ) { + windowBits = 0; + } else { + const min = + import_classic_binding.default.CONST_Z_MIN_WINDOWBITS + + (mode === CONST_GZIP ? 1 : 0); + windowBits = (0, import_validators.checkRangesOrGetDefault)( + options.windowBits, + "options.windowBits", + min, + CONST_Z_MAX_WINDOWBITS, + CONST_Z_DEFAULT_WINDOWBITS, + ); + } + level = (0, import_validators.checkRangesOrGetDefault)( + options.level, + "options.level", + CONST_Z_MIN_LEVEL, + CONST_Z_MAX_LEVEL, + CONST_Z_DEFAULT_COMPRESSION, + ); + memLevel = (0, import_validators.checkRangesOrGetDefault)( + options.memLevel, + "options.memLevel", + CONST_Z_MIN_MEMLEVEL, + CONST_Z_MAX_MEMLEVEL, + CONST_Z_DEFAULT_MEMLEVEL, + ); + strategy = (0, import_validators.checkRangesOrGetDefault)( + options.strategy, + "options.strategy", + CONST_Z_DEFAULT_STRATEGY, + CONST_Z_FIXED, + CONST_Z_DEFAULT_STRATEGY, + ); + dictionary = options.dictionary; + if ( + dictionary !== void 0 && + !(0, import_types.isArrayBufferView)(dictionary) + ) { + if ((0, import_types.isAnyArrayBuffer)(dictionary)) { + dictionary = import_node_buffer.Buffer.from(dictionary); + } else { + throw new import_errors.ERR_INVALID_ARG_TYPE( + "options.dictionary", + ["Buffer", "TypedArray", "DataView", "ArrayBuffer"], + dictionary, + ); + } + } + } + const writeState = new Uint32Array(2); + const handle = new import_classic_binding.default.ZlibStream(mode); + handle.initialize( + windowBits, + level, + memLevel, + strategy, + writeState, + () => { + queueMicrotask(processCallback.bind(handle)); + }, + dictionary, + ); + super(options ?? {}, mode, handle); + this._level = level; + this._strategy = strategy; + this._handle = handle; + this._writeState = writeState; + } + params(level, strategy, callback) { + (0, import_validators.checkRangesOrGetDefault)( + level, + "level", + CONST_Z_MIN_LEVEL, + CONST_Z_MAX_LEVEL, + ); + (0, import_validators.checkRangesOrGetDefault)( + strategy, + "strategy", + CONST_Z_DEFAULT_STRATEGY, + CONST_Z_FIXED, + ); + if (this._level !== level || this._strategy !== strategy) { + this.flush( + CONST_Z_SYNC_FLUSH, + this.#paramsAfterFlushCallback.bind(this, level, strategy, callback), + ); + } else { + queueMicrotask(callback); + } + } + // This callback is used by `.params()` to wait until a full flush happened + // before adjusting the parameters. In particular, the call to the native + // `params()` function should not happen while a write is currently in progress + // on the threadpool. + #paramsAfterFlushCallback(level, strategy, callback) { + (0, import_node_assert.ok)(this._handle, "zlib binding closed"); + this._handle.params(level, strategy); + if (!this.destroyed) { + this._level = level; + this._strategy = strategy; + callback?.(); + } + } +} +const kMaxBrotliParam = Math.max( + ...Object.entries(constants).map(([key, value]) => + key.startsWith("BROTLI_PARAM_") ? value : 0, + ), +); +const brotliInitParamsArray = new Uint32Array(kMaxBrotliParam + 1); +const brotliDefaultOptions = { + flush: CONST_BROTLI_OPERATION_PROCESS, + finishFlush: CONST_BROTLI_OPERATION_FINISH, + fullFlush: CONST_BROTLI_OPERATION_FLUSH, +}; +class Brotli extends ZlibBase { + constructor(options, mode) { + (0, import_node_assert.ok)( + mode === CONST_BROTLI_DECODE || mode === CONST_BROTLI_ENCODE, + ); + brotliInitParamsArray.fill(-1); + if (options?.params) { + for (const [origKey, value] of Object.entries(options.params)) { + const key = +origKey; + if ( + Number.isNaN(key) || + key < 0 || + key > kMaxBrotliParam || + (brotliInitParamsArray[key] | 0) !== -1 + ) { + throw new import_errors.ERR_BROTLI_INVALID_PARAM(origKey); + } + if (typeof value !== "number" && typeof value !== "boolean") { + throw new import_errors.ERR_INVALID_ARG_TYPE( + "options.params[key]", + "number", + value, + ); + } + brotliInitParamsArray[key] = value; + } + } + const handle = + mode === CONST_BROTLI_DECODE + ? new import_classic_binding.default.BrotliDecoder(mode) + : new import_classic_binding.default.BrotliEncoder(mode); + const _writeState = new Uint32Array(2); + if ( + !handle.initialize( + brotliInitParamsArray, + _writeState, + processCallback.bind(handle), + ) + ) { + throw new import_errors.ERR_ZLIB_INITIALIZATION_FAILED(); + } + super(options ?? {}, mode, handle, brotliDefaultOptions); + this._writeState = _writeState; + } +} +const kMaxZstdCParam = Math.max( + ...Object.entries(constants).map(([key, value]) => + key.startsWith("ZSTD_c_") ? value : 0, + ), +); +const zstdInitCParamsArray = new Int32Array(kMaxZstdCParam + 1); +const kMaxZstdDParam = Math.max( + ...Object.entries(constants).map(([key, value]) => + key.startsWith("ZSTD_d_") ? value : 0, + ), +); +const zstdInitDParamsArray = new Int32Array(kMaxZstdDParam + 1); +const zstdDefaultOptions = { + flush: CONST_ZSTD_e_continue, + finishFlush: CONST_ZSTD_e_end, + fullFlush: CONST_ZSTD_e_flush, +}; +class Zstd extends ZlibBase { + constructor(options, mode, initParamsArray, maxParam) { + (0, import_node_assert.ok)( + mode === CONST_ZSTD_DECODE || mode === CONST_ZSTD_ENCODE, + ); + initParamsArray.fill(-1); + if (options?.params) { + for (const [origKey, value] of Object.entries(options.params)) { + const key = +origKey; + if ( + Number.isNaN(key) || + key < 0 || + key > maxParam || + (initParamsArray[key] | 0) !== -1 + ) { + throw new import_errors.ERR_ZSTD_INVALID_PARAM(origKey); + } + if (typeof value !== "number" && typeof value !== "boolean") { + throw new import_errors.ERR_INVALID_ARG_TYPE( + "options.params[key]", + "number", + value, + ); + } + initParamsArray[key] = value; + } + } + const handle = + mode === CONST_ZSTD_DECODE + ? new import_classic_binding.default.ZstdDecoder(mode) + : new import_classic_binding.default.ZstdEncoder(mode); + const _writeState = new Uint32Array(2); + const pledgedSrcSize = options?.pledgedSrcSize; + if ( + !handle.initialize( + initParamsArray, + _writeState, + () => { + queueMicrotask(processCallback.bind(handle)); + }, + pledgedSrcSize, + ) + ) { + throw new import_errors.ERR_ZLIB_INITIALIZATION_FAILED(); + } + super(options ?? {}, mode, handle, zstdDefaultOptions); + this._writeState = _writeState; + } +} + +module.exports = { + Zlib, + ZlibBase, + Brotli, + Zstd, + kMaxZstdCParam, + kMaxZstdDParam, + zstdInitCParamsArray, + zstdInitDParamsArray, +}; diff --git a/src/compat/zlib/classic-base.d.cts b/src/compat/zlib/classic-base.d.cts new file mode 100644 index 0000000..153dcae --- /dev/null +++ b/src/compat/zlib/classic-base.d.cts @@ -0,0 +1,39 @@ +import { Transform } from "node:stream"; +import { Buffer } from "node:buffer"; +declare class ZlibBase extends Transform { + bytesWritten: number; + bytesRead: number; + readonly _closed: boolean; + _finishFlushFlag: number; + _processChunk(input: ArrayBufferView | ArrayBuffer, flush: number): Buffer; + _processChunk( + input: ArrayBufferView | ArrayBuffer, + flush: number, + callback: () => void, + ): void; + flush(callback?: () => void): void; + flush(kind: number, callback?: () => void): void; + reset(): void; + close(callback?: () => void): void; +} +declare class Zlib extends ZlibBase { + constructor(options: any, mode: number); + params(level: number, strategy: number, callback: () => void): void; +} +declare class Brotli extends ZlibBase { + constructor(options: any, mode: number); +} +declare class Zstd extends ZlibBase { + constructor(options: any, mode: number, params: Int32Array, max: number); +} +declare const base: { + Zlib: typeof Zlib; + Brotli: typeof Brotli; + Zstd: typeof Zstd; + zstdInitCParamsArray: Int32Array; + zstdInitDParamsArray: Int32Array; + kMaxZstdCParam: number; + kMaxZstdDParam: number; +}; + +export = base; diff --git a/src/compat/zlib/classic-binding.ts b/src/compat/zlib/classic-binding.ts new file mode 100644 index 0000000..9246bda --- /dev/null +++ b/src/compat/zlib/classic-binding.ts @@ -0,0 +1,156 @@ +import { viewBytes } from "../../shared/buffer-source.js"; +import { bindingBuffers } from "./input.js"; +import * as codecs from "./codec-binding.js"; +import { Buffer } from "node:buffer"; +import { library, message } from "./native-zlib.js"; +import * as constants from "./constants.js"; +const codes: Record = { + 0: "Z_OK", + 1: "Z_STREAM_END", + 2: "Z_NEED_DICT", + [-1]: "Z_ERRNO", + [-2]: "Z_STREAM_ERROR", + [-3]: "Z_DATA_ERROR", + [-4]: "Z_MEM_ERROR", + [-5]: "Z_BUF_ERROR", + [-6]: "Z_VERSION_ERROR", +}; +export class ZlibStream { + private api = library(); + private context = 0; + private dictionarySize = 0; + private state!: Uint32Array; + private callback!: () => void; + private onerror?: (errno: number, code: string, message: string) => void; + constructor(readonly mode: number) {} + initialize( + windowBits: number, + level: number, + memLevel: number, + strategy: number, + state: Uint32Array, + callback: () => void, + dictionary?: ArrayBufferView, + ) { + this.state = state; + this.callback = callback; + const bytes = dictionary && Buffer.from(viewBytes(dictionary)); + this.dictionarySize = bytes?.length ?? 0; + const pointer = bytes ? this.api.malloc(bytes.length || 1) : 0; + try { + if (bytes) { + if (!pointer) throw new RangeError("Memory limit exceeded"); + new Uint8Array(this.api.memory.buffer).set(bytes, pointer); + } + this.context = this.api.cz_create( + this.mode, + windowBits, + Math.trunc(level), + Math.trunc(memLevel), + Math.trunc(strategy), + pointer, + bytes?.length ?? 0, + ); + if (!this.context) throw new Error("Zlib error"); + } finally { + if (pointer) this.api.free(pointer); + } + } + setErrorHandler( + handler: (errno: number, code: string, message: string) => void, + ) { + this.onerror = handler; + } + writeSync( + flush: number, + input: Uint8Array, + inputOffset: number, + inputLength: number, + output: Uint8Array, + outputOffset: number, + outputLength: number, + ) { + const api = this.api; + const bounds = bindingBuffers( + "ZlibContext", + input, + inputOffset, + inputLength, + output, + outputOffset, + outputLength, + ); + const buffer = bounds.source; + ({ inputOffset, inputLength, outputOffset, outputLength } = bounds); + const pointer = api.malloc(Math.max(1, inputLength) + outputLength + 8); + if (!pointer) throw new RangeError("Memory limit exceeded"); + const out = pointer + Math.max(1, inputLength), + state = out + outputLength; + try { + new Uint8Array(api.memory.buffer).set( + buffer.subarray(inputOffset, inputOffset + inputLength), + pointer, + ); + const result = api.cz_process( + this.context, + pointer, + inputLength, + out, + outputLength, + flush, + state, + ); + const view = new DataView(api.memory.buffer); + this.state[0] = view.getUint32(state, true); + this.state[1] = view.getUint32(state + 4, true); + bounds.target.set( + new Uint8Array(api.memory.buffer, out, outputLength - this.state[0]), + outputOffset, + ); + if ( + result !== 0 && + result !== 1 && + (result !== -5 || (flush === 4 && this.state[0] !== 0)) + ) + this.onerror?.( + result, + codes[result], + result === -5 + ? "unexpected end of file" + : result === 2 + ? this.dictionarySize === 0 + ? "Missing dictionary" + : "Bad dictionary" + : message(api, api.cz_message(this.context)), + ); + } finally { + api.free(pointer); + } + } + write(...args: Parameters) { + this.writeSync(...args); + this.callback(); + } + reset() { + const result = this.api.cz_reset(this.context); + if (result !== 0) + this.onerror?.(result, codes[result], "Failed to reset stream"); + } + params(level: number, strategy: number) { + const result = this.api.cz_params(this.context, level, strategy); + if (result !== 0) + this.onerror?.(result, codes[result], "Failed to set parameters"); + } + close() { + if (this.context) { + this.api.cz_free(this.context); + this.context = 0; + } + } +} +export default Object.assign( + { ZlibStream, ...codecs }, + Object.fromEntries( + Object.entries(constants).map(([name, value]) => ["CONST_" + name, value]), + ), +); diff --git a/src/compat/zlib/classic.ts b/src/compat/zlib/classic.ts new file mode 100644 index 0000000..30beb45 --- /dev/null +++ b/src/compat/zlib/classic.ts @@ -0,0 +1,309 @@ +import type { ZlibOptions, SyncCodec, AsyncCodec } from "./public-types.js"; +import { + optionInteger as integer, + codecBytes, + assertCodecInput, +} from "./input.js"; +import { isAnyBuffer } from "../../shared/buffer-source.js"; +import { Buffer } from "node:buffer"; +import { Zlib } from "./classic-base.cjs"; +import { ZlibStream } from "./classic-binding.js"; +import { ERR_INVALID_ARG_TYPE } from "../net/errors.js"; +export class Deflate extends Zlib { + constructor(options?: ZlibOptions) { + super(options, 1); + } +} +export class Inflate extends Zlib { + constructor(options?: ZlibOptions) { + super(options, 2); + } +} +export class Gzip extends Zlib { + constructor(options?: ZlibOptions) { + super(options, 3); + } +} +export class Gunzip extends Zlib { + constructor(options?: ZlibOptions) { + super(options, 4); + } +} +export class DeflateRaw extends Zlib { + constructor(options?: ZlibOptions) { + if (options?.windowBits === 8) options.windowBits = 9; + super(options, 5); + } +} +export class InflateRaw extends Zlib { + constructor(options?: ZlibOptions) { + super(options, 6); + } +} +export class Unzip extends Zlib { + constructor(options?: ZlibOptions) { + super(options, 7); + } +} +const constructors = [ + Deflate, + Inflate, + Gzip, + Gunzip, + DeflateRaw, + InflateRaw, + Unzip, +]; +export const createDeflate = (options?: ZlibOptions) => new Deflate(options); +export const createInflate = (options?: ZlibOptions) => new Inflate(options); +export const createGzip = (options?: ZlibOptions) => new Gzip(options); +export const createGunzip = (options?: ZlibOptions) => new Gunzip(options); +export const createDeflateRaw = (options?: ZlibOptions) => + new DeflateRaw(options); +export const createInflateRaw = (options?: ZlibOptions) => + new InflateRaw(options); +export const createUnzip = (options?: ZlibOptions) => new Unzip(options); +const maxChunk = 134217728; +function bytes(input: any, parameter = 1, method = "zlibSync"): Uint8Array { + if (typeof input === "string") return Buffer.from(input); + if (ArrayBuffer.isView(input) || isAnyBuffer(input)) return codecBytes(input); + throw new TypeError( + `Failed to execute '${method}' on 'ZlibUtil': parameter ${parameter} is not of type 'string or ArrayBuffer or ArrayBufferView'.`, + ); +} + +function bounded(value: number, name: string, min: number, max: number) { + if (value < min || value > max) + throw new RangeError( + `The value of "options.${name}" is out of range. It must be >= ${min} and <= ${max}. Received ${value}`, + ); + return value; +} +function dictionary(value: unknown): Uint8Array | undefined { + if (value === undefined) return undefined; + if (ArrayBuffer.isView(value) || isAnyBuffer(value)) return codecBytes(value); + throw new TypeError( + "Incorrect type for the 'dictionary' field on 'Options': the provided value is not of type 'ArrayBuffer or ArrayBufferView'.", + ); +} +function prepare(input: any, options: any, method = "zlibSync") { + const data = bytes(input, 1, method); + if (typeof options !== "object" || options === null) + throw new TypeError( + `Failed to execute '${method}' on 'ZlibUtil': parameter 2 is not of type 'Options'.`, + ); + // Conversion precedes native validation, including otherwise unused flush. + const opts = { + flush: integer(options.flush, 0, false), + finishFlush: integer(options.finishFlush, 4, false), + chunkSize: integer(options.chunkSize, 16384), + windowBits: integer(options.windowBits, 15), + level: integer(options.level, -1, false), + memLevel: integer(options.memLevel, 8), + strategy: integer(options.strategy, 0), + dictionary: dictionary(options.dictionary), + maxOutputLength: integer(options.maxOutputLength, maxChunk), + }; + return { data, opts }; +} +function fast( + mode: number, + data: Uint8Array, + opts: ReturnType["opts"], +) { + bounded(opts.chunkSize, "chunkSize", 64, maxChunk); + bounded(opts.maxOutputLength, "maxOutputLength", 1, maxChunk); + if (!(opts.windowBits === 0 && [2, 4, 7].includes(mode))) + bounded(opts.windowBits | 0, "windowBits", 8, 15); + bounded(opts.level, "level", -1, 9); + bounded(opts.memLevel | 0, "memLevel", 1, 9); + if (opts.strategy > 4) throw new Error("invalid strategy"); + if (mode === 3 && opts.windowBits === 8) throw new Error("Zlib error"); + bounded(opts.flush, "flush", 0, 6); + if (opts.finishFlush < 0 || opts.finishFlush > 6) + throw new RangeError( + `The value of "options.finishFlush" is out of range. It must be >= 0 and <= 6. Received ${opts.flush}`, + ); + assertCodecInput(data); + assertCodecInput(opts.dictionary); + const state = new Uint32Array(2), + handle = new ZlibStream(mode), + chunks: Buffer[] = []; + let error: Error | undefined, + offset = 0, + length = 0; + handle.initialize( + opts.windowBits, + opts.level, + opts.memLevel, + opts.strategy, + state, + () => {}, + opts.dictionary, + ); + handle.setErrorHandler((_errno, _code, message) => { + error = new Error(message); + }); + try { + for (;;) { + const size = Math.min(16384, opts.maxOutputLength - length + 1); + const output = Buffer.allocUnsafe(size); + handle.writeSync( + opts.finishFlush, + data, + offset, + data.length - offset, + output, + 0, + size, + ); + if (error) throw error; + offset = data.length - state[1]; + const produced = size - state[0]; + length += produced; + if (length > opts.maxOutputLength) + throw new RangeError("Memory limit exceeded"); + if (produced) chunks.push(output.subarray(0, produced)); + if (state[0] > 0) break; + } + return Buffer.concat(chunks, length); + } finally { + handle.close(); + } +} +function sync(mode: number, input: any, options: any = {}) { + if (options.info) { + const engine = new constructors[mode - 1](options); + return { + buffer: engine._processChunk( + typeof input === "string" ? Buffer.from(input) : input, + engine._finishFlushFlag, + ), + engine, + }; + } + const { data, opts } = prepare(input, options); + return fast(mode, data, opts); +} +function callbackOperation( + mode: number, + input: any, + options: any, + callback?: any, +) { + if (typeof options === "function") { + callback = options; + options = {}; + } + if (typeof callback !== "function") + throw new ERR_INVALID_ARG_TYPE("callback", "Function", callback); + if (options.info) { + const engine = new constructors[mode - 1](options); + let result: any, error: unknown; + try { + result = { + buffer: engine._processChunk( + typeof input === "string" ? Buffer.from(input) : input, + engine._finishFlushFlag, + ), + engine, + }; + } catch (e) { + error = e; + } + queueMicrotask(() => (error ? callback(error) : callback(null, result))); + return; + } + const { data, opts } = prepare(input, options, "zlib"); + let result: Buffer | undefined, error: unknown; + try { + result = fast(mode, data, opts); + } catch (e) { + error = e; + } + queueMicrotask(() => (error ? callback(error) : callback(null, result))); +} +export const deflateSync: SyncCodec = (( + input: any, + options?: any, +) => sync(1, input, options)) as SyncCodec; +export const inflateSync: SyncCodec = (( + input: any, + options?: any, +) => sync(2, input, options)) as SyncCodec; +export const gzipSync: SyncCodec = (( + input: any, + options?: any, +) => sync(3, input, options)) as SyncCodec; +export const gunzipSync: SyncCodec = (( + input: any, + options?: any, +) => sync(4, input, options)) as SyncCodec; +export const deflateRawSync: SyncCodec = (( + input: any, + options?: any, +) => sync(5, input, options)) as SyncCodec; +export const inflateRawSync: SyncCodec = (( + input: any, + options?: any, +) => sync(6, input, options)) as SyncCodec; +export const unzipSync: SyncCodec = (( + input: any, + options?: any, +) => sync(7, input, options)) as SyncCodec; +export const deflate: AsyncCodec = (( + input: any, + options: any, + callback?: any, +) => callbackOperation(1, input, options, callback)) as AsyncCodec< + ZlibOptions, + Deflate +>; +export const inflate: AsyncCodec = (( + input: any, + options: any, + callback?: any, +) => callbackOperation(2, input, options, callback)) as AsyncCodec< + ZlibOptions, + Inflate +>; +export const gzip: AsyncCodec = (( + input: any, + options: any, + callback?: any, +) => callbackOperation(3, input, options, callback)) as AsyncCodec< + ZlibOptions, + Gzip +>; +export const gunzip: AsyncCodec = (( + input: any, + options: any, + callback?: any, +) => callbackOperation(4, input, options, callback)) as AsyncCodec< + ZlibOptions, + Gunzip +>; +export const deflateRaw: AsyncCodec = (( + input: any, + options: any, + callback?: any, +) => callbackOperation(5, input, options, callback)) as AsyncCodec< + ZlibOptions, + DeflateRaw +>; +export const inflateRaw: AsyncCodec = (( + input: any, + options: any, + callback?: any, +) => callbackOperation(6, input, options, callback)) as AsyncCodec< + ZlibOptions, + InflateRaw +>; +export const unzip: AsyncCodec = (( + input: any, + options: any, + callback?: any, +) => callbackOperation(7, input, options, callback)) as AsyncCodec< + ZlibOptions, + Unzip +>; diff --git a/src/compat/zlib/codec-binding.ts b/src/compat/zlib/codec-binding.ts new file mode 100644 index 0000000..ffe1b6f --- /dev/null +++ b/src/compat/zlib/codec-binding.ts @@ -0,0 +1,213 @@ +import { bindingBuffers } from "./input.js"; +import { library, string } from "./native-codecs.js"; +class CodecBinding { + private api = library(); + private context = 0; + private state!: Uint32Array; + private callback!: () => void; + private onerror?: (errno: number, code: string, message: string) => void; + private params!: Uint32Array | Int32Array; + private pledged?: number; + private finished = false; + private frameInProgress = false; + constructor( + private readonly kind: "brotli" | "zstd", + private readonly encode: boolean, + ) {} + initialize( + params: Uint32Array | Int32Array, + state: Uint32Array, + callback: () => void, + pledged?: number, + ): boolean { + this.finished = false; + this.frameInProgress = false; + this.params = params.slice(); + this.state = state; + this.callback = callback; + this.pledged = pledged; + const api = this.api; + this.context = (this.kind === "brotli" ? api.b_create : api.z_create)( + +this.encode, + ); + if (!this.context) return false; + for (let key = 0; key < params.length; key++) + if ((params[key] | 0) !== -1) { + const result = (this.kind === "brotli" ? api.b_param : api.z_param)( + this.context, + +this.encode, + key, + params[key], + ); + if (this.kind === "brotli" ? !result : api.z_is_error(result)) { + this.close(); + return false; + } + } + if (pledged !== undefined && this.kind === "zstd" && this.encode) { + if ( + typeof pledged !== "number" || + !Number.isFinite(pledged) || + pledged < 0 || + pledged >= 18446744073709551616 + ) { + this.close(); + throw new TypeError( + "The value cannot be converted because it is not an integer.", + ); + } + if ( + api.z_is_error( + api.z_size64( + this.context, + pledged >>> 0, + Math.floor(pledged / 4294967296) >>> 0, + ), + ) + ) { + this.close(); + return false; + } + } + return true; + } + setErrorHandler( + handler: (errno: number, code: string, message: string) => void, + ) { + this.onerror = handler; + } + writeSync( + flush: number, + input: Uint8Array, + inputOffset: number, + inputLength: number, + output: Uint8Array, + outputOffset: number, + outputLength: number, + ) { + const bounds = bindingBuffers( + `${this.kind === "brotli" ? "Brotli" : "Zstd"}${this.encode ? "Encoder" : "Decoder"}Context`, + input, + inputOffset, + inputLength, + output, + outputOffset, + outputLength, + ); + ({ inputOffset, inputLength, outputOffset, outputLength } = bounds); + if ( + this.finished && + !this.encode && + (inputLength === 0 || this.kind === "brotli") + ) { + this.state[0] = outputLength; + this.state[1] = inputLength; + return; + } + const api = this.api, + buffer = bounds.source; + const pointer = api.malloc(Math.max(1, inputLength) + outputLength + 8); + if (!pointer) throw new RangeError("Memory limit exceeded"); + const out = pointer + Math.max(1, inputLength), + state = out + outputLength; + try { + new Uint8Array(api.memory.buffer).set( + buffer.subarray(inputOffset, inputOffset + inputLength), + pointer, + ); + const result = (this.kind === "brotli" ? api.b_process : api.z_process)( + this.context, + +this.encode, + pointer, + inputLength, + out, + outputLength, + flush, + state, + ); + this.finished = this.kind === "brotli" ? result === 1 : result === 0; + if (this.kind === "zstd" && !this.encode && inputLength > 0) + this.frameInProgress = result > 0; + const view = new DataView(api.memory.buffer); + const consumed = view.getUint32(state, true), + produced = view.getUint32(state + 4, true); + this.state[0] = outputLength - produced; + this.state[1] = inputLength - consumed; + bounds.target.set( + new Uint8Array(api.memory.buffer, out, produced), + outputOffset, + ); + if (this.kind === "brotli" && !result) { + const errno = this.encode ? 0 : api.b_error(this.context); + this.onerror?.( + errno, + this.encode + ? "ERR_BROTLI_COMPRESSION_FAILED" + : "ERR_" + string(api, api.b_error_string(errno)), + "Compression failed", + ); + } else if (this.kind === "zstd" && api.z_is_error(result)) + this.onerror?.( + -1, + this.encode + ? "ERR_ZSTD_COMPRESSION_FAILED" + : "ERR_ZSTD_DECOMPRESSION_FAILED", + `Zstd ${this.encode ? "compression" : "decompression"} failed: ${string(api, api.z_error(result))}`, + ); + else if ( + !this.encode && + flush === 2 && + produced < outputLength && + (this.kind === "brotli" ? result !== 1 : this.frameInProgress) + ) { + if (this.kind === "zstd") + this.onerror?.( + -1, + "ERR_ZSTD_DECOMPRESSION_FAILED", + "unexpected end of file", + ); + else this.onerror?.(-5, "Z_BUF_ERROR", "Unexpected end of file"); + } + } finally { + api.free(pointer); + } + } + write(...args: Parameters) { + this.writeSync(...args); + this.callback(); + } + reset() { + this.close(); + if (!this.initialize(this.params, this.state, this.callback, this.pledged)) + throw new Error("Initialization failed"); + } + close() { + if (this.context) { + (this.kind === "brotli" ? this.api.b_free : this.api.z_free)( + this.context, + +this.encode, + ); + this.context = 0; + } + } +} +export class BrotliEncoder extends CodecBinding { + constructor() { + super("brotli", true); + } +} +export class BrotliDecoder extends CodecBinding { + constructor() { + super("brotli", false); + } +} +export class ZstdEncoder extends CodecBinding { + constructor() { + super("zstd", true); + } +} +export class ZstdDecoder extends CodecBinding { + constructor() { + super("zstd", false); + } +} diff --git a/src/compat/zlib/compression.ts b/src/compat/zlib/compression.ts index ae37e32..49adaa6 100644 --- a/src/compat/zlib/compression.ts +++ b/src/compat/zlib/compression.ts @@ -1,290 +1,192 @@ +import type { + BrotliOptions, + ZstdOptions, + SyncCodec, + AsyncCodec, +} from "./public-types.js"; +import { + nativeNumber, + codecBytes, + assertCodecInput, + nativeInt32, + optionInteger as integer, +} from "./input.js"; +import { isAnyBuffer } from "../../shared/buffer-source.js"; import { Buffer } from "node:buffer"; -import { Transform } from "node:stream"; import { NativeCodec, type Kind } from "./native-codecs.js"; -import { validateFunction, validateObject } from "../net/validators.js"; -import { validateInteger } from "../crypto/validation.js"; -import { ERR_INVALID_ARG_TYPE, ERR_OUT_OF_RANGE } from "../net/errors.js"; -const flushMarker = Symbol("codec flush"); -function bytes(value: unknown): Uint8Array { - if (typeof value === "string") return Buffer.from(value); - if (ArrayBuffer.isView(value)) - return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); - if (value instanceof ArrayBuffer) return new Uint8Array(value); - throw new ERR_INVALID_ARG_TYPE( - "buffer", - ["string", "Buffer", "TypedArray", "DataView", "ArrayBuffer"], - value, - ); +import { ERR_INVALID_ARG_TYPE } from "../net/errors.js"; +import { + Brotli, + Zstd, + zstdInitCParamsArray, + zstdInitDParamsArray, + kMaxZstdCParam, + kMaxZstdDParam, +} from "./classic-base.cjs"; +export class BrotliCompress extends Brotli { + constructor(options?: BrotliOptions) { + super(options, 9); + } } -// Pinned workerd validators: NaN selects defaults; finite fractional values -// are accepted here and converted by the underlying buffer/codec operation. -function finite(value: unknown, name: string): value is number { - if (value === undefined || Number.isNaN(value)) return false; - if (typeof value !== "number") - throw new ERR_INVALID_ARG_TYPE(name, "number", value); - if (!Number.isFinite(value)) - throw new ERR_OUT_OF_RANGE(name, "a finite number", value); - return true; +export class BrotliDecompress extends Brotli { + constructor(options?: BrotliOptions) { + super(options, 8); + } } -function range( - value: unknown, - name: string, - low: number, - high: number, - fallback: number, -): number { - if (!finite(value, name)) return fallback; - if (value < low || value > high) - throw new ERR_OUT_OF_RANGE(name, `>= ${low} and <= ${high}`, value); - return value; +export class ZstdCompress extends Zstd { + constructor(options?: ZstdOptions) { + super(options, 10, zstdInitCParamsArray, kMaxZstdCParam); + } } -function settings(kind: Kind, encode: boolean, value: any = {}) { - if (value == null) value = {}; - validateObject(value, "options"); - const chunkSize = finite(value.chunkSize, "options.chunkSize") - ? value.chunkSize - : 16384; - if (chunkSize < 64) - throw new ERR_OUT_OF_RANGE("options.chunkSize", ">= 64", chunkSize); - const maxOutputLength = range( - value.maxOutputLength, - "options.maxOutputLength", - 1, - 2147483647, - 2147483647, - ); - const params = value.params ?? {}; - validateObject(params, "options.params"); - const maximum = kind === "brotli" ? 8 : encode ? 402 : 100; - const seen = new Set(); - for (const [key, val] of Object.entries(params)) { - const number = Number(key); - if ( - !Number.isInteger(number) || - number < 0 || - number > maximum || - seen.has(number) - ) - throw Object.assign( - new RangeError( - `${key} is not a valid ${kind === "brotli" ? "Brotli" : "Zstd"} parameter`, - ), - { - code: - kind === "brotli" - ? "ERR_BROTLI_INVALID_PARAM" - : "ERR_ZSTD_INVALID_PARAM", - }, - ); - seen.add(number); - if (typeof val !== "number" && typeof val !== "boolean") - throw new ERR_INVALID_ARG_TYPE("options.params[key]", "number", val); +export class ZstdDecompress extends Zstd { + constructor(options?: ZstdOptions) { + super(options, 11, zstdInitDParamsArray, kMaxZstdDParam); } - const flush = range( - value.flush, - "options.flush", - 0, - kind === "brotli" ? 3 : 2, - 0, - ); - const finishFlush = range( - value.finishFlush, - "options.finishFlush", - 0, - kind === "brotli" ? 3 : 2, - 2, +} +export const createBrotliCompress = (options?: BrotliOptions) => + new BrotliCompress(options); +export const createBrotliDecompress = (options?: BrotliOptions) => + new BrotliDecompress(options); +export const createZstdCompress = (options?: ZstdOptions) => + new ZstdCompress(options); +export const createZstdDecompress = (options?: ZstdOptions) => + new ZstdDecompress(options); + +function bytes(value: unknown, method: string): Uint8Array { + if (typeof value === "string") return Buffer.from(value); + if (ArrayBuffer.isView(value) || isAnyBuffer(value)) return codecBytes(value); + throw new TypeError( + `Failed to execute '${method}' on 'ZlibUtil': parameter 1 is not of type 'string or ArrayBuffer or ArrayBufferView'.`, ); - if (value.pledgedSrcSize !== undefined) - validateInteger( - value.pledgedSrcSize, - "options.pledgedSrcSize", - 0, - 0xffffffff, +} + +function pledged(value: any) { + if (value === undefined) return undefined; + const number = nativeNumber(value); + if (!Number.isFinite(number) || number < 0 || number >= 18446744073709551616) + throw new TypeError( + "The value cannot be converted because it is not an integer.", + ); + return Math.trunc(number); +} +function convert(options: any, kind: Kind) { + if (!options || typeof options !== "object") + throw new TypeError("Options must be an object"); + const flush = integer(options.flush, 0, false); + const finishFlush = integer(options.finishFlush, 2, false); + const chunkSize = integer(options.chunkSize, 16384); + const parameters = options.params; + if ( + parameters !== undefined && + (!parameters || typeof parameters !== "object") + ) + throw new TypeError( + "Incorrect type for the 'params' field on 'Options': the provided value is not of type 'object'.", ); + const params: Record = Object.create(null); + for (const key of Object.keys(parameters ?? {})) + params[key] = nativeInt32(parameters[key]); + const maxOutputLength = integer(options.maxOutputLength, 134217728); return { - chunkSize, - maxOutputLength, - params, flush, finishFlush, - pledged: value.pledgedSrcSize, - info: !!value.info, + chunkSize, + params, + maxOutputLength, + pledged: kind === "zstd" ? pledged(options.pledgedSrcSize) : undefined, }; } -class CodecTransform extends Transform { - private codec: NativeCodec; - private readonly config: ReturnType; - bytesWritten = 0; - private readonly kind: Kind; - private readonly encode: boolean; - constructor(kind: Kind, encode: boolean, options?: any) { - const config = settings(kind, encode, options); - super(options ?? {}); - this.kind = kind; - this.encode = encode; - this.config = config; - this.codec = new NativeCodec( - kind, - encode, - config.chunkSize, - config.params, - config.pledged, + +function bounded(value: number, name: string, min: number, max: number) { + if (value < min || value > max) + throw new RangeError( + `The value of "options.${name}" is out of range. It must be >= ${min} and <= ${max}. Received ${value}`, ); - } - _transform(input: any, _encoding: any, callback: any) { - try { - const operation = input[flushMarker] ?? this.config.flush; - this.codec.process(bytes(input), operation, (output) => - this.push(Buffer.from(output)), - ); - this.bytesWritten = this.codec.consumed; - callback(); - } catch (error) { - callback(error); - } - } - _flush(callback: any) { - try { - this.codec.process(new Uint8Array(), this.config.finishFlush, (output) => - this.push(Buffer.from(output)), - ); - this.bytesWritten = this.codec.consumed; - callback(); - } catch (error) { - callback(error); - } - } - _destroy(error: any, callback: any) { - this.codec.close(); - callback(error); - } - flush(kind?: number | (() => void), callback?: () => void) { - if (typeof kind === "function") { - callback = kind; - kind = 1; - } - kind ??= 1; - validateInteger(kind, "kind", 0, this.kind === "brotli" ? 3 : 2); - if (callback !== undefined) validateFunction(callback, "callback"); - if (this.writableFinished) { - if (callback) queueMicrotask(callback); - return; - } - const marker: any = Buffer.alloc(0); - marker[flushMarker] = kind; - this.write(marker, callback); - } - reset() { - if (this.destroyed) throw new Error("Codec is closed"); - this.codec.close(); - this.codec = new NativeCodec( - this.kind, - this.encode, - this.config.chunkSize, - this.config.params, - this.config.pledged, - ); - this.bytesWritten = 0; - } - close(callback?: () => void) { - if (callback) { - validateFunction(callback, "callback"); - this.once("close", callback); - } - this.destroy(); - } - _processChunk(input: unknown, operation = 2) { - const chunks: Uint8Array[] = []; - let length = 0; - try { - this.codec.process(bytes(input), operation, (output) => { - length += output.length; - if (length > this.config.maxOutputLength) - throw Object.assign( - new RangeError( - "Cannot create a Buffer larger than maxOutputLength", - ), - { code: "ERR_BUFFER_TOO_LARGE" }, - ); - chunks.push(output); - }); - this.bytesWritten = this.codec.consumed; - return Buffer.concat(chunks, length); - } finally { - this.codec.close(); - } - } } -export class BrotliCompress extends CodecTransform { - constructor(options?: any) { - super("brotli", true, options); - } -} -export class BrotliDecompress extends CodecTransform { - constructor(options?: any) { - super("brotli", false, options); - } -} -export class ZstdCompress extends CodecTransform { - constructor(options?: any) { - super("zstd", true, options); - } -} -export class ZstdDecompress extends CodecTransform { - constructor(options?: any) { - super("zstd", false, options); - } -} -export const createBrotliCompress = (options?: any) => - new BrotliCompress(options); -export const createBrotliDecompress = (options?: any) => - new BrotliDecompress(options); -export const createZstdCompress = (options?: any) => new ZstdCompress(options); -export const createZstdDecompress = (options?: any) => - new ZstdDecompress(options); -function sync(kind: Kind, encode: boolean, input: unknown, options?: any) { - const data = bytes(input), - config = settings(kind, encode, options); - if (config.info) { - const Constructor = - kind === "brotli" - ? encode - ? BrotliCompress - : BrotliDecompress - : encode - ? ZstdCompress - : ZstdDecompress; - const engine = new Constructor(options); - return { buffer: engine._processChunk(data, config.finishFlush), engine }; - } +function fast( + kind: Kind, + encode: boolean, + data: Uint8Array, + config: ReturnType, +) { + bounded(config.chunkSize, "chunkSize", 64, 134217728); + bounded(config.maxOutputLength, "maxOutputLength", 1, 134217728); + for (const key of Object.keys(config.params)) + if (!/^[+-]?\d+$/.test(key)) throw new Error("Invalid parameter name"); const codec = new NativeCodec( kind, encode, - config.chunkSize, + Math.min(16384, config.maxOutputLength), config.params, config.pledged ?? (encode && kind === "zstd" ? data.length : undefined), ); const chunks: Uint8Array[] = []; let length = 0; try { + bounded(config.flush, "flush", 0, kind === "brotli" ? 3 : 2); + bounded(config.finishFlush, "finishFlush", 0, kind === "brotli" ? 3 : 2); + assertCodecInput(data); codec.process(data, config.finishFlush, (output) => { length += output.length; if (length > config.maxOutputLength) - throw new RangeError("Memory limit exceeded"); + throw kind === "zstd" && encode + ? new Error("Unexpected end of file") + : new RangeError("Memory limit exceeded"); chunks.push(output); }); return Buffer.concat(chunks, length); } catch (error) { if ( + error instanceof TypeError || error instanceof RangeError || - (error instanceof Error && error.message === "Unexpected end of file") + (error instanceof Error && + ["Unexpected end of file", "unexpected end of file"].includes( + error.message, + )) ) throw error; + if ( + kind === "zstd" && + !encode && + (error as { code?: string })?.code === "ZSTD_ERROR" + ) + throw new Error(`Zstd decompression failed: ${(error as Error).message}`); throw new Error("Compression failed"); } finally { codec.close(); } } +function engine(kind: Kind, encode: boolean, options: any) { + return new ( + kind === "brotli" + ? encode + ? BrotliCompress + : BrotliDecompress + : encode + ? ZstdCompress + : ZstdDecompress + )(options); +} +function sync(kind: Kind, encode: boolean, input: unknown, options: any = {}) { + if (options.info) { + const instance = engine(kind, encode, options); + return { + buffer: instance._processChunk( + typeof input === "string" + ? Buffer.from(input) + : (input as ArrayBufferView), + instance._finishFlushFlag, + ), + engine: instance, + }; + } + return fast( + kind, + encode, + bytes(input, `${kind}${encode ? "Compress" : "Decompress"}Sync`), + convert(options, kind), + ); +} function asyncOperation( kind: Kind, encode: boolean, @@ -294,41 +196,91 @@ function asyncOperation( ) { if (typeof options === "function") { callback = options; - options = undefined; + options = {}; } - validateFunction(callback, "callback"); - const data = bytes(input); - settings(kind, encode, options); - new Promise((resolve) => resolve(sync(kind, encode, data, options))).then( - (result) => callback(null, result), - (error) => callback(error), - ); -} -export function brotliCompressSync(input: unknown, options?: any) { - return sync("brotli", true, input, options); -} -export function brotliDecompressSync(input: unknown, options?: any) { - return sync("brotli", false, input, options); -} -export function zstdCompressSync(input: unknown, options?: any) { - return sync("zstd", true, input, options); -} -export function zstdDecompressSync(input: unknown, options?: any) { - return sync("zstd", false, input, options); -} -export function brotliCompress(input: unknown, options?: any, callback?: any) { - asyncOperation("brotli", true, input, options, callback); + if (typeof callback !== "function") + throw new ERR_INVALID_ARG_TYPE("callback", "Function", callback); + let run: () => unknown; + if (options.info) { + const instance = engine(kind, encode, options); + run = () => ({ + buffer: instance._processChunk( + typeof input === "string" + ? Buffer.from(input) + : (input as ArrayBufferView), + instance._finishFlushFlag, + ), + engine: instance, + }); + } else { + const data = bytes(input, `${kind}${encode ? "Compress" : "Decompress"}`), + config = convert(options, kind); + run = () => fast(kind, encode, data, config); + } + let result: unknown, error: unknown; + try { + result = run(); + } catch (failure) { + error = failure; + } + queueMicrotask(() => (error ? callback(error) : callback(null, result))); } -export function brotliDecompress( +export const brotliCompressSync: SyncCodec = (( + input: unknown, + options?: any, +) => sync("brotli", true, input, options)) as SyncCodec< + BrotliOptions, + BrotliCompress +>; +export const brotliDecompressSync: SyncCodec = + ((input: unknown, options?: any) => + sync("brotli", false, input, options)) as SyncCodec< + BrotliOptions, + BrotliDecompress + >; +export const zstdCompressSync: SyncCodec = (( + input: unknown, + options?: any, +) => sync("zstd", true, input, options)) as SyncCodec< + ZstdOptions, + ZstdCompress +>; +export const zstdDecompressSync: SyncCodec = (( + input: unknown, + options?: any, +) => sync("zstd", false, input, options)) as SyncCodec< + ZstdOptions, + ZstdDecompress +>; +export const brotliCompress: AsyncCodec = (( input: unknown, options?: any, callback?: any, -) { - asyncOperation("brotli", false, input, options, callback); -} -export function zstdCompress(input: unknown, options?: any, callback?: any) { - asyncOperation("zstd", true, input, options, callback); -} -export function zstdDecompress(input: unknown, options?: any, callback?: any) { - asyncOperation("zstd", false, input, options, callback); -} +) => asyncOperation("brotli", true, input, options, callback)) as AsyncCodec< + BrotliOptions, + BrotliCompress +>; +export const brotliDecompress: AsyncCodec = (( + input: unknown, + options?: any, + callback?: any, +) => asyncOperation("brotli", false, input, options, callback)) as AsyncCodec< + BrotliOptions, + BrotliDecompress +>; +export const zstdCompress: AsyncCodec = (( + input: unknown, + options?: any, + callback?: any, +) => asyncOperation("zstd", true, input, options, callback)) as AsyncCodec< + ZstdOptions, + ZstdCompress +>; +export const zstdDecompress: AsyncCodec = (( + input: unknown, + options?: any, + callback?: any, +) => asyncOperation("zstd", false, input, options, callback)) as AsyncCodec< + ZstdOptions, + ZstdDecompress +>; diff --git a/src/compat/zlib/errors.ts b/src/compat/zlib/errors.ts new file mode 100644 index 0000000..f01c48c --- /dev/null +++ b/src/compat/zlib/errors.ts @@ -0,0 +1,33 @@ +export { ERR_INVALID_ARG_TYPE, ERR_OUT_OF_RANGE } from "../net/errors.js"; +export class NodeError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + } +} +export class ERR_BUFFER_TOO_LARGE extends RangeError { + readonly code = "ERR_BUFFER_TOO_LARGE"; + constructor(size: number) { + super(`Cannot create a Buffer larger than ${size} bytes`); + } +} +export class ERR_BROTLI_INVALID_PARAM extends RangeError { + readonly code = "ERR_BROTLI_INVALID_PARAM"; + constructor(key: string) { + super(`${key} is not a valid Brotli parameter`); + } +} +export class ERR_ZSTD_INVALID_PARAM extends RangeError { + readonly code = "ERR_ZSTD_INVALID_PARAM"; + constructor(key: string) { + super(`${key} is not a valid Zstd parameter`); + } +} +export class ERR_ZLIB_INITIALIZATION_FAILED extends Error { + readonly code = "ERR_ZLIB_INITIALIZATION_FAILED"; + constructor() { + super("Initialization failed"); + } +} diff --git a/src/compat/zlib/index.ts b/src/compat/zlib/index.ts index 28e3c0e..c3f854a 100644 --- a/src/compat/zlib/index.ts +++ b/src/compat/zlib/index.ts @@ -1,42 +1,45 @@ +export type { + CodecInput, + CodecOptions, + ZlibOptions, + BrotliOptions, + ZstdOptions, + CodecInfo, + CodecCallback, +} from "./public-types.js"; +import type { CodecInput } from "./public-types.js"; +import { + viewBytes, + isAnyBuffer, + bufferBytes, +} from "../../shared/buffer-source.js"; import { Buffer } from "node:buffer"; -// @ts-expect-error browserify-zlib has no declarations -import original from "browserify-zlib/lib/index.js"; -import { validateInteger } from "../crypto/validation.js"; -import { ERR_INVALID_ARG_TYPE } from "../net/errors.js"; +import { validateUint32 } from "../net/validators.js"; import * as compression from "./compression.js"; export * from "./compression.js"; -export const { - Zlib, - codes, - Deflate, - Inflate, - Gzip, - Gunzip, - DeflateRaw, - InflateRaw, - Unzip, - createDeflate, - createInflate, - createDeflateRaw, - createInflateRaw, - createGzip, - createGunzip, - createUnzip, - deflate, - deflateSync, - gzip, - gzipSync, - deflateRaw, - deflateRawSync, - unzip, - unzipSync, - inflate, - inflateSync, - gunzip, - gunzipSync, - inflateRaw, - inflateRawSync, -} = original; +import * as classic from "./classic.js"; +export * from "./classic.js"; +export const codes = Object.freeze( + Object.fromEntries( + [ + "Z_OK", + "Z_STREAM_END", + "Z_NEED_DICT", + "Z_ERRNO", + "Z_STREAM_ERROR", + "Z_DATA_ERROR", + "Z_MEM_ERROR", + "Z_BUF_ERROR", + "Z_VERSION_ERROR", + ].flatMap((name) => { + const value = pinnedConstants[name as keyof typeof pinnedConstants]; + return [ + [name, value], + [value, name], + ]; + }), + ), +); import * as pinnedConstants from "./constants.js"; const topLevelConstants = { Z_NO_FLUSH: pinnedConstants.Z_NO_FLUSH, @@ -370,17 +373,14 @@ export const constants = Object.freeze({ ...pinnedConstants }); function data(input: unknown): Uint8Array { if (typeof input === "string") return Buffer.from(input); - if (ArrayBuffer.isView(input)) - return new Uint8Array(input.buffer, input.byteOffset, input.byteLength); - if (input instanceof ArrayBuffer) return new Uint8Array(input); - throw new ERR_INVALID_ARG_TYPE( - "buffer", - ["string", "Buffer", "TypedArray", "DataView", "ArrayBuffer"], - input, + if (ArrayBuffer.isView(input)) return viewBytes(input); + if (isAnyBuffer(input)) return bufferBytes(input); + throw new TypeError( + "Failed to execute 'crc32' on 'ZlibUtil': parameter 1 is not of type 'string or ArrayBuffer or ArrayBufferView'.", ); } -export function crc32(input: unknown, value = 0): number { - validateInteger(value, "value", 0, 0xffffffff); +export function crc32(input: CodecInput, value = 0): number { + validateUint32(value, "value"); let crc = value ^ -1; for (const byte of data(input)) { crc ^= byte; @@ -390,7 +390,8 @@ export function crc32(input: unknown, value = 0): number { return (crc ^ -1) >>> 0; } export default { - ...original, + codes, + ...classic, ...topLevelConstants, constants, ...compression, diff --git a/src/compat/zlib/input.spec.ts b/src/compat/zlib/input.spec.ts new file mode 100644 index 0000000..6676991 --- /dev/null +++ b/src/compat/zlib/input.spec.ts @@ -0,0 +1,138 @@ +import { expect, test } from "vitest"; +import { bindingBuffers, nativeNumber } from "./input.js"; + +test("codec input bounds reject lengths larger than the intrinsic view", () => { + const input = new Uint8Array([65]); + Object.defineProperty(input, "byteLength", { value: 32 }); + expect(() => + bindingBuffers("ZlibContext", input, 0, 32, new Uint8Array(64), 0, 64), + ).toThrow("Input access is not within bounds"); + expect( + bindingBuffers("ZlibContext", input, 0, 0, new Uint8Array(64), 0, 64) + .inputLength, + ).toBe(0); +}); + +test("codec bounds cover output extents and uint32 addition overflow", () => { + const input = new Uint8Array(1), + output = new Uint8Array(1); + Object.defineProperty(output, "byteLength", { value: 64 }); + expect(() => + bindingBuffers("ZlibContext", input, 0, 1, output, 0, 64), + ).toThrow("Output access is not within bounds"); + expect(() => + bindingBuffers("ZlibContext", input, 4294967295, 1, output, 0, 1), + ).toThrow("Input access is not within bounds"); + expect(() => + bindingBuffers("ZlibContext", input, 0, 1, output, 4294967295, 1), + ).toThrow("Output access is not within bounds"); +}); + +test("codec buffer conversion validates brands before accessing native memory", () => { + const output = new Uint8Array(1); + expect(() => bindingBuffers("ZlibContext", {}, 0, 1, output, 0, 1)).toThrow( + TypeError, + ); + expect(() => bindingBuffers("ZlibContext", output, 0, 1, {}, 0, 1)).toThrow( + TypeError, + ); + for (const length of [-1, NaN, Infinity, 4294967296]) + expect(() => + bindingBuffers("ZlibContext", output, 0, length, output, 0, 1), + ).toThrow(TypeError); +}); + +test("native numeric conversion rejects BigInt without repeated coercion", () => { + let reads = 0; + const object = { + valueOf() { + reads++; + return 0n; + }, + }; + for (const value of [ + 0n, + object, + { + [Symbol.toPrimitive]() { + return 0n; + }, + }, + ]) + expect(() => nativeNumber(value)).toThrow( + "Cannot convert a BigInt value to a number", + ); + expect(reads).toBe(1); + expect(() => nativeNumber(Symbol())).toThrow( + "Cannot convert a Symbol value to a number", + ); + expect( + nativeNumber({ + valueOf() { + return "1.5"; + }, + }), + ).toBe(1.5); +}); + +// Empty buffers must be distinguished from detached buffers, even when their +// byteLength is identical. RAB snapshots and fixed aliases have different lives. +import { codecBytes, assertCodecInput } from "./input.js"; +const ResizableBuffer = ArrayBuffer as unknown as new ( + length: number, + options?: { maxByteLength: number }, +) => ArrayBuffer & { resize(size: number): void }; +const GrowableBuffer = SharedArrayBuffer as unknown as new ( + length: number, + options?: { maxByteLength: number }, +) => SharedArrayBuffer & { grow(size: number): void }; +test("codec input retention distinguishes empty and detached fixed buffers", () => { + for (const length of [0, 3]) { + const buffer = new ArrayBuffer(length); + const captured = codecBytes(buffer); + expect(() => assertCodecInput(captured)).not.toThrow(); + structuredClone(buffer, { transfer: [buffer] }); + expect(() => assertCodecInput(captured)).toThrow( + "Codec input was detached during option conversion", + ); + // Already detached at entry is the Workers empty-input conversion. + expect(() => assertCodecInput(codecBytes(buffer))).not.toThrow(); + } +}); +test("codec conversion snapshots resizable memory and retains fixed input mutations", () => { + for (const resizable of [false, true]) { + const buffer = resizable + ? new ResizableBuffer(3, { maxByteLength: 6 }) + : new ResizableBuffer(3); + const view = new Uint8Array(buffer); + view.set([97, 98, 99]); + const captured = codecBytes(view); + view[0] = 120; + expect(Array.from(captured)).toEqual( + resizable ? [97, 98, 99] : [120, 98, 99], + ); + if (resizable) { + buffer.resize(0); + structuredClone(buffer, { transfer: [buffer] }); + expect(() => assertCodecInput(captured)).not.toThrow(); + expect(Array.from(captured)).toEqual([97, 98, 99]); + } + } +}); + +test("codec conversion snapshots growable shared memory but retains fixed shared mutations", () => { + for (const growable of [false, true]) { + const buffer = growable + ? new GrowableBuffer(3, { maxByteLength: 6 }) + : new GrowableBuffer(3); + const view = new Uint8Array(buffer); + view.set([97, 98, 99]); + const captured = codecBytes(view); + view[0] = 120; + expect(Array.from(captured)).toEqual( + growable ? [97, 98, 99] : [120, 98, 99], + ); + if (growable) buffer.grow(6); + expect(() => assertCodecInput(captured)).not.toThrow(); + } +}); diff --git a/src/compat/zlib/input.ts b/src/compat/zlib/input.ts new file mode 100644 index 0000000..f02155b --- /dev/null +++ b/src/compat/zlib/input.ts @@ -0,0 +1,163 @@ +import { + viewBytes, + isAnyBuffer, + bufferBytes, +} from "../../shared/buffer-source.js"; + +const resizable = Object.getOwnPropertyDescriptor( + ArrayBuffer.prototype, + "resizable", +)?.get; +const growable = + typeof SharedArrayBuffer === "undefined" + ? undefined + : Object.getOwnPropertyDescriptor(SharedArrayBuffer.prototype, "growable") + ?.get; +/** JSG snapshots resizable backing stores but retains fixed stores through option conversion. */ +export function codecBytes( + value: ArrayBufferLike | ArrayBufferView, +): Uint8Array { + const view = ArrayBuffer.isView(value) + ? viewBytes(value) + : bufferBytes(value); + if (resizable) { + try { + if (resizable.call(view.buffer)) return view.slice(); + } catch { + /* SharedArrayBuffer */ + } + } + if (growable) { + try { + if (growable.call(view.buffer)) return view.slice(); + } catch { + /* ArrayBuffer */ + } + } + return view; +} +export function assertCodecInput(value: Uint8Array | undefined): void { + if (!value) return; + try { + new Uint8Array(value.buffer, 0, 0); + } catch { + throw new TypeError("Codec input was detached during option conversion"); + } +} + +export function bindingInput( + value: unknown, + context: string, + parameter = 2, +): Uint8Array { + if (ArrayBuffer.isView(value)) return viewBytes(value); + if (isAnyBuffer(value)) return bufferBytes(value); + throw new TypeError( + `Failed to execute 'writeSync' on '${context}>': parameter ${parameter} is not of type 'BufferSource'.`, + ); +} +export function nativeNumber(value: any): number { + const object = (item: any) => + item !== null && (typeof item === "object" || typeof item === "function"); + if (object(value)) { + const exotic = value[Symbol.toPrimitive]; + if (exotic != null) { + value = Reflect.apply(exotic, value, ["number"]); + if (object(value)) + throw new TypeError("Cannot convert object to primitive value"); + } else { + const original = value; + for (const name of ["valueOf", "toString"]) { + const method = original[name]; + if (typeof method !== "function") continue; + value = Reflect.apply(method, original, []); + if (!object(value)) break; + } + if (object(value)) + throw new TypeError("Cannot convert object to primitive value"); + } + } + if (typeof value === "bigint") + throw new TypeError("Cannot convert a BigInt value to a number"); + if (typeof value === "symbol") + throw new TypeError("Cannot convert a Symbol value to a number"); + return Number(value); +} +export function nativeInt32(value: unknown): number { + const number = nativeNumber(value); + // JSG signed integers use V8 Int32Value for non-finite values. + if (!Number.isFinite(number)) return 0; + if (number < -2147483648 || number > 2147483647) + throw new TypeError( + "Value out of range. Must be between -2147483648 and 2147483647 (inclusive).", + ); + return Math.trunc(number); +} +export function optionInteger( + value: unknown, + fallback: number, + unsigned = true, +): number { + return value === undefined + ? fallback + : unsigned + ? nativeUint32(value) + : nativeInt32(value); +} +export function nativeUint32(value: unknown): number { + const number = nativeNumber(value); + if (!Number.isFinite(number)) + throw new TypeError( + "The value cannot be converted because it is not an integer.", + ); + if (number < 0) + throw new TypeError( + "The value cannot be converted because it is negative and this API expects a positive number.", + ); + if (number > 4294967295) + throw new TypeError( + "Value out of range. Must be less than or equal to 4294967295.", + ); + return Math.trunc(number); +} +export function bindingBuffers( + context: string, + input: unknown, + inputOffset: number, + inputLength: number, + output: unknown, + outputOffset: number, + outputLength: number, +) { + const source = + input === undefined ? new Uint8Array(0) : bindingInput(input, context); + inputOffset = nativeUint32(inputOffset); + inputLength = nativeUint32(inputLength); + const target = bindingInput(output, context, 5); + outputOffset = nativeUint32(outputOffset); + outputLength = nativeUint32(outputLength); + if (input === undefined) inputOffset = inputLength = 0; + if ( + inputOffset + inputLength > 4294967295 || + inputOffset > source.byteLength || + inputLength > source.byteLength - inputOffset + ) + throw new Error("Input access is not within bounds"); + if ( + outputOffset + outputLength > 4294967295 || + outputOffset > target.byteLength || + outputLength > target.byteLength - outputOffset + ) + throw new Error("Output access is not within bounds"); + // Emscripten's wasm32 malloc must not receive a wrapped allocation length. + if (Math.max(1, inputLength) + outputLength + 8 > 0x7fffffff) + throw new RangeError("Memory limit exceeded"); + return { + source, + target, + inputOffset, + inputLength, + outputOffset, + outputLength, + }; +} diff --git a/src/compat/zlib/native-codecs.build.json b/src/compat/zlib/native-codecs.build.json index 2d4d5d2..fa83ee4 100644 --- a/src/compat/zlib/native-codecs.build.json +++ b/src/compat/zlib/native-codecs.build.json @@ -4,5 +4,6 @@ "zstd": "f8745da6ff1ad1e7bab384bd1f9d742439278e99", "brotli": "ed738e842d2fbdf2d6459e39267a633c4a9b2f5d" }, - "sha256": "52f2d0edcc0193e5289a0e9b945e2b1a2de8b6ea3efadf11ebd60b88c53d11a4" + "bindingSha256": "de7a1c0cb1f58ec553a00b574e08ff21626bcbb134050da90ca5a46dc66d164a", + "sha256": "c20e4002af3f5482cf2b022034e641e2ee2e8623023971de5176c2d5144946b3" } diff --git a/src/compat/zlib/native-codecs.c b/src/compat/zlib/native-codecs.c index eb9bc82..2269c96 100644 --- a/src/compat/zlib/native-codecs.c +++ b/src/compat/zlib/native-codecs.c @@ -24,6 +24,7 @@ API size_t z_process(void* ctx, int encode, const void* src, size_t len, void* d } API int z_is_error(size_t code) { return ZSTD_isError(code); } API const char* z_error(size_t code) { return ZSTD_getErrorName(code); } +API size_t z_size64(void* ctx, uint32_t low, uint32_t high) { return ZSTD_CCtx_setPledgedSrcSize(ctx, ((uint64_t)high << 32) | low); } API size_t z_size(void* ctx, uint32_t length) { return ZSTD_CCtx_setPledgedSrcSize(ctx, length); } API void* b_create(int encode) { return encode ? (void*)BrotliEncoderCreateInstance(NULL, NULL, NULL) : (void*)BrotliDecoderCreateInstance(NULL, NULL, NULL); } diff --git a/src/compat/zlib/native-codecs.ts b/src/compat/zlib/native-codecs.ts index f4b2acf..43011e4 100644 --- a/src/compat/zlib/native-codecs.ts +++ b/src/compat/zlib/native-codecs.ts @@ -3,7 +3,7 @@ type API = Record number> & { memory: WebAssembly.Memory; }; let api: API | undefined; -function library(): API { +export function library(): API { if (!api) { const module = new WebAssembly.Module(binary as BufferSource); api = new WebAssembly.Instance(module, { @@ -21,7 +21,7 @@ function library(): API { } return api; } -function string(api: API, pointer: number) { +export function string(api: API, pointer: number) { const bytes = new Uint8Array(api.memory.buffer); let end = pointer; while (bytes[end]) end++; @@ -35,6 +35,7 @@ export class NativeCodec { private output: number; private closed = false; finished = false; + private frameInProgress = false; consumed = 0; constructor( readonly kind: Kind, @@ -60,12 +61,20 @@ export class NativeCodec { Number(value), ); if (kind === "brotli" ? !result : api.z_is_error(result)) - throw Object.assign(new Error("Initialization failed"), { - code: "ERR_ZLIB_INITIALIZATION_FAILED", - }); + throw new Error( + kind === "brotli" + ? "Setting parameter failed" + : "Setting parameter failed: " + string(api, api.z_error(result)), + ); } if (pledged !== undefined && kind === "zstd" && encode) - this.zcheck(api.z_size(this.ctx, pledged)); + this.zcheck( + api.z_size64( + this.ctx, + pledged >>> 0, + Math.floor(pledged / 4294967296) >>> 0, + ), + ); } catch (error) { this.close(); throw error; @@ -126,12 +135,11 @@ export class NativeCodec { new Uint8Array(api.memory.buffer, this.output, produced).slice(), ); this.finished = this.kind === "brotli" ? result === 1 : result === 0; - if ( - !this.encode && - this.finished && - (this.kind === "brotli" || offset === input.length) - ) - break; + if (this.kind === "zstd" && !this.encode && input.length > 0) + this.frameInProgress = result > 0; + // Workers' fast helper stops after the first completed frame. The + // stream binding separately permits concatenated frames. + if (!this.encode && this.finished) break; if (this.encode && operation !== 0 && this.finished) break; if (offset === input.length && produced < this.chunkSize) { if (this.encode && operation === 2 && !this.finished) { @@ -139,8 +147,17 @@ export class NativeCodec { throw new Error("Codec made no progress"); continue; } - if (!this.encode && operation === 2 && !this.finished) - throw new Error("Unexpected end of file"); + if ( + !this.encode && + operation === 2 && + !this.finished && + (this.kind === "brotli" || this.frameInProgress) + ) + throw new Error( + this.kind === "zstd" + ? "unexpected end of file" + : "Unexpected end of file", + ); break; } if (!consumed && !produced) throw new Error("Codec made no progress"); diff --git a/src/compat/zlib/native-codecs.wasm b/src/compat/zlib/native-codecs.wasm index a3cdd9c..684db3b 100755 Binary files a/src/compat/zlib/native-codecs.wasm and b/src/compat/zlib/native-codecs.wasm differ diff --git a/src/compat/zlib/native-zlib.build.json b/src/compat/zlib/native-zlib.build.json new file mode 100644 index 0000000..7658be4 --- /dev/null +++ b/src/compat/zlib/native-zlib.build.json @@ -0,0 +1,7 @@ +{ + "emsdk": "e4fe26ef59168ff44f4c23c466e497bf60b3411e", + "zlib": "1.3.1", + "portSha256": "d3d8ca9a7caac82fa68e4984e47c03386093b8fa2b068e8e9eccedd3aa406926", + "bindingSha256": "df6ddf030f9c09f6cd341ed81555c0c913cb37f1e94984b5c6859aedc017d384", + "sha256": "862cb0b55eec1d22aedcff581b883fc278f0e00243ba0ee780e025c82cc6512f" +} diff --git a/src/compat/zlib/native-zlib.c b/src/compat/zlib/native-zlib.c new file mode 100644 index 0000000..f787981 --- /dev/null +++ b/src/compat/zlib/native-zlib.c @@ -0,0 +1,43 @@ +// Owned bridge to zlib 1.3.1; the compiler port pins and verifies its source hash. +#include +#include +#include +#include +#include +#define API EMSCRIPTEN_KEEPALIVE +struct Codec { z_stream stream; int encode, mode, ended; unsigned char *dictionary; unsigned dictionary_size; unsigned char pending[1024]; unsigned pending_size; unsigned char header[2]; unsigned header_size; }; +API void* cz_create(int mode, int bits, int level, int memory, int strategy, const unsigned char* dictionary, unsigned length) { + struct Codec* c=calloc(1,sizeof(*c)); if(!c)return NULL; + c->mode=mode;c->encode=mode==1||mode==3||mode==5; + if(mode==3||mode==4)bits+=16;else if(mode==7)bits+=32;else if(mode==5||mode==6)bits=-bits; + int result=c->encode?deflateInit2(&c->stream,level,Z_DEFLATED,bits,memory,strategy):inflateInit2(&c->stream,bits); + if(result!=Z_OK){free(c);return NULL;} + if(length){c->dictionary=malloc(length);if(!c->dictionary){if(c->encode)deflateEnd(&c->stream);else inflateEnd(&c->stream);free(c);return NULL;}memcpy(c->dictionary,dictionary,length);c->dictionary_size=length; + if(c->encode&&mode!=3)result=deflateSetDictionary(&c->stream,dictionary,length); + else if(mode==6)result=inflateSetDictionary(&c->stream,dictionary,length); + if(result!=Z_OK){if(c->encode)deflateEnd(&c->stream);else inflateEnd(&c->stream);free(c->dictionary);free(c);return NULL;} + } + return c; +} +API void cz_free(struct Codec* c){if(!c)return;if(c->encode)deflateEnd(&c->stream);else inflateEnd(&c->stream);free(c->dictionary);free(c);} +API int cz_reset(struct Codec* c){c->ended=0;c->pending_size=0;c->header_size=0;int r=c->encode?deflateReset(&c->stream):inflateReset(&c->stream);if(r==Z_OK&&c->dictionary_size&&((c->encode&&c->mode!=3)||c->mode==6))r=c->encode?deflateSetDictionary(&c->stream,c->dictionary,c->dictionary_size):inflateSetDictionary(&c->stream,c->dictionary,c->dictionary_size);return r;} +API int cz_params(struct Codec* c,int level,int strategy){if(c->mode!=1&&c->mode!=5)return Z_OK;c->stream.next_out=c->pending;c->stream.avail_out=sizeof(c->pending);int r=deflateParams(&c->stream,level,strategy);c->pending_size=sizeof(c->pending)-c->stream.avail_out;return r;} +API const char* cz_message(struct Codec* c){return c->stream.msg?c->stream.msg:"Zlib error";} +API int cz_process(struct Codec* c,const unsigned char* input,unsigned length,unsigned char* output,unsigned capacity,int flush,unsigned* state){ + unsigned prefix=c->pending_sizepending_size:capacity; + if(prefix){memcpy(output,c->pending,prefix);memmove(c->pending,c->pending+prefix,c->pending_size-prefix);c->pending_size-=prefix;} + if(prefix==capacity){state[0]=0;state[1]=length;return Z_OK;} + c->stream.next_in=(Bytef*)input;c->stream.avail_in=length;c->stream.next_out=output+prefix;c->stream.avail_out=capacity-prefix; + if(c->mode==7){unsigned n=0;while(c->header_size<2&&nheader[c->header_size++]=input[n++];if(c->header_size==2)c->mode=(c->header[0]==31&&c->header[1]==139)?4:2;} + if(c->ended&&c->mode==4&&length&&input[0]){inflateReset(&c->stream);c->ended=0;} + int result; + do { + result=c->encode?deflate(&c->stream,flush):inflate(&c->stream,flush); + if(result==Z_NEED_DICT&&c->dictionary){result=inflateSetDictionary(&c->stream,c->dictionary,c->dictionary_size);if(result==Z_OK)result=inflate(&c->stream,flush);else if(result==Z_DATA_ERROR)result=Z_NEED_DICT;} + if(result==Z_STREAM_END){c->ended=1;if(c->mode==4&&c->stream.avail_in&&c->stream.next_in[0]&&c->stream.avail_out){inflateReset(&c->stream);c->ended=0;continue;}} + break; + }while(1); + state[0]=c->stream.avail_out;state[1]=c->stream.avail_in; + if((result==Z_OK||result==Z_BUF_ERROR)&&flush==Z_FINISH&&c->stream.avail_out)return Z_BUF_ERROR; + return result; +} diff --git a/src/compat/zlib/native-zlib.ts b/src/compat/zlib/native-zlib.ts new file mode 100644 index 0000000..0923043 --- /dev/null +++ b/src/compat/zlib/native-zlib.ts @@ -0,0 +1,31 @@ +import binary from "./native-zlib.wasm"; +export type API = Record number> & { + memory: WebAssembly.Memory; +}; +let api: API | undefined; +export function library(): API { + if (!api) { + api = new WebAssembly.Instance( + new WebAssembly.Module(binary as BufferSource), + { + env: { emscripten_notify_memory_growth() {} }, + wasi_snapshot_preview1: { + proc_exit(code: number) { + throw Error(`Codec exited: ${code}`); + }, + fd_write() { + return 8; + }, + }, + }, + ).exports as unknown as API; + api._initialize(); + } + return api; +} +export function message(api: API, pointer: number): string { + const bytes = new Uint8Array(api.memory.buffer); + let end = pointer; + while (bytes[end]) end++; + return new TextDecoder().decode(bytes.subarray(pointer, end)); +} diff --git a/src/compat/zlib/native-zlib.wasm b/src/compat/zlib/native-zlib.wasm new file mode 100644 index 0000000..b13eef8 Binary files /dev/null and b/src/compat/zlib/native-zlib.wasm differ diff --git a/src/compat/zlib/public-types.ts b/src/compat/zlib/public-types.ts new file mode 100644 index 0000000..707bfcf --- /dev/null +++ b/src/compat/zlib/public-types.ts @@ -0,0 +1,56 @@ +import type { Buffer } from "node:buffer"; +import type { TransformOptions } from "node:stream"; + +export type CodecInput = string | ArrayBufferLike | ArrayBufferView; +export interface CodecOptions extends Omit { + flush?: number; + finishFlush?: number; + chunkSize?: number; + maxOutputLength?: number; + info?: boolean; +} +export interface ZlibOptions extends CodecOptions { + windowBits?: number; + level?: number; + memLevel?: number; + strategy?: number; + dictionary?: ArrayBufferLike | ArrayBufferView; +} +export interface BrotliOptions extends CodecOptions { + params?: Record; +} +export interface ZstdOptions extends CodecOptions { + params?: Record; + pledgedSrcSize?: number; +} +export interface CodecInfo { + buffer: Buffer; + engine: Engine; +} +export type CodecCallback = ( + error: Error | null, + result: Result | undefined, +) => void; +export interface SyncCodec { + (input: CodecInput, options: Options & { info: true }): CodecInfo; + (input: CodecInput, options?: Options & { info?: false }): Buffer; + (input: CodecInput, options?: Options): Buffer | CodecInfo; +} +export interface AsyncCodec { + (input: CodecInput, callback: CodecCallback): void; + ( + input: CodecInput, + options: Options & { info: true }, + callback: CodecCallback>, + ): void; + ( + input: CodecInput, + options: Options & { info?: false }, + callback: CodecCallback, + ): void; + ( + input: CodecInput, + options: Options, + callback: CodecCallback>, + ): void; +} diff --git a/src/compat/zlib/validators.ts b/src/compat/zlib/validators.ts new file mode 100644 index 0000000..20f12fd --- /dev/null +++ b/src/compat/zlib/validators.ts @@ -0,0 +1,25 @@ +import { ERR_INVALID_ARG_TYPE, ERR_OUT_OF_RANGE } from "../net/errors.js"; +export { validateUint32 } from "../net/validators.js"; +export function checkFiniteNumber( + value: unknown, + name: string, +): value is number { + if (value === undefined || Number.isNaN(value)) return false; + if (typeof value !== "number") + throw new ERR_INVALID_ARG_TYPE(name, "number", value); + if (!Number.isFinite(value)) + throw new ERR_OUT_OF_RANGE(name, "a finite number", value); + return true; +} +export function checkRangesOrGetDefault( + value: unknown, + name: string, + min: number, + max: number, + fallback?: number, +): number | undefined { + if (!checkFiniteNumber(value, name)) return fallback; + if (value < min || value > max) + throw new ERR_OUT_OF_RANGE(name, `>= ${min} and <= ${max}`, value); + return value; +} diff --git a/src/shared/buffer-source.spec.ts b/src/shared/buffer-source.spec.ts new file mode 100644 index 0000000..43a410d --- /dev/null +++ b/src/shared/buffer-source.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from "vitest"; +import { runInNewContext } from "node:vm"; +import { bufferBytes, isAnyBuffer, viewBytes } from "./buffer-source.js"; + +test("intrinsic view access ignores shadow properties and preserves the subview", () => { + for (const create of [ + (buffer: ArrayBuffer) => new Uint8Array(buffer, 1, 1), + (buffer: ArrayBuffer) => new DataView(buffer, 1, 1), + ]) { + const bytes = new Uint8Array([8, 1, 9]); + const view = create(bytes.buffer); + for (const name of ["buffer", "byteOffset", "byteLength"]) + Object.defineProperty(view, name, { + get() { + throw new Error("shadowed " + name); + }, + }); + const actual = viewBytes(view); + expect(Array.from(actual)).toEqual([1]); + actual[0] = 2; + expect(Array.from(bytes)).toEqual([8, 2, 9]); + } +}); + +test("detached buffers and views have zero intrinsic extent", () => { + const buffer = new ArrayBuffer(3), + typed = new Uint8Array(buffer), + data = new DataView(buffer); + structuredClone(buffer, { transfer: [buffer] }); + expect(bufferBytes(buffer).length).toBe(0); + expect(viewBytes(typed).length).toBe(0); + expect(viewBytes(data).length).toBe(0); +}); + +test("cross-realm and shared backing use intrinsic brands", () => { + const view = runInNewContext("new Uint8Array([1,2])") as Uint8Array; + expect(isAnyBuffer(view.buffer)).toBe(true); + expect(Array.from(viewBytes(view))).toEqual([1, 2]); + const shared = new SharedArrayBuffer(1); + expect(isAnyBuffer(shared)).toBe(true); + bufferBytes(shared)[0] = 3; + expect(viewBytes(new DataView(shared))[0]).toBe(3); + expect(isAnyBuffer({ [Symbol.toStringTag]: "ArrayBuffer" })).toBe(false); +}); diff --git a/src/shared/buffer-source.ts b/src/shared/buffer-source.ts new file mode 100644 index 0000000..ce0f116 --- /dev/null +++ b/src/shared/buffer-source.ts @@ -0,0 +1,80 @@ +// Intrinsic BufferSource access shared by owned API adapters. +const typedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype); +const typedArrayBuffer = Object.getOwnPropertyDescriptor( + typedArrayPrototype, + "buffer", +)!.get!; +const typedArrayOffset = Object.getOwnPropertyDescriptor( + typedArrayPrototype, + "byteOffset", +)!.get!; +const typedArrayLength = Object.getOwnPropertyDescriptor( + typedArrayPrototype, + "byteLength", +)!.get!; +const dataViewBuffer = Object.getOwnPropertyDescriptor( + DataView.prototype, + "buffer", +)!.get!; +const dataViewOffset = Object.getOwnPropertyDescriptor( + DataView.prototype, + "byteOffset", +)!.get!; +const dataViewLength = Object.getOwnPropertyDescriptor( + DataView.prototype, + "byteLength", +)!.get!; +/** Read the intrinsic view extent, ignoring shadowed user properties. */ +export function viewBytes(value: ArrayBufferView): Uint8Array { + let buffer: ArrayBufferLike; + try { + buffer = typedArrayBuffer.call(value); + } catch { + buffer = dataViewBuffer.call(value); + try { + return new Uint8Array( + buffer, + dataViewOffset.call(value), + dataViewLength.call(value), + ); + } catch { + return new Uint8Array(0); + } + } + try { + return new Uint8Array( + buffer, + typedArrayOffset.call(value), + typedArrayLength.call(value), + ); + } catch { + return new Uint8Array(0); + } +} + +export function isAnyBuffer(value: unknown): value is ArrayBuffer { + try { + Reflect.getOwnPropertyDescriptor( + ArrayBuffer.prototype, + "byteLength", + )!.get!.call(value); + return true; + } catch {} + if (typeof SharedArrayBuffer !== "undefined") + try { + Reflect.getOwnPropertyDescriptor( + SharedArrayBuffer.prototype, + "byteLength", + )!.get!.call(value); + return true; + } catch {} + return false; +} + +export function bufferBytes(value: ArrayBufferLike): Uint8Array { + try { + return new Uint8Array(value); + } catch { + return new Uint8Array(0); + } +} diff --git a/src/shared/request-context.spec.ts b/src/shared/request-context.spec.ts new file mode 100644 index 0000000..5d1dd33 --- /dev/null +++ b/src/shared/request-context.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from "vitest"; +test("unused dispatcher import preserves host globals", async () => { + const globals = [ + globalThis.Promise, + globalThis.Promise.prototype.then, + globalThis.MessageChannel, + globalThis.setTimeout, + globalThis.queueMicrotask, + ]; + await import("./request-scope.js"); + expect([ + globalThis.Promise, + globalThis.Promise.prototype.then, + globalThis.MessageChannel, + globalThis.setTimeout, + globalThis.queueMicrotask, + ]).toEqual(globals); +}); +test("first crypto dynamic import retains the active request", async () => { + const { runWithRequestScope } = await import("./request-scope.js"); + const { hasActiveRequest } = await import("./request-context.js"); + expect(hasActiveRequest()).toBe(false); + const result = await runWithRequestScope(async () => { + await Promise.resolve(); + const { createDiffieHellman } = await import("../compat/crypto/dh.js"); + expect(hasActiveRequest()).toBe(true); + return createDiffieHellman(64).getPrime().length; + }); + expect(result).toBe(8); + expect(hasActiveRequest()).toBe(false); +}); +test("request marker does not leak into concurrent outside callbacks", async () => { + const { runWithRequestScope } = await import("./request-scope.js"); + const { hasActiveRequest } = await import("./request-context.js"); + const inside = runWithRequestScope(async () => { + await Promise.resolve(); + return hasActiveRequest(); + }); + const outside = Promise.resolve().then(() => hasActiveRequest()); + expect(await Promise.all([inside, outside])).toEqual([true, false]); +}); diff --git a/src/shared/request-context.ts b/src/shared/request-context.ts new file mode 100644 index 0000000..8a2bd75 --- /dev/null +++ b/src/shared/request-context.ts @@ -0,0 +1,11 @@ +import { AsyncLocalStorage } from "../compat/async-hooks/index.js"; +// Created at dispatch, before optional modules are imported. Merely importing +// the SDK must not install async-context propagation or replace host globals. +let active: AsyncLocalStorage | undefined; +export function hasActiveRequest(): boolean { + return active?.getStore() === true; +} +export function runWithActiveRequest(callback: () => T): T { + active ??= new AsyncLocalStorage(); + return active.run(true, callback); +} diff --git a/src/shared/request-scope.ts b/src/shared/request-scope.ts index d91cc84..b28b91a 100644 --- a/src/shared/request-scope.ts +++ b/src/shared/request-scope.ts @@ -1,5 +1,6 @@ -// Optional compatibility modules register request-local state here. Keeping -// this leaf dependency-free preserves the raw browser ESM entry point. +// Optional modules register request-local state here. The active request marker +// exists before registration so a first dynamic import sees its current request. +import { runWithActiveRequest } from "./request-context.js"; type Scope = (callback: () => T) => T; const scopes = new Set(); export function registerRequestScope(scope: Scope): void { @@ -11,5 +12,5 @@ export function runWithRequestScope(callback: () => T): T { index === entries.length ? callback() : entries[index](() => run(index + 1)); - return run(0); + return runWithActiveRequest(() => run(0)); } diff --git a/src/tooling/shared.ts b/src/tooling/shared.ts index 5b13c34..a6e7b36 100644 --- a/src/tooling/shared.ts +++ b/src/tooling/shared.ts @@ -143,6 +143,12 @@ export function compatibilityAliases(): Record { stdlib.process, ), "cloudflare:node": workersModulePath("cloudflare-node"), + "cf-workers-native:response": fileURLToPath( + new URL("../browser/response/index.js", import.meta.url), + ), + "cf-workers-native:headers": fileURLToPath( + new URL("../browser/headers/index.js", import.meta.url), + ), "cloudflare:workers": workersModulePath("cloudflare-workers"), "punycode/": polyfill("punycode/punycode.js"), "util/util.js": polyfill("util/util.js"), @@ -160,6 +166,10 @@ export function commonJSCompatibilityAliases(): Record { export function compatibilityDependencies(): string[] { return [ processStatePath, + // The HTTP bridge and runtime response checks must share Fetch brands in + // Vite dev, including when cloudflare:node is dependency-prebundled. + "cf-workers-native:response", + "cf-workers-native:headers", "cloudflare:node", "cloudflare:workers", ...Object.keys(builtinAliases()), diff --git a/src/tooling/vite.ts b/src/tooling/vite.ts index 231c6a6..6310285 100644 --- a/src/tooling/vite.ts +++ b/src/tooling/vite.ts @@ -111,6 +111,10 @@ function compatibilityPlugins( let specifier: string | undefined; if (target === `${root}/compat/stream-web/index.js`) specifier = "node:stream/web"; + else if (target === `${root}/browser/response/index.js`) + specifier = "cf-workers-native:response"; + else if (target === `${root}/browser/headers/index.js`) + specifier = "cf-workers-native:headers"; else if ( owner === `${root}/browser/body/stream-body.js` || owner === `${root}/shared/globals/web.js` diff --git a/tests/types/fs-consumer.mts b/tests/types/fs-consumer.mts new file mode 100644 index 0000000..7ff8892 --- /dev/null +++ b/tests/types/fs-consumer.mts @@ -0,0 +1,109 @@ +import * as promises from "../../src/compat/fs/promises/index.js"; +import * as fs from "../../src/compat/fs/index.js"; +import { Buffer } from "node:buffer"; +type IsAny = 0 extends 1 & T ? true : false; +const promisesAreTyped: IsAny = false; +const openIsTyped: IsAny = false; +const file = await promises.open("/tmp/types", "w+"); +const fd: number | undefined = file.fd; +const input = new DataView(new ArrayBuffer(4)); +const read = await file.read(input, { position: 0 }); +const count: number = read.bytesRead; +const sameView: DataView = read.buffer; +const vector = await file.readv([input]); +const vectorView: DataView = vector.buffers[0]; +const raw: Buffer = await file.readFile(); +const text: string = await file.readFile("utf8"); +const text2: string = await promises.readFile("/tmp/types", { + encoding: "utf8", +}); +const raw2: Buffer = await promises.readFile("/tmp/types"); +const result = await file.writeFile(input); +const sameWrittenView: DataView = result.buffer; +const written: number = result.bytesWritten; +const closed: Promise = file.close(); +// @ts-expect-error Unsupported path type. +promises.readFile(Symbol("path")); +// @ts-expect-error Encoding must be a BufferEncoding or buffer selection. +promises.readFile("/tmp/types", "not-an-encoding"); +// @ts-expect-error Read buffer must be an ArrayBuffer view. +file.read("not-a-buffer"); +void [ + promisesAreTyped, + openIsTyped, + fd, + count, + sameView, + vectorView, + raw, + text, + text2, + raw2, + sameWrittenView, + written, + closed, +]; + +const stringWrite: Buffer = (await file.write("hello")).buffer; +const stringWriteFile: Buffer = (await file.writeFile("hello")).buffer; +const tuple = [input, new Uint8Array(2)] as const; +const readTuple: typeof tuple = (await file.readv(tuple)).buffers; +const writeTuple: typeof tuple = (await file.writev(tuple)).buffers; +const bigintSize: bigint = (await file.stat({ bigint: true })).size; +const numberSize: number = (await promises.stat("/tmp/types")).size; +const entryNames: string[] = await promises.readdir("/tmp"); +const bufferNames: Buffer[] = await promises.readdir("/tmp", "buffer"); +const entries = await promises.readdir("/tmp", { withFileTypes: true }); +const directory: boolean = entries[0].isDirectory(); +for await (const entry of promises.glob("*", { withFileTypes: true })) { + const isFile: boolean = entry.isFile(); + void isFile; +} +// @ts-expect-error File descriptors are read only. +file.fd = 1; +// @ts-expect-error Workers does not implement Node-only promise operations. +promises.openAsBlob("/tmp/types"); +// @ts-expect-error Numeric mode required by access. +promises.access("/tmp/types", "4"); +void [ + stringWrite, + stringWriteFile, + readTuple, + writeTuple, + bigintSize, + numberSize, + entryNames, + bufferNames, + directory, +]; + +import defaultFs from "../../src/compat/fs/index.js"; +const defaultPromisesAreTyped: IsAny = false; +const defaultBuffer: Buffer = (await file.read()).buffer; +const optionBuffer: Buffer = (await file.read({ length: 1 })).buffer; +const optionView: DataView = (await file.read({ buffer: input })).buffer; +// @ts-expect-error A selected generic view type requires an actual buffer. +file.read(); +// @ts-expect-error Omitting the buffer cannot promise a DataView. +file.read({ length: 1 }); +const mixedInput: string | Uint8Array = Math.random() + ? "hello" + : new Uint8Array(3); +const mixedWrite: Buffer | Uint8Array = (await file.write(mixedInput)).buffer; +const mixedWriteFile: Buffer | Uint8Array = (await file.writeFile(mixedInput)) + .buffer; +void [ + defaultPromisesAreTyped, + defaultBuffer, + optionBuffer, + optionView, + mixedWrite, + mixedWriteFile, +]; + +const temporaryName: string = await promises.mkdtemp("/tmp/type-", "buffer"); +void temporaryName; + +// @ts-expect-error View writes interpret the second argument as a numeric offset. +file.write(new Uint8Array(1), 1n); +file.write("text", 1n); diff --git a/tests/types/zlib-consumer.mts b/tests/types/zlib-consumer.mts new file mode 100644 index 0000000..d06b916 --- /dev/null +++ b/tests/types/zlib-consumer.mts @@ -0,0 +1,500 @@ +import * as z from "../../src/compat/zlib/index.js"; +import defaultZlib from "../../src/compat/zlib/index.js"; +import type { Buffer } from "node:buffer"; +type IsAny = 0 extends 1 & T ? true : false; +const input = new Uint8Array([1, 2, 3]); +const info: boolean = Math.random() > 0.5; +{ + const plain: Buffer = z.deflateSync(input); + const explicit: Buffer = z.deflateSync(input, { info: false }); + const detail = z.deflateSync(input, { info: true }); + const engine: z.Deflate = detail.engine; + const data: Buffer = detail.buffer; + const dynamic: Buffer | z.CodecInfo = z.deflateSync(input, { + info, + }); + z.deflate(input, (error, result) => { + if (error || result === undefined) return; + const buffer: Buffer = result; + const typed: IsAny = false; + void [buffer, typed]; + }); + z.deflate(input, { info: true }, (error, result) => { + if (error || result === undefined) return; + const engine: z.Deflate = result.engine; + const buffer: Buffer = result.buffer; + void [engine, buffer]; + }); + z.deflate(input, { info }, (error, result) => { + if (error || result === undefined) return; + const maybe: Buffer | z.CodecInfo = result; + void maybe; + }); + const stream = new z.Deflate({ chunkSize: 1024 }); + const count: number = stream.bytesWritten; + stream.flush(() => {}); + stream.flush(0, () => {}); + stream.reset(); + stream.close(() => {}); + stream.end(input); + // @ts-expect-error Plain objects are not codec inputs. + z.deflateSync({}); + // @ts-expect-error Callback is required. + z.deflate(input); + // @ts-expect-error Invalid numeric option type. + z.createDeflate({ chunkSize: "large" }); + void [plain, explicit, engine, data, dynamic, count]; +} +{ + const plain: Buffer = z.inflateSync(input); + const explicit: Buffer = z.inflateSync(input, { info: false }); + const detail = z.inflateSync(input, { info: true }); + const engine: z.Inflate = detail.engine; + const data: Buffer = detail.buffer; + const dynamic: Buffer | z.CodecInfo = z.inflateSync(input, { + info, + }); + z.inflate(input, (error, result) => { + if (error || result === undefined) return; + const buffer: Buffer = result; + const typed: IsAny = false; + void [buffer, typed]; + }); + z.inflate(input, { info: true }, (error, result) => { + if (error || result === undefined) return; + const engine: z.Inflate = result.engine; + const buffer: Buffer = result.buffer; + void [engine, buffer]; + }); + z.inflate(input, { info }, (error, result) => { + if (error || result === undefined) return; + const maybe: Buffer | z.CodecInfo = result; + void maybe; + }); + const stream = new z.Inflate({ chunkSize: 1024 }); + const count: number = stream.bytesWritten; + stream.flush(() => {}); + stream.flush(0, () => {}); + stream.reset(); + stream.close(() => {}); + stream.end(input); + // @ts-expect-error Plain objects are not codec inputs. + z.inflateSync({}); + // @ts-expect-error Callback is required. + z.inflate(input); + // @ts-expect-error Invalid numeric option type. + z.createInflate({ chunkSize: "large" }); + void [plain, explicit, engine, data, dynamic, count]; +} +{ + const plain: Buffer = z.gzipSync(input); + const explicit: Buffer = z.gzipSync(input, { info: false }); + const detail = z.gzipSync(input, { info: true }); + const engine: z.Gzip = detail.engine; + const data: Buffer = detail.buffer; + const dynamic: Buffer | z.CodecInfo = z.gzipSync(input, { info }); + z.gzip(input, (error, result) => { + if (error || result === undefined) return; + const buffer: Buffer = result; + const typed: IsAny = false; + void [buffer, typed]; + }); + z.gzip(input, { info: true }, (error, result) => { + if (error || result === undefined) return; + const engine: z.Gzip = result.engine; + const buffer: Buffer = result.buffer; + void [engine, buffer]; + }); + z.gzip(input, { info }, (error, result) => { + if (error || result === undefined) return; + const maybe: Buffer | z.CodecInfo = result; + void maybe; + }); + const stream = new z.Gzip({ chunkSize: 1024 }); + const count: number = stream.bytesWritten; + stream.flush(() => {}); + stream.flush(0, () => {}); + stream.reset(); + stream.close(() => {}); + stream.end(input); + // @ts-expect-error Plain objects are not codec inputs. + z.gzipSync({}); + // @ts-expect-error Callback is required. + z.gzip(input); + // @ts-expect-error Invalid numeric option type. + z.createGzip({ chunkSize: "large" }); + void [plain, explicit, engine, data, dynamic, count]; +} +{ + const plain: Buffer = z.gunzipSync(input); + const explicit: Buffer = z.gunzipSync(input, { info: false }); + const detail = z.gunzipSync(input, { info: true }); + const engine: z.Gunzip = detail.engine; + const data: Buffer = detail.buffer; + const dynamic: Buffer | z.CodecInfo = z.gunzipSync(input, { info }); + z.gunzip(input, (error, result) => { + if (error || result === undefined) return; + const buffer: Buffer = result; + const typed: IsAny = false; + void [buffer, typed]; + }); + z.gunzip(input, { info: true }, (error, result) => { + if (error || result === undefined) return; + const engine: z.Gunzip = result.engine; + const buffer: Buffer = result.buffer; + void [engine, buffer]; + }); + z.gunzip(input, { info }, (error, result) => { + if (error || result === undefined) return; + const maybe: Buffer | z.CodecInfo = result; + void maybe; + }); + const stream = new z.Gunzip({ chunkSize: 1024 }); + const count: number = stream.bytesWritten; + stream.flush(() => {}); + stream.flush(0, () => {}); + stream.reset(); + stream.close(() => {}); + stream.end(input); + // @ts-expect-error Plain objects are not codec inputs. + z.gunzipSync({}); + // @ts-expect-error Callback is required. + z.gunzip(input); + // @ts-expect-error Invalid numeric option type. + z.createGunzip({ chunkSize: "large" }); + void [plain, explicit, engine, data, dynamic, count]; +} +{ + const plain: Buffer = z.deflateRawSync(input); + const explicit: Buffer = z.deflateRawSync(input, { info: false }); + const detail = z.deflateRawSync(input, { info: true }); + const engine: z.DeflateRaw = detail.engine; + const data: Buffer = detail.buffer; + const dynamic: Buffer | z.CodecInfo = z.deflateRawSync(input, { + info, + }); + z.deflateRaw(input, (error, result) => { + if (error || result === undefined) return; + const buffer: Buffer = result; + const typed: IsAny = false; + void [buffer, typed]; + }); + z.deflateRaw(input, { info: true }, (error, result) => { + if (error || result === undefined) return; + const engine: z.DeflateRaw = result.engine; + const buffer: Buffer = result.buffer; + void [engine, buffer]; + }); + z.deflateRaw(input, { info }, (error, result) => { + if (error || result === undefined) return; + const maybe: Buffer | z.CodecInfo = result; + void maybe; + }); + const stream = new z.DeflateRaw({ chunkSize: 1024 }); + const count: number = stream.bytesWritten; + stream.flush(() => {}); + stream.flush(0, () => {}); + stream.reset(); + stream.close(() => {}); + stream.end(input); + // @ts-expect-error Plain objects are not codec inputs. + z.deflateRawSync({}); + // @ts-expect-error Callback is required. + z.deflateRaw(input); + // @ts-expect-error Invalid numeric option type. + z.createDeflateRaw({ chunkSize: "large" }); + void [plain, explicit, engine, data, dynamic, count]; +} +{ + const plain: Buffer = z.inflateRawSync(input); + const explicit: Buffer = z.inflateRawSync(input, { info: false }); + const detail = z.inflateRawSync(input, { info: true }); + const engine: z.InflateRaw = detail.engine; + const data: Buffer = detail.buffer; + const dynamic: Buffer | z.CodecInfo = z.inflateRawSync(input, { + info, + }); + z.inflateRaw(input, (error, result) => { + if (error || result === undefined) return; + const buffer: Buffer = result; + const typed: IsAny = false; + void [buffer, typed]; + }); + z.inflateRaw(input, { info: true }, (error, result) => { + if (error || result === undefined) return; + const engine: z.InflateRaw = result.engine; + const buffer: Buffer = result.buffer; + void [engine, buffer]; + }); + z.inflateRaw(input, { info }, (error, result) => { + if (error || result === undefined) return; + const maybe: Buffer | z.CodecInfo = result; + void maybe; + }); + const stream = new z.InflateRaw({ chunkSize: 1024 }); + const count: number = stream.bytesWritten; + stream.flush(() => {}); + stream.flush(0, () => {}); + stream.reset(); + stream.close(() => {}); + stream.end(input); + // @ts-expect-error Plain objects are not codec inputs. + z.inflateRawSync({}); + // @ts-expect-error Callback is required. + z.inflateRaw(input); + // @ts-expect-error Invalid numeric option type. + z.createInflateRaw({ chunkSize: "large" }); + void [plain, explicit, engine, data, dynamic, count]; +} +{ + const plain: Buffer = z.unzipSync(input); + const explicit: Buffer = z.unzipSync(input, { info: false }); + const detail = z.unzipSync(input, { info: true }); + const engine: z.Unzip = detail.engine; + const data: Buffer = detail.buffer; + const dynamic: Buffer | z.CodecInfo = z.unzipSync(input, { info }); + z.unzip(input, (error, result) => { + if (error || result === undefined) return; + const buffer: Buffer = result; + const typed: IsAny = false; + void [buffer, typed]; + }); + z.unzip(input, { info: true }, (error, result) => { + if (error || result === undefined) return; + const engine: z.Unzip = result.engine; + const buffer: Buffer = result.buffer; + void [engine, buffer]; + }); + z.unzip(input, { info }, (error, result) => { + if (error || result === undefined) return; + const maybe: Buffer | z.CodecInfo = result; + void maybe; + }); + const stream = new z.Unzip({ chunkSize: 1024 }); + const count: number = stream.bytesWritten; + stream.flush(() => {}); + stream.flush(0, () => {}); + stream.reset(); + stream.close(() => {}); + stream.end(input); + // @ts-expect-error Plain objects are not codec inputs. + z.unzipSync({}); + // @ts-expect-error Callback is required. + z.unzip(input); + // @ts-expect-error Invalid numeric option type. + z.createUnzip({ chunkSize: "large" }); + void [plain, explicit, engine, data, dynamic, count]; +} +{ + const plain: Buffer = z.brotliCompressSync(input); + const explicit: Buffer = z.brotliCompressSync(input, { info: false }); + const detail = z.brotliCompressSync(input, { info: true }); + const engine: z.BrotliCompress = detail.engine; + const data: Buffer = detail.buffer; + const dynamic: Buffer | z.CodecInfo = z.brotliCompressSync( + input, + { info }, + ); + z.brotliCompress(input, (error, result) => { + if (error || result === undefined) return; + const buffer: Buffer = result; + const typed: IsAny = false; + void [buffer, typed]; + }); + z.brotliCompress(input, { info: true }, (error, result) => { + if (error || result === undefined) return; + const engine: z.BrotliCompress = result.engine; + const buffer: Buffer = result.buffer; + void [engine, buffer]; + }); + z.brotliCompress(input, { info }, (error, result) => { + if (error || result === undefined) return; + const maybe: Buffer | z.CodecInfo = result; + void maybe; + }); + const stream = new z.BrotliCompress({ chunkSize: 1024 }); + const count: number = stream.bytesWritten; + stream.flush(() => {}); + stream.flush(0, () => {}); + stream.reset(); + stream.close(() => {}); + stream.end(input); + // @ts-expect-error Plain objects are not codec inputs. + z.brotliCompressSync({}); + // @ts-expect-error Callback is required. + z.brotliCompress(input); + // @ts-expect-error Invalid numeric option type. + z.createBrotliCompress({ chunkSize: "large" }); + void [plain, explicit, engine, data, dynamic, count]; +} +{ + const plain: Buffer = z.brotliDecompressSync(input); + const explicit: Buffer = z.brotliDecompressSync(input, { info: false }); + const detail = z.brotliDecompressSync(input, { info: true }); + const engine: z.BrotliDecompress = detail.engine; + const data: Buffer = detail.buffer; + const dynamic: Buffer | z.CodecInfo = + z.brotliDecompressSync(input, { info }); + z.brotliDecompress(input, (error, result) => { + if (error || result === undefined) return; + const buffer: Buffer = result; + const typed: IsAny = false; + void [buffer, typed]; + }); + z.brotliDecompress(input, { info: true }, (error, result) => { + if (error || result === undefined) return; + const engine: z.BrotliDecompress = result.engine; + const buffer: Buffer = result.buffer; + void [engine, buffer]; + }); + z.brotliDecompress(input, { info }, (error, result) => { + if (error || result === undefined) return; + const maybe: Buffer | z.CodecInfo = result; + void maybe; + }); + const stream = new z.BrotliDecompress({ chunkSize: 1024 }); + const count: number = stream.bytesWritten; + stream.flush(() => {}); + stream.flush(0, () => {}); + stream.reset(); + stream.close(() => {}); + stream.end(input); + // @ts-expect-error Plain objects are not codec inputs. + z.brotliDecompressSync({}); + // @ts-expect-error Callback is required. + z.brotliDecompress(input); + // @ts-expect-error Invalid numeric option type. + z.createBrotliDecompress({ chunkSize: "large" }); + void [plain, explicit, engine, data, dynamic, count]; +} +{ + const plain: Buffer = z.zstdCompressSync(input); + const explicit: Buffer = z.zstdCompressSync(input, { info: false }); + const detail = z.zstdCompressSync(input, { info: true }); + const engine: z.ZstdCompress = detail.engine; + const data: Buffer = detail.buffer; + const dynamic: Buffer | z.CodecInfo = z.zstdCompressSync( + input, + { info }, + ); + z.zstdCompress(input, (error, result) => { + if (error || result === undefined) return; + const buffer: Buffer = result; + const typed: IsAny = false; + void [buffer, typed]; + }); + z.zstdCompress(input, { info: true }, (error, result) => { + if (error || result === undefined) return; + const engine: z.ZstdCompress = result.engine; + const buffer: Buffer = result.buffer; + void [engine, buffer]; + }); + z.zstdCompress(input, { info }, (error, result) => { + if (error || result === undefined) return; + const maybe: Buffer | z.CodecInfo = result; + void maybe; + }); + const stream = new z.ZstdCompress({ chunkSize: 1024 }); + const count: number = stream.bytesWritten; + stream.flush(() => {}); + stream.flush(0, () => {}); + stream.reset(); + stream.close(() => {}); + stream.end(input); + // @ts-expect-error Plain objects are not codec inputs. + z.zstdCompressSync({}); + // @ts-expect-error Callback is required. + z.zstdCompress(input); + // @ts-expect-error Invalid numeric option type. + z.createZstdCompress({ chunkSize: "large" }); + void [plain, explicit, engine, data, dynamic, count]; +} +{ + const plain: Buffer = z.zstdDecompressSync(input); + const explicit: Buffer = z.zstdDecompressSync(input, { info: false }); + const detail = z.zstdDecompressSync(input, { info: true }); + const engine: z.ZstdDecompress = detail.engine; + const data: Buffer = detail.buffer; + const dynamic: Buffer | z.CodecInfo = z.zstdDecompressSync( + input, + { info }, + ); + z.zstdDecompress(input, (error, result) => { + if (error || result === undefined) return; + const buffer: Buffer = result; + const typed: IsAny = false; + void [buffer, typed]; + }); + z.zstdDecompress(input, { info: true }, (error, result) => { + if (error || result === undefined) return; + const engine: z.ZstdDecompress = result.engine; + const buffer: Buffer = result.buffer; + void [engine, buffer]; + }); + z.zstdDecompress(input, { info }, (error, result) => { + if (error || result === undefined) return; + const maybe: Buffer | z.CodecInfo = result; + void maybe; + }); + const stream = new z.ZstdDecompress({ chunkSize: 1024 }); + const count: number = stream.bytesWritten; + stream.flush(() => {}); + stream.flush(0, () => {}); + stream.reset(); + stream.close(() => {}); + stream.end(input); + // @ts-expect-error Plain objects are not codec inputs. + z.zstdDecompressSync({}); + // @ts-expect-error Callback is required. + z.zstdDecompress(input); + // @ts-expect-error Invalid numeric option type. + z.createZstdDecompress({ chunkSize: "large" }); + void [plain, explicit, engine, data, dynamic, count]; +} + +const normal: Buffer = defaultZlib.gzipSync("text"); +const checksum: number = z.crc32(new DataView(new ArrayBuffer(4))); +z.deflateSync(new SharedArrayBuffer(4), { + dictionary: new DataView(new ArrayBuffer(4)), +}); +z.brotliCompressSync(input, { + params: { [z.constants.BROTLI_PARAM_DISABLE_LITERAL_CONTEXT_MODELING]: true }, +}); +z.zstdCompressSync(input, { + params: { [z.constants.ZSTD_c_checksumFlag]: true }, + pledgedSrcSize: 3, +}); +z.createDeflate().params(1, 0, () => {}); +// @ts-expect-error Brotli has no zlib params method. +z.createBrotliCompress().params(1, 0, () => {}); +// @ts-expect-error Zstd has no zlib params method. +z.createZstdCompress().params(1, 0, () => {}); +// @ts-expect-error crc32 requires byte input or text. +z.crc32(Symbol("invalid")); +// @ts-expect-error A callback cannot promise output on failure. +z.gzip(input, (error: Error | null, result: Buffer) => {}); +void [normal, checksum]; + +// Workers rejects an explicit undefined options argument in the three-argument form. +// @ts-expect-error Omit options with the two-argument form instead. +z.deflate(input, undefined, () => {}); +// @ts-expect-error Omit options with the two-argument form instead. +z.inflate(input, undefined, () => {}); +// @ts-expect-error Omit options with the two-argument form instead. +z.gzip(input, undefined, () => {}); +// @ts-expect-error Omit options with the two-argument form instead. +z.gunzip(input, undefined, () => {}); +// @ts-expect-error Omit options with the two-argument form instead. +z.deflateRaw(input, undefined, () => {}); +// @ts-expect-error Omit options with the two-argument form instead. +z.inflateRaw(input, undefined, () => {}); +// @ts-expect-error Omit options with the two-argument form instead. +z.unzip(input, undefined, () => {}); +// @ts-expect-error Omit options with the two-argument form instead. +z.brotliCompress(input, undefined, () => {}); +// @ts-expect-error Omit options with the two-argument form instead. +z.brotliDecompress(input, undefined, () => {}); +// @ts-expect-error Omit options with the two-argument form instead. +z.zstdCompress(input, undefined, () => {}); +// @ts-expect-error Omit options with the two-argument form instead. +z.zstdDecompress(input, undefined, () => {}); diff --git a/tests/unit/http-streaming.test.ts b/tests/unit/http-streaming.test.ts index 53edb0b..064051b 100644 --- a/tests/unit/http-streaming.test.ts +++ b/tests/unit/http-streaming.test.ts @@ -1,52 +1,59 @@ import { afterEach, expect, it, vi } from "vitest"; import { request } from "../../src/compat/http/index"; afterEach(() => vi.unstubAllGlobals()); -it("starts sending before end and holds write callbacks until the consumer reads", async () => { - let body!: ReadableStream; - let respond!: (response: Response) => void; - const fetcher = vi.fn((_url, init) => { - body = init.body; - return new Promise((resolve) => { - respond = resolve; +it.each([false, true])( + "captures writes before queued processing (corked=%s) and sends at end", + async (corked) => { + let body!: Blob; + const fetcher = vi.fn((_url, init) => { + body = init.body; + return Promise.resolve(new Response(null, { status: 204 })); }); - }); + vi.stubGlobal("fetch", fetcher); + const req = request("http://example.test/upload", { method: "POST" }); + req.on("error", () => {}); + const written = vi.fn(), + chunk = new Uint8Array([65]); + if (corked) req.cork(); + req.write(chunk, written); + chunk[0] = 66; + if (corked) req.uncork(); + await vi.waitFor(() => expect(written).toHaveBeenCalledOnce()); + expect(written.mock.calls[0]?.[0]).toBeFalsy(); + expect(fetcher).not.toHaveBeenCalled(); + req.end("C"); + expect(fetcher).toHaveBeenCalledOnce(); + expect(await body.text()).toBe("AC"); + }, +); +it("aborting an unfinished upload does not initiate a fetch", async () => { + const fetcher = vi.fn(() => new Promise(() => {})); vi.stubGlobal("fetch", fetcher); - const req = request("http://example.test/upload", { method: "POST" }); - req.on("error", () => {}); - const written = vi.fn(); - expect(req.write(new Uint8Array(128 * 1024), written)).toBe(false); - expect(fetcher).toHaveBeenCalledOnce(); - expect(written).not.toHaveBeenCalled(); - const reader = body.getReader(); - expect((await reader.read()).value?.byteLength).toBe(128 * 1024); - await vi.waitFor(() => expect(written).toHaveBeenCalledOnce()); - req.end(); - expect((await reader.read()).done).toBe(true); - respond(new Response(null, { status: 204 })); -}); -it("destroy settles a blocked writer and errors the upload", async () => { - let body!: ReadableStream; - vi.stubGlobal( - "fetch", - vi.fn((_url, init) => { - body = init.body; - return new Promise(() => {}); - }), - ); - const req = request("http://example.test/upload", { method: "POST" }); - req.on("error", () => {}); + const controller = new AbortController(), + reason = new Error("stopped"); + const req = request("http://example.test/upload", { + method: "POST", + signal: controller.signal, + }); + const errors: Error[] = []; + req.on("error", (error) => errors.push(error)); const written = vi.fn(); req.write(new Uint8Array(65536), written); - req.destroy(new Error("stopped")); - await vi.waitFor(() => expect(written).toHaveBeenCalledOnce()); - await expect(body.getReader().read()).rejects.toThrow("stopped"); - expect((req as any).upload).toBeUndefined(); + controller.abort(reason); + await vi.waitFor(() => expect(errors).toHaveLength(1)); + expect(written).toHaveBeenCalledOnce(); + expect(written.mock.calls[0]?.[0]).toBeFalsy(); + expect(errors[0]).toMatchObject({ + name: "AbortError", + code: "ABORT_ERR", + cause: reason, + }); + expect(fetcher).not.toHaveBeenCalled(); + expect(req.destroyed).toBe(true); }); -it("releases queued writes on destroy", async () => { - vi.stubGlobal( - "fetch", - vi.fn(() => new Promise(() => {})), - ); +it("settles every accepted write once on destroy", async () => { + const fetcher = vi.fn(() => new Promise(() => {})); + vi.stubGlobal("fetch", fetcher); const req = request("http://example.test/upload", { method: "POST" }); req.on("error", () => {}); const callbacks = [vi.fn(), vi.fn(), vi.fn()]; @@ -58,4 +65,5 @@ it("releases queued writes on destroy", async () => { ).toBe(true), ); expect(req.writableLength).toBe(0); + expect(fetcher).not.toHaveBeenCalled(); }); diff --git a/vitest.config.ts b/vitest.config.ts index e43e97b..1358eec 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,8 +1,29 @@ +import { transform as transformFixture } from "esbuild"; import { lowerAsync } from "./src/tooling/async.js"; import { readFile } from "node:fs/promises"; import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [ + { + name: "test-upstream-resource-management", + enforce: "pre", + async transform(code, id) { + if ( + !id.includes("/compat/fs/upstream-") || + !id.endsWith(".fixture.mjs") + ) + return; + // Lower await-using before the request-context async transform, as the + // production ES2022 build does. Keep upstream disposal assertions intact. + return ( + await transformFixture(code, { + loader: "js", + target: "es2022", + format: "esm", + }) + ).code; + }, + }, { name: "test-async-context", enforce: "post", @@ -13,7 +34,12 @@ export default defineConfig({ { name: "test-wasm-binary", async load(id) { - if (!/\/(?:brotli_dec_wasm_bg|native-crypto)\.wasm$/.test(id)) return; + if ( + !/\/(?:brotli_dec_wasm_bg|native-crypto|native-cipher)\.wasm$/.test( + id, + ) + ) + return; const data = (await readFile(id)).toString("base64"); return `export default Uint8Array.from(atob(${JSON.stringify(data)}), c => c.charCodeAt(0));`; }, @@ -27,6 +53,7 @@ export default defineConfig({ "tests/unit/**/*.test.ts", "src/cloudflare/apis/cloudflare-node/index.spec.ts", "src/shared/globals/index.spec.ts", + "src/shared/request-context.spec.ts", "src/shared/globals/web.spec.ts", "src/browser/abort/installation.spec.ts", "src/compat/buffer/installation.spec.ts", @@ -47,7 +74,23 @@ export default defineConfig({ "src/browser/fetch/scope.spec.ts", "src/compat/net/sockets.spec.ts", "src/compat/tls/upgrade.spec.ts", + "src/compat/crypto/kdf-limits.spec.ts", + "src/compat/crypto/random.spec.ts", + "src/compat/crypto/dh.spec.ts", + "src/compat/crypto/cipher.spec.ts", + "src/compat/crypto/hash.spec.ts", + "src/compat/crypto/hash-types.spec.ts", + "src/compat/crypto/signatures.spec.ts", + "src/compat/crypto/keys.spec.ts", + "src/compat/crypto/subtle-digest.spec.ts", + "src/compat/crypto/kdf-input.spec.ts", + "src/shared/buffer-source.spec.ts", + "src/compat/zlib/input.spec.ts", + "src/compat/crypto/digest-stream.spec.ts", "src/compat/fs/writable.spec.ts", + "src/compat/fs/file-handle.spec.ts", + "src/compat/fs/glob.spec.ts", + "src/compat/fs/directory.spec.ts", "src/shared/http-wire/index.spec.ts", ], },