diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d24a314..efe9876 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,7 @@ jobs: - run: bun install --frozen-lockfile --ignore-scripts + - run: bun run fmt --check - run: bun run typecheck - run: bun run build - name: Verify tracked files are unchanged after build diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..25742a3 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,6 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "ignorePatterns": ["README.md"], + "semi": false, + "printWidth": 120 +} diff --git a/bun.lock b/bun.lock index eb69ab0..a9398d3 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "devDependencies": { "@types/bun": "latest", "node-gyp": "^11.5.0", + "oxfmt": "^0.44.0", "typescript": "^5", "zig-build": "github:solarwinds/zig-build#fa7428c0a607e4075172346e4d22f7a19ba68fe0", }, @@ -24,6 +25,44 @@ "@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.44.0", "", { "os": "android", "cpu": "arm" }, "sha512-5UvghMd9SA/yvKTWCAxMAPXS1d2i054UeOf4iFjZjfayTwCINcC3oaSXjtbZfCaEpxgJod7XiOjTtby5yEv/BQ=="], + + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.44.0", "", { "os": "android", "cpu": "arm64" }, "sha512-IVudM1BWfvrYO++Khtzr8q9n5Rxu7msUvoFMqzGJVdX7HfUXUDHwaH2zHZNB58svx2J56pmCUzophyaPFkcG/A=="], + + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.44.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eWCLAIKAHfx88EqEP1Ga2yz7qVcqDU5lemn4xck+07bH182hDdprOHjbogyk0In1Djys3T0/pO2JepFnRJ41Mg=="], + + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.44.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-eHTBznHLM49++dwz07MblQ2cOXyIgeedmE3Wgy4ptUESj38/qYZyRi1MPwC9olQJWssMeY6WI3UZ7YmU5ggvyQ=="], + + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.44.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jLMmbj0u0Ft43QpkUVr/0v1ZfQCGWAvU+WznEHcN3wZC/q6ox7XeSJtk9P36CCpiDSUf3sGnzbIuG1KdEMEDJQ=="], + + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.44.0", "", { "os": "linux", "cpu": "arm" }, "sha512-n+A/u/ByK1qV8FVGOwyaSpw5NPNl0qlZfgTBqHeGIqr8Qzq1tyWZ4lAaxPoe5mZqE3w88vn3+jZtMxriHPE7tg=="], + + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.44.0", "", { "os": "linux", "cpu": "arm" }, "sha512-5eax+FkxyCqAi3Rw0mrZFr7+KTt/XweFsbALR+B5ljWBLBl8nHe4ADrUnb1gLEfQCJLl+Ca5FIVD4xEt95AwIw=="], + + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.44.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-58l8JaHxSGOmOMOG2CIrNsnkRJAj0YcHQCmvNACniOa/vd1iRHhlPajczegzS5jwMENlqgreyiTR9iNlke8qCw=="], + + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.44.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AlObQIXyVRZ96LbtVljtFq0JqH5B92NU+BQeDFrXWBUWlCKAM0wF5GLfIhCLT5kQ3Sl+U0YjRJ7Alqj5hGQaCg=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.44.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-YcFE8/q/BbrCiIiM5piwbkA6GwJc5QqhMQp2yDrqQ2fuVkZ7CInb1aIijZ/k8EXc72qXMSwKpVlBv1w/MsGO/A=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.44.0", "", { "os": "linux", "cpu": "none" }, "sha512-eOdzs6RqkRzuqNHUX5C8ISN5xfGh4xDww8OEd9YAmc3OWN8oAe5bmlIqQ+rrHLpv58/0BuU48bxkhnIGjA/ATQ=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.44.0", "", { "os": "linux", "cpu": "none" }, "sha512-YBgNTxntD/QvlFUfgvh8bEdwOhXiquX8gaofZJAwYa/Xp1S1DQrFVZEeck7GFktr24DztsSp8N8WtWCBwxs0Hw=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.44.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-GLIh1R6WHWshl/i4QQDNgj0WtT25aRO4HNUWEoitxiywyRdhTFmFEYT2rXlcl9U6/26vhmOqG5cRlMLG3ocaIA=="], + + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.44.0", "", { "os": "linux", "cpu": "x64" }, "sha512-gZOpgTlOsLcLfAF9qgpTr7FIIFSKnQN3hDf/0JvQ4CIwMY7h+eilNjxq/CorqvYcEOu+LRt1W4ZS7KccEHLOdA=="], + + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.44.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1CyS9JTB+pCUFYFI6pkQGGZaT/AY5gnhHVrQQLhFba6idP9AzVYm1xbdWfywoldTYvjxQJV6x4SuduCIfP3W+A=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.44.0", "", { "os": "none", "cpu": "arm64" }, "sha512-bmEv70Ak6jLr1xotCbF5TxIKjsmQaiX+jFRtnGtfA03tJPf6VG3cKh96S21boAt3JZc+Vjx8PYcDuLj39vM2Pw=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.44.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-yWzB+oCpSnP/dmw85eFLAT5o35Ve5pkGS2uF/UCISpIwDqf1xa7OpmtomiqY/Vzg8VyvMbuf6vroF2khF/+1Vg=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.44.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-TcWpo18xEIE3AmIG2kpr3kz5IEhQgnx0lazl2+8L+3eTopOAUevQcmlr4nhguImNWz0OMeOZrYZOhJNCf16nlQ=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.44.0", "", { "os": "win32", "cpu": "x64" }, "sha512-oj8aLkPJZppIM4CMQNsyir9ybM1Xw/CfGPTSsTnzpVGyljgfbdP0EVUlURiGM0BDrmw5psQ6ArmGCcUY/yABaQ=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], @@ -130,6 +169,8 @@ "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], + "oxfmt": ["oxfmt@0.44.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.44.0", "@oxfmt/binding-android-arm64": "0.44.0", "@oxfmt/binding-darwin-arm64": "0.44.0", "@oxfmt/binding-darwin-x64": "0.44.0", "@oxfmt/binding-freebsd-x64": "0.44.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.44.0", "@oxfmt/binding-linux-arm-musleabihf": "0.44.0", "@oxfmt/binding-linux-arm64-gnu": "0.44.0", "@oxfmt/binding-linux-arm64-musl": "0.44.0", "@oxfmt/binding-linux-ppc64-gnu": "0.44.0", "@oxfmt/binding-linux-riscv64-gnu": "0.44.0", "@oxfmt/binding-linux-riscv64-musl": "0.44.0", "@oxfmt/binding-linux-s390x-gnu": "0.44.0", "@oxfmt/binding-linux-x64-gnu": "0.44.0", "@oxfmt/binding-linux-x64-musl": "0.44.0", "@oxfmt/binding-openharmony-arm64": "0.44.0", "@oxfmt/binding-win32-arm64-msvc": "0.44.0", "@oxfmt/binding-win32-ia32-msvc": "0.44.0", "@oxfmt/binding-win32-x64-msvc": "0.44.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-lnncqvHewyRvaqdrnntVIrZV2tEddz8lbvPsQzG/zlkfvgZkwy0HP1p/2u1aCDToeg1jb9zBpbJdfkV73Itw+w=="], + "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], @@ -176,6 +217,8 @@ "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], diff --git a/index.d.ts b/index.d.ts index c6887d6..1cf52ab 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,13 +1,13 @@ /** * Returns a pointer to the first byte of the given `ArrayBuffer` or `ArrayBufferView` as a number. - * + * * [See Bun's explanation on `number` vs `bigint` pointers](https://bun.com/docs/runtime/ffi#pointers) - * + * * ``` * type MyPointerType = number & { __ptr: true } * const ptr: MyPointerType = unsafePointerOf(buffer) * ``` - * + * * @unsafe The JavaScript runtime may move or deallocate objects at will, leading to invalid pointer access. Invalid pointer access can lead to attackers controlling your users' computers. * @see {@link unsafeBigIntPointerOf} for bigint pointers */ @@ -16,14 +16,14 @@ export function unsafePointerOf(buf: ArrayBufferLike | ArrayBu /** * Returns a pointer to the first byte of the given `ArrayBuffer` or `ArrayBufferView` as a bigint. * Bigints are slower than numbers but theoretically a safer way to represent pointers. - * + * * [See Bun's explanation on `number` vs `bigint` pointers](https://bun.com/docs/runtime/ffi#pointers) - * + * * ``` * type MyPointerType = bigint & { __ptr: true } * const ptr: MyPointerType = unsafeBigIntPointerOf(buffer) * ``` - * + * * @unsafe The JavaScript runtime may move or deallocate objects at will, leading to invalid pointer access. Invalid pointer access can lead to attackers controlling your users' computers. * @see {@link unsafePointerOf} for number pointers */ @@ -31,35 +31,71 @@ export function unsafeBigIntPointerOf(buf: ArrayBufferLike | A /** * Unsafely create an `ArrayBuffer` aliasing the memory at `pointer + offset` with the given length. - * + * * ``` * type Point3DPointer = number & { __ptr: true, __type: "Point3D" } * type Point3D = Float32Array & { length: 3, __type: "Point3D" } - * + * * function UnsafePoint32(ptr: Point3DPointer): Point3D { * return new Float32Array(unsafeArrayBufferAt(ptr, 0, 3 * Float32Array.BYTES_PER_ELEMENT)) * } * ``` - * + * * @unsafe Accessing arbitrary memory can lead to attackers controlling your users' computers. */ -export function unsafeArrayBufferAt(ptr: T, offset: number | undefined, byteLength: number): ArrayBuffer +export function unsafeArrayBufferAt( + ptr: T, + offset: number | undefined, + byteLength: number, +): ArrayBuffer /** * Iterates from `ptr` until the first null byte is found, or `maxBytes` bytes are reached. * Returns the number of bytes iterated, or `-1` if no null byte found before `maxBytes`. - * + * * Pass `-1` for `maxBytes` to count all bytes (which like `strlen` is unsafe). - * + * * ``` * function unsafeStringAt(ptr: number) { * const length = unsafeCountNonNullBytes(ptr, -1) * return new TextDecoder().decode(new Uint8Array(unsafeArrayBufferAt(ptr, 0, length))) * } - * + * * const cstring = new TextEncoder().encode("Hello, world!\0") * const pointer = unsafePointerOf(cstring) * console.log(unsafeStringAt(pointer)) // "Hello, world!" * ``` + * + * @unsafe Accessing arbitrary memory can lead to attackers controlling your users' computers. */ export function unsafeCountNonNullBytes(ptr: T, maxBytes: number): number + +/** + * A copy of a C UTF-8 string who's properties indicate the original pointer, + * offset, and byte length. + * + * As it is a copy, it's safe to use after the pointer is freed. + * + * @warning It is unsafe to assume the c string at `ptr + offset` is still valid + * @warning It is unsafe to assume there is a null terminator at `ptr + offset + byteLength`. + */ +export type CString = { + value: string + ptr: T + offset: number + byteLength: number +} + +/** + * Copy the C UTF-8 string at `ptr` into a JavaScript string. + * If `byteLength` not provided, scans for the closing `\0` character. + * Pass a negative `byteLength` to scan at most ABS(byteLength) bytes. + * + * @unsafe Accessing arbitrary memory can lead to attackers controlling your users' computers. + */ +export function unsafeCStringAt>>( + ptr: T, + offset?: number, + byteLength?: number, + result?: R, +): R & CString diff --git a/index.mjs b/index.mjs index 6b35aad..4310b4d 100644 --- a/index.mjs +++ b/index.mjs @@ -1,10 +1,11 @@ // Generated from index.d.ts by scripts/codegen-index-mjs.mts. Do not edit. -import { createRequire } from 'node:module' +import { createRequire } from "node:module" const require = createRequire(import.meta.url) -const binding = require('./index.js') +const binding = require("./index.js") export const unsafePointerOf = binding.unsafePointerOf export const unsafeBigIntPointerOf = binding.unsafeBigIntPointerOf export const unsafeArrayBufferAt = binding.unsafeArrayBufferAt export const unsafeCountNonNullBytes = binding.unsafeCountNonNullBytes +export const unsafeCStringAt = binding.unsafeCStringAt diff --git a/package.json b/package.json index de28738..b38431f 100644 --- a/package.json +++ b/package.json @@ -2,20 +2,21 @@ "name": "unsafe-pointer", "version": "0.2.0", "description": "Unsafely turn ArrayBuffers into raw pointers and raw pointers into ArrayBuffers. Warning: arbitrary memory access can lead to attackers controlling your users' computers.", - "type": "commonjs", - "repository": { - "url": "https://github.com/justjake/node-unsafe-pointer" - }, + "keywords": [ + "ffi", + "napi", + "native", + "node-api", + "pointer", + "unsafe" + ], + "license": "MIT", "author": { "name": "Jake Teton-Landis", "url": "https://jake.tl" }, - "main": "./index.js", - "types": "./index.d.ts", - "exports": { - "types": "./index.d.ts", - "import": "./index.mjs", - "require": "./index.js" + "repository": { + "url": "https://github.com/justjake/node-unsafe-pointer" }, "files": [ "index.js", @@ -25,6 +26,17 @@ "src/", "prebuilds/" ], + "type": "commonjs", + "main": "./index.js", + "types": "./index.d.ts", + "exports": { + "types": "./index.d.ts", + "import": "./index.mjs", + "require": "./index.js" + }, + "publishConfig": { + "access": "public" + }, "scripts": { "install": "node-gyp-build", "rebuild": "node-gyp rebuild", @@ -32,19 +44,8 @@ "build:js": "./scripts/codegen-index-mjs.mts", "build:c": "./scripts/zig-build-prebuilds.mts", "test": "node --test ./index.test.js", - "typecheck": "tsc --pretty -p jsconfig.json" - }, - "keywords": [ - "napi", - "node-api", - "ffi", - "pointer", - "native", - "unsafe" - ], - "license": "MIT", - "engines": { - "node": ">=18" + "typecheck": "tsc --pretty -p jsconfig.json", + "fmt": "oxfmt" }, "dependencies": { "node-gyp-build": "^4.8.4" @@ -52,10 +53,11 @@ "devDependencies": { "@types/bun": "latest", "node-gyp": "^11.5.0", + "oxfmt": "^0.44.0", "typescript": "^5", "zig-build": "github:solarwinds/zig-build#fa7428c0a607e4075172346e4d22f7a19ba68fe0" }, - "publishConfig": { - "access": "public" + "engines": { + "node": ">=18" } } diff --git a/scripts/codegen-index-mjs.mts b/scripts/codegen-index-mjs.mts index 561df9b..b5ecddf 100755 --- a/scripts/codegen-index-mjs.mts +++ b/scripts/codegen-index-mjs.mts @@ -1,25 +1,19 @@ #!/usr/bin/env bun -import { readFileSync, writeFileSync } from 'node:fs' -import * as path from 'node:path' -import * as ts from 'typescript' -import { fileURLToPath } from 'node:url' +import { readFileSync, writeFileSync } from "node:fs" +import * as path from "node:path" +import * as ts from "typescript" +import { fileURLToPath } from "node:url" const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const repoRoot = path.resolve(__dirname, '..') -const declarationsPath = path.join(repoRoot, 'index.d.ts') -const outputPath = path.join(repoRoot, 'index.mjs') +const repoRoot = path.resolve(__dirname, "..") +const declarationsPath = path.join(repoRoot, "index.d.ts") +const outputPath = path.join(repoRoot, "index.mjs") export function generateIndexMjs(): void { - const sourceText = readFileSync(declarationsPath, 'utf8') - const sourceFile = ts.createSourceFile( - declarationsPath, - sourceText, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS - ) + const sourceText = readFileSync(declarationsPath, "utf8") + const sourceFile = ts.createSourceFile(declarationsPath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) const exportNames = getRuntimeExportNames(sourceFile) if (exportNames.length === 0) { @@ -27,15 +21,15 @@ export function generateIndexMjs(): void { } const output = [ - '// Generated from index.d.ts by scripts/codegen-index-mjs.mts. Do not edit.', - "import { createRequire } from 'node:module'", - '', - 'const require = createRequire(import.meta.url)', - "const binding = require('./index.js')", - '', + "// Generated from index.d.ts by scripts/codegen-index-mjs.mts. Do not edit.", + 'import { createRequire } from "node:module"', + "", + "const require = createRequire(import.meta.url)", + 'const binding = require("./index.js")', + "", ...exportNames.map((name) => `export const ${name} = binding.${name}`), - '' - ].join('\n') + "", + ].join("\n") writeFileSync(outputPath, output) } @@ -87,6 +81,6 @@ function collectBindingNames(name: ts.BindingName, names: string[]): void { } } -if (path.resolve(process.argv[1] ?? '') === __filename) { +if (path.resolve(process.argv[1] ?? "") === __filename) { generateIndexMjs() } diff --git a/scripts/zig-build-prebuilds.mts b/scripts/zig-build-prebuilds.mts index afb7508..e7be2b5 100755 --- a/scripts/zig-build-prebuilds.mts +++ b/scripts/zig-build-prebuilds.mts @@ -1,12 +1,21 @@ #!/usr/bin/env bun -import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs' -import * as path from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' - -type TargetArch = 'x64' | 'arm64' -type TargetLibc = 'glibc' | 'musl' -type TargetPlatform = 'darwin' | 'linux' | 'win32' +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync, + unlinkSync, +} from "node:fs" +import * as path from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" + +type TargetArch = "x64" | "arm64" +type TargetLibc = "glibc" | "musl" +type TargetPlatform = "darwin" | "linux" | "win32" type TargetSpec = { arch: TargetArch @@ -18,11 +27,11 @@ type TargetSpec = { type ZigBuildTarget = { cflags?: string[] glibc?: string - mode: 'debug' | 'fast' | 'small' + mode: "debug" | "fast" | "small" napiVersion: number output: string sources: string[] - std: 'gnu17' + std: "gnu17" target: string } @@ -34,62 +43,62 @@ type PackageJson = { const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const repoRoot = path.resolve(__dirname, '..') +const repoRoot = path.resolve(__dirname, "..") -const zigCacheRoot = path.join(repoRoot, '.zig-cache') -const zigLocalCacheDir = path.join(zigCacheRoot, 'local') -const zigGlobalCacheDir = path.join(zigCacheRoot, 'global') -process.env['ZIG_LOCAL_CACHE_DIR'] = zigLocalCacheDir -process.env['ZIG_GLOBAL_CACHE_DIR'] = zigGlobalCacheDir +const zigCacheRoot = path.join(repoRoot, ".zig-cache") +const zigLocalCacheDir = path.join(zigCacheRoot, "local") +const zigGlobalCacheDir = path.join(zigCacheRoot, "global") +process.env["ZIG_LOCAL_CACHE_DIR"] = zigLocalCacheDir +process.env["ZIG_GLOBAL_CACHE_DIR"] = zigGlobalCacheDir const packageName = encodePrebuildName(readPackageName()) const requestedTargets = process.argv.slice(2) const targetSpecs: Record = { - 'linux-x64-glibc': { - arch: 'x64', - libc: 'glibc', - platform: 'linux', - target: 'x86_64-linux-gnu' + "linux-x64-glibc": { + arch: "x64", + libc: "glibc", + platform: "linux", + target: "x86_64-linux-gnu", }, - 'darwin-x64': { - arch: 'x64', - platform: 'darwin', - target: 'x86_64-macos' + "darwin-x64": { + arch: "x64", + platform: "darwin", + target: "x86_64-macos", }, - 'linux-arm64-glibc': { - arch: 'arm64', - libc: 'glibc', - platform: 'linux', - target: 'aarch64-linux-gnu' + "linux-arm64-glibc": { + arch: "arm64", + libc: "glibc", + platform: "linux", + target: "aarch64-linux-gnu", }, - 'darwin-arm64': { - arch: 'arm64', - platform: 'darwin', - target: 'aarch64-macos' + "darwin-arm64": { + arch: "arm64", + platform: "darwin", + target: "aarch64-macos", }, - 'linux-x64-musl': { - arch: 'x64', - libc: 'musl', - platform: 'linux', - target: 'x86_64-linux-musl' + "linux-x64-musl": { + arch: "x64", + libc: "musl", + platform: "linux", + target: "x86_64-linux-musl", }, - 'linux-arm64-musl': { - arch: 'arm64', - libc: 'musl', - platform: 'linux', - target: 'aarch64-linux-musl' + "linux-arm64-musl": { + arch: "arm64", + libc: "musl", + platform: "linux", + target: "aarch64-linux-musl", }, - 'win32-x64': { - arch: 'x64', - platform: 'win32', - target: 'x86_64-windows' + "win32-x64": { + arch: "x64", + platform: "win32", + target: "x86_64-windows", + }, + "win32-arm64": { + arch: "arm64", + platform: "win32", + target: "aarch64-windows", }, - 'win32-arm64': { - arch: 'arm64', - platform: 'win32', - target: 'aarch64-windows' - } } main().catch((error: unknown) => { @@ -103,14 +112,12 @@ main().catch((error: unknown) => { }) async function main(): Promise { - const targetNames = requestedTargets.length > 0 - ? requestedTargets - : Object.keys(targetSpecs) + const targetNames = requestedTargets.length > 0 ? requestedTargets : Object.keys(targetSpecs) const missing = targetNames.filter((name) => !(name in targetSpecs)) if (missing.length > 0) { throw new Error( - `unknown zig-build target(s): ${missing.join(', ')}\nvalid targets: ${Object.keys(targetSpecs).join(', ')}` + `unknown zig-build target(s): ${missing.join(", ")}\nvalid targets: ${Object.keys(targetSpecs).join(", ")}`, ) } @@ -128,28 +135,26 @@ async function main(): Promise { } async function loadZigBuild(): Promise<{ build: BuildFn }> { - const distEntry = path.join(repoRoot, 'node_modules', 'zig-build', 'dist', 'index.js') + const distEntry = path.join(repoRoot, "node_modules", "zig-build", "dist", "index.js") if (existsSync(distEntry)) { const module = await import(pathToFileURL(distEntry).href) return module as { build: BuildFn } } - const sourceEntry = path.join(repoRoot, 'node_modules', 'zig-build', 'src', 'index.ts') + const sourceEntry = path.join(repoRoot, "node_modules", "zig-build", "src", "index.ts") if (existsSync(sourceEntry)) { const module = await import(pathToFileURL(sourceEntry).href) return module as { build: BuildFn } } - throw new Error('unable to locate zig-build entrypoint under node_modules/zig-build') + throw new Error("unable to locate zig-build entrypoint under node_modules/zig-build") } function readPackageName(): string { - const packageJson = JSON.parse( - readFileSync(path.join(repoRoot, 'package.json'), 'utf8') - ) as PackageJson + const packageJson = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8")) as PackageJson - if (typeof packageJson.name !== 'string' || packageJson.name.length === 0) { - throw new Error('expected package.json name to be a non-empty string') + if (typeof packageJson.name !== "string" || packageJson.name.length === 0) { + throw new Error("expected package.json name to be a non-empty string") } return packageJson.name @@ -165,13 +170,13 @@ function requireTargetSpec(name: string): TargetSpec { } function createBuildBatches( - selectedTargets: readonly (readonly [name: string, spec: TargetSpec])[] + selectedTargets: readonly (readonly [name: string, spec: TargetSpec])[], ): (readonly (readonly [name: string, spec: TargetSpec])[])[] { const windowsBatches: (readonly [name: string, spec: TargetSpec])[][] = [] const sharedBatch: (readonly [name: string, spec: TargetSpec])[] = [] for (const entry of selectedTargets) { - if (entry[1].platform === 'win32') { + if (entry[1].platform === "win32") { windowsBatches.push([entry]) } else { sharedBatch.push(entry) @@ -182,7 +187,7 @@ function createBuildBatches( } function createBuildTargets( - selectedTargets: readonly (readonly [name: string, spec: TargetSpec])[] + selectedTargets: readonly (readonly [name: string, spec: TargetSpec])[], ): Record { const targets: Record = {} @@ -192,23 +197,20 @@ function createBuildTargets( mkdirSync(path.dirname(output), { recursive: true }) const target: ZigBuildTarget = { - mode: 'fast', + mode: "fast", napiVersion: 6, output, - sources: ['src/binding.c'], - std: 'gnu17', - target: spec.target + sources: ["src/binding.c"], + std: "gnu17", + target: spec.target, } - if (spec.platform === 'linux' && spec.libc === 'glibc' && process.env['ZIG_BUILD_GLIBC_VERSION']) { - target.glibc = process.env['ZIG_BUILD_GLIBC_VERSION'] + if (spec.platform === "linux" && spec.libc === "glibc" && process.env["ZIG_BUILD_GLIBC_VERSION"]) { + target.glibc = process.env["ZIG_BUILD_GLIBC_VERSION"] } - if (spec.platform === 'win32') { - target.cflags = [ - '-Wno-deprecated-non-prototype', - '-Wno-inconsistent-dllimport' - ] + if (spec.platform === "win32") { + target.cflags = ["-Wno-deprecated-non-prototype", "-Wno-inconsistent-dllimport"] } targets[name] = target @@ -218,26 +220,15 @@ function createBuildTargets( } function prebuildPath(spec: TargetSpec): string { - return path.join( - repoRoot, - 'prebuilds', - `${spec.platform}-${spec.arch}`, - prebuildFilename(spec) - ) + return path.join(repoRoot, "prebuilds", `${spec.platform}-${spec.arch}`, prebuildFilename(spec)) } function stagedBuildPath(spec: TargetSpec): string { - return path.join( - repoRoot, - 'build', - 'zig-build', - `${spec.platform}-${spec.arch}`, - prebuildFilename(spec) - ) + return path.join(repoRoot, "build", "zig-build", `${spec.platform}-${spec.arch}`, prebuildFilename(spec)) } function encodePrebuildName(name: string): string { - return name.replace(/\//g, '+') + return name.replace(/\//g, "+") } function publishPrebuildArtifacts(specs: TargetSpec[]): void { @@ -274,7 +265,7 @@ function publishPrebuildArtifacts(specs: TargetSpec[]): void { function removeStalePrebuilds(dir: string, expectedFiles: ReadonlySet): void { for (const name of readdirSync(dir)) { - if (!name.endsWith('.node')) { + if (!name.endsWith(".node")) { continue } @@ -287,7 +278,5 @@ function removeStalePrebuilds(dir: string, expectedFiles: ReadonlySet): } function prebuildFilename(spec: TargetSpec): string { - return spec.platform === 'linux' && spec.libc - ? `${packageName}.${spec.libc}.node` - : `${packageName}.node` + return spec.platform === "linux" && spec.libc ? `${packageName}.${spec.libc}.node` : `${packageName}.node` } diff --git a/src/binding.c b/src/binding.c index c47c783..668d94b 100644 --- a/src/binding.c +++ b/src/binding.c @@ -90,6 +90,45 @@ static bool get_max_byte_count_arg(napi_env env, napi_value value, const char* n return true; } +static bool get_optional_safe_int64_arg(napi_env env, napi_value value, const char* name, bool* provided, int64_t* result) { + napi_valuetype value_type; + double number_value = 0; + char message[160]; + + if (napi_typeof(env, value, &value_type) != napi_ok) { + napi_throw_error(env, NULL, "Failed to inspect argument type"); + return false; + } + + if (value_type == napi_undefined) { + *provided = false; + *result = 0; + return true; + } + + if (napi_get_value_double(env, value, &number_value) != napi_ok) { + snprintf(message, sizeof(message), "%s must be a number", name); + throw_type_error(env, "ERR_INVALID_ARG_TYPE", message); + return false; + } + + if (!isfinite(number_value) || number_value < -MAX_SAFE_INTEGER_AS_DOUBLE || number_value > MAX_SAFE_INTEGER_AS_DOUBLE) { + snprintf(message, sizeof(message), "%s must be a safe integer", name); + throw_range_error(env, "ERR_OUT_OF_RANGE", message); + return false; + } + + if (floor(number_value) != number_value) { + snprintf(message, sizeof(message), "%s must be an integer", name); + throw_range_error(env, "ERR_OUT_OF_RANGE", message); + return false; + } + + *provided = true; + *result = (int64_t)number_value; + return true; +} + static bool get_pointer_number_arg(napi_env env, napi_value value, const char* name, uint64_t* result) { double number_value = 0; char message[160]; @@ -166,6 +205,81 @@ static bool create_int64_result(napi_env env, int64_t value, napi_value* result) return true; } +static bool create_size_t_number(napi_env env, size_t value, napi_value* result) { + if (napi_create_double(env, (double)value, result) != napi_ok) { + napi_throw_error(env, NULL, "Failed to create number result"); + return false; + } + + return true; +} + +static bool get_c_string_byte_length( + napi_env env, + const uint8_t* data, + bool has_byte_length, + int64_t requested_byte_length, + size_t* result +) { + if (!has_byte_length) { + size_t count = 0; + + while (data[count] != 0) { + if (count == SIZE_MAX) { + throw_range_error(env, "ERR_OUT_OF_RANGE", "string length exceeds supported range"); + return false; + } + + count++; + } + + *result = count; + return true; + } + + if (requested_byte_length >= 0) { + *result = (size_t)requested_byte_length; + return true; + } + + size_t limit = (size_t)(-requested_byte_length); + for (size_t count = 0; count < limit; count++) { + if (data[count] == 0) { + *result = count; + return true; + } + } + + *result = limit; + return true; +} + +static bool get_optional_result_object(napi_env env, napi_value value, napi_value* result) { + napi_valuetype value_type; + + if (napi_typeof(env, value, &value_type) != napi_ok) { + napi_throw_error(env, NULL, "Failed to inspect result argument type"); + return false; + } + + if (value_type == napi_undefined) { + if (napi_create_object(env, result) != napi_ok) { + napi_throw_error(env, NULL, "Failed to create result object"); + return false; + } + + return true; + } + + if (value_type != napi_object && value_type != napi_function) { + throw_type_error(env, "ERR_INVALID_ARG_TYPE", "result must be an object"); + return false; + } + + *result = value; + return true; +} + static bool get_buffer_data(napi_env env, napi_value value, uint8_t** data) { bool is_buffer = false; if (napi_is_buffer(env, value, &is_buffer) != napi_ok) { @@ -416,12 +530,90 @@ static napi_value unsafeCountNonNullBytes(napi_env env, napi_callback_info info) return result; } +static napi_value unsafeCStringAt(napi_env env, napi_callback_info info) { + size_t argc = 4; + napi_value argv[4]; + uint64_t pointer = 0; + size_t offset = 0; + bool has_byte_length = false; + int64_t requested_byte_length = 0; + size_t byte_length = 0; + const uint8_t* data = NULL; + napi_value result; + napi_value value; + napi_value offset_value; + napi_value byte_length_value; + + if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok) { + napi_throw_error(env, NULL, "Failed to read arguments"); + return NULL; + } + + if (argc < 1) { + return throw_type_error(env, "ERR_MISSING_ARGS", "ptr is required"); + } + + if (!get_pointer_arg(env, argv[0], "ptr", &pointer)) { + return NULL; + } + + if (argc >= 2 && !get_optional_size_t_arg(env, argv[1], "offset", &offset)) { + return NULL; + } + + if (argc >= 3 && !get_optional_safe_int64_arg(env, argv[2], "byteLength", &has_byte_length, &requested_byte_length)) { + return NULL; + } + + if ((uint64_t)offset > UINT64_MAX - pointer) { + return throw_range_error(env, "ERR_OUT_OF_RANGE", "ptr + offset is out of range"); + } + + if (argc >= 4) { + if (!get_optional_result_object(env, argv[3], &result)) { + return NULL; + } + } else if (napi_create_object(env, &result) != napi_ok) { + napi_throw_error(env, NULL, "Failed to create result object"); + return NULL; + } + + data = (const uint8_t*)(uintptr_t)(pointer + (uint64_t)offset); + if (!get_c_string_byte_length(env, data, has_byte_length, requested_byte_length, &byte_length)) { + return NULL; + } + + if (napi_create_string_utf8(env, (const char*)data, byte_length, &value) != napi_ok) { + napi_throw_error(env, NULL, "Failed to create string result"); + return NULL; + } + + if (!create_size_t_number(env, offset, &offset_value)) { + return NULL; + } + + if (!create_size_t_number(env, byte_length, &byte_length_value)) { + return NULL; + } + + if (napi_set_named_property(env, result, "value", value) != napi_ok || + napi_set_named_property(env, result, "ptr", argv[0]) != napi_ok || + napi_set_named_property(env, result, "offset", offset_value) != napi_ok || + napi_set_named_property(env, result, "byteLength", byte_length_value) != napi_ok) { + napi_throw_error(env, NULL, "Failed to populate string result"); + return NULL; + } + + return result; +} + static napi_value init(napi_env env, napi_value exports) { napi_property_descriptor descriptors[] = { { "unsafePointerOf", NULL, unsafePointerOf, NULL, NULL, NULL, napi_default, NULL }, { "unsafeBigIntPointerOf", NULL, unsafeBigIntPointerOf, NULL, NULL, NULL, napi_default, NULL }, { "unsafeArrayBufferAt", NULL, unsafeArrayBufferAt, NULL, NULL, NULL, napi_default, NULL }, { "unsafeCountNonNullBytes", NULL, unsafeCountNonNullBytes, NULL, NULL, NULL, napi_default, NULL }, + { "unsafeCStringAt", NULL, unsafeCStringAt, NULL, NULL, NULL, napi_default, NULL }, }; if (napi_define_properties(env, exports, sizeof(descriptors) / sizeof(descriptors[0]), descriptors) != napi_ok) { diff --git a/test/register-unsafe-pointer-tests.js b/test/register-unsafe-pointer-tests.js index a57111f..f536d09 100644 --- a/test/register-unsafe-pointer-tests.js +++ b/test/register-unsafe-pointer-tests.js @@ -8,6 +8,7 @@ module.exports = function registerUnsafePointerTests({ unsafeBigIntPointerOf, unsafeArrayBufferAt, unsafeCountNonNullBytes, + unsafeCStringAt, }) { test("unsafePointerOf returns the address of a typed array view", () => { const view = new Uint8Array([1, 2, 3, 4]) @@ -102,4 +103,124 @@ module.exports = function registerUnsafePointerTests({ assert.equal(unsafeStringAt(pointer), "Hello, world!") }) + + test("unsafeCStringAt copies a null-terminated UTF-8 string and includes metadata", () => { + const cstring = new TextEncoder().encode("Hello, world!\0") + const ptr = unsafePointerOf(cstring) + const result = unsafeCStringAt(ptr) + + assert.deepEqual(result, { + value: "Hello, world!", + ptr, + offset: 0, + byteLength: 13, + }) + }) + + test("unsafeCStringAt decodes UTF-8 and reports byteLength in bytes", () => { + const value = "hé🌍" + const cstring = new TextEncoder().encode(`${value}\0`) + const ptr = unsafePointerOf(cstring) + + assert.deepEqual(unsafeCStringAt(ptr), { + value, + ptr, + offset: 0, + byteLength: cstring.byteLength - 1, + }) + }) + + test("unsafeCStringAt reuses the provided result object", () => { + const cstring = new TextEncoder().encode("abc\0rest") + const ptr = unsafeBigIntPointerOf(cstring) + const result = { extra: true } + const returned = unsafeCStringAt(ptr, 0, -4, result) + + assert.equal(returned, result) + assert.deepEqual(result, { + extra: true, + value: "abc", + ptr, + offset: 0, + byteLength: 3, + }) + }) + + test("unsafeCStringAt negative byteLength decodes UTF-8 correctly with an offset", () => { + const value = "hé🌍" + const prefix = "xx" + const cstring = new TextEncoder().encode(`${prefix}${value}\0tail`) + const ptr = unsafeBigIntPointerOf(cstring) + const byteLength = new TextEncoder().encode(`${value}\0`).byteLength + + assert.deepEqual(unsafeCStringAt(ptr, prefix.length, -byteLength), { + value, + ptr, + offset: prefix.length, + byteLength: byteLength - 1, + }) + }) + + test("unsafeCStringAt negative byteLength stops at a null terminator found exactly at the bound", () => { + const cstring = new TextEncoder().encode("abc\0") + const ptr = unsafePointerOf(cstring) + + assert.deepEqual(unsafeCStringAt(ptr, 0, -4), { + value: "abc", + ptr, + offset: 0, + byteLength: 3, + }) + }) + + test("unsafeCStringAt negative byteLength uses the full bound when no null terminator is found in range", () => { + const cstring = new TextEncoder().encode("abcdef") + const ptr = unsafePointerOf(cstring) + + assert.deepEqual(unsafeCStringAt(ptr, 0, -3), { + value: "abc", + ptr, + offset: 0, + byteLength: 3, + }) + }) + + test("unsafeCStringAt negative byteLength respects offset when scanning for a null terminator", () => { + const cstring = new TextEncoder().encode("xxabc\0tail") + const ptr = unsafeBigIntPointerOf(cstring) + + assert.deepEqual(unsafeCStringAt(ptr, 2, -6), { + value: "abc", + ptr, + offset: 2, + byteLength: 3, + }) + }) + + test("unsafeCStringAt honors positive byteLength for UTF-8 strings", () => { + const value = "é🌍" + const prefix = "x" + const encodedValue = new TextEncoder().encode(value) + const cstring = new TextEncoder().encode(`${prefix}${value}\0`) + const ptr = unsafePointerOf(cstring) + + assert.deepEqual(unsafeCStringAt(ptr, prefix.length, encodedValue.byteLength), { + value, + ptr, + offset: prefix.length, + byteLength: encodedValue.byteLength, + }) + }) + + test("unsafeCStringAt honors positive byteLength without scanning for a null terminator", () => { + const cstring = new TextEncoder().encode("abcdef\0") + const ptr = unsafePointerOf(cstring) + + assert.deepEqual(unsafeCStringAt(ptr, 2, 3), { + value: "cde", + ptr, + offset: 2, + byteLength: 3, + }) + }) }