diff --git a/.github/workflows/native-windows.yml b/.github/workflows/native-windows.yml new file mode 100644 index 000000000..5dc1cbb1a --- /dev/null +++ b/.github/workflows/native-windows.yml @@ -0,0 +1,89 @@ +name: native-windows + +on: + push: + branches: [main] + pull_request: + types: [opened, reopened, synchronize] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + primitives: + name: primitives / win32-${{ matrix.arch }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - runner: windows-2022 + arch: x64 + target: x86_64-pc-windows-msvc + - runner: windows-11-arm + arch: arm64 + target: aarch64-pc-windows-msvc + defaults: + run: + shell: pwsh + working-directory: plugins/codex-security/native + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Set up pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: package.json + - name: Set up Node.js 22 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: "22.13.0" + architecture: ${{ matrix.arch }} + - name: Compile TypeScript tools + working-directory: . + run: | + pnpm --dir sdk/typescript install --frozen-lockfile + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + pnpm --dir sdk/typescript run build:ci + - name: Set up Rust + run: rustup toolchain install 1.97.1 --profile minimal --component rustfmt --component clippy --target ${{ matrix.target }} + - name: Check Rust source + run: | + cargo fmt --check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo clippy --locked --target ${{ matrix.target }} -- -D warnings + - name: Build and verify with Node.js 22 + run: | + node build.mjs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node check.mjs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $nativeNode = (Get-Command node).Source + $env:PATH = "" + & $nativeNode --expose-gc proof-windows.mjs + - name: Set up Node.js 20 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: "20.0.0" + architecture: ${{ matrix.arch }} + - name: Verify the same artifact with Node.js 20 + run: | + node check.mjs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $nativeNode = (Get-Command node).Source + $env:PATH = "" + & $nativeNode --expose-gc proof-windows.mjs + - name: Upload verified native artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-win32-${{ matrix.arch }}-${{ github.sha }} + path: plugins/codex-security/native/dist/win32-${{ matrix.arch }}/windows.node + if-no-files-found: error + retention-days: 7 diff --git a/plugins/codex-security/native/Cargo.lock b/plugins/codex-security/native/Cargo.lock index 346cf3300..b1e1532f2 100644 --- a/plugins/codex-security/native/Cargo.lock +++ b/plugins/codex-security/native/Cargo.lock @@ -15,13 +15,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "codex-security-native-unix" +name = "codex-security-native" version = "0.0.0" dependencies = [ "libc", "napi", "napi-build", "napi-derive", + "windows-sys", ] [[package]] @@ -294,3 +295,12 @@ name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/plugins/codex-security/native/Cargo.toml b/plugins/codex-security/native/Cargo.toml index dbc6d2334..50a3bb3ad 100644 --- a/plugins/codex-security/native/Cargo.toml +++ b/plugins/codex-security/native/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "codex-security-native-unix" +name = "codex-security-native" version = "0.0.0" edition = "2021" rust-version = "1.97" @@ -12,8 +12,13 @@ crate-type = ["cdylib"] [dependencies] napi = { version = "=3.12.2", default-features = false, features = ["napi8"] } napi-derive = "=3.6.3" + +[target.'cfg(unix)'.dependencies] libc = "=0.2.189" +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_IO"] } + [build-dependencies] napi-build = "=2.4.1" diff --git a/plugins/codex-security/native/README.md b/plugins/codex-security/native/README.md index 16f23b0b2..216e3c3df 100644 --- a/plugins/codex-security/native/README.md +++ b/plugins/codex-security/native/README.md @@ -1,6 +1,6 @@ -# Unix native primitives +# Native OS primitives -This foundation supplies the descriptor operations that Node does not expose. The SDK and CLI continue to use their existing helpers while Windows primitives and universal package assembly are completed. +This foundation supplies the OS operations that Node does not expose. The SDK and CLI continue to use their existing helpers while universal package assembly and migration proofs are completed. The nine Node-API 8 functions are typed in `binding.mts`. Paths remain byte buffers. `statAt` never follows the final symlink; device and inode numbers are decimal strings so JavaScript does not round them. `openAt` and `duplicate` create descriptors with close-on-exec set. Node owns subsequent reads, writes, `fstat`, `fsync`, and close calls. `userHome` looks up raw username bytes through the operating system and returns raw home-directory bytes or a missing result, without Git. @@ -32,3 +32,17 @@ node plugins/codex-security/native/check.mjs Linux artifacts must import no glibc version newer than 2.28. macOS artifacts must declare a deployment target of 11.0 or earlier. A build from a newer Linux workstation can pass the behavioral proof and still fail this distribution check. The `native-unix` workflow builds Linux artifacts in digest-pinned manylinux 2.28 images. It mounts the pinned Rust toolchain and fetched Cargo registry, builds offline, and blocks Python commands during compilation. macOS builds set `MACOSX_DEPLOYMENT_TARGET=11.0`. CI verifies separate x64 and arm64 artifacts on both platforms using Node 20.0.0 and 22.13.0. These artifacts are inputs to the later universal-package gate. + +Windows uses `windows-binding.mts` and the same Rust crate. `WindowsHandle` owns a non-inheritable Win32 handle; explicit `close()` and garbage collection release it. Handles never cross into Node's CRT descriptor table. Paths and returned names are UTF-16LE buffers without a NUL terminator, preserving lone surrogates. Volume identities and file positions are decimal strings; file IDs retain all 128 bits in a buffer. + +The binding exposes synchronous file and directory creation, attributes and reparse tags, identity and final/opened names, read/write/seek/size/EOF/flush, exact-handle rename and deletion, and byte-range locking. Calls return numeric Windows errors. Buffer ranges, path encoding, and 64-bit arguments are checked before FFI calls. Overlapped handles are unsupported because pending operations could retain native buffers beyond the call. Path authorization, ancestor traversal, and reparse-point policy remain the caller's responsibility. + +Build on Windows after compiling the TypeScript tools, then run: + +```sh +node plugins/codex-security/native/build.mjs +node plugins/codex-security/native/check.mjs +node --expose-gc plugins/codex-security/native/proof-windows.mjs +``` + +The `native-windows` workflow builds x64 and arm64 with MSVC and a static CRT. It checks PE architecture and private paths, then runs the same artifact on Node 22.13.0 and 20.0.0 with an empty `PATH`. The proof covers handle lifetime and garbage collection, ancestor replacement, junctions, exact-handle operations, raw UTF-16 and long paths, numeric errors, and cross-process byte-zero locking and release. Blocking locks run in child processes. Comparison with the existing Python `msvcrt` lock remains a separate migration gate before production routing. diff --git a/plugins/codex-security/native/binding.mts b/plugins/codex-security/native/binding.mts index eab03d364..4dac94153 100644 --- a/plugins/codex-security/native/binding.mts +++ b/plugins/codex-security/native/binding.mts @@ -5,7 +5,10 @@ import { fileURLToPath } from "node:url"; export const root = dirname(fileURLToPath(import.meta.url)); export const output = join(root, "dist", `${process.platform}-${process.arch}`); -export const binaryPath = join(output, "unix.node"); +export const binaryPath = join( + output, + process.platform === "win32" ? "windows.node" : "unix.node", +); export interface SyscallResult { value: number; diff --git a/plugins/codex-security/native/build.mts b/plugins/codex-security/native/build.mts index 50e675640..ba14ef327 100644 --- a/plugins/codex-security/native/build.mts +++ b/plugins/codex-security/native/build.mts @@ -5,7 +5,18 @@ import { join, resolve } from "node:path"; import { binaryPath, output, root } from "./binding.mjs"; import { checkPrivatePaths } from "./check.mjs"; -const extension = process.platform === "darwin" ? "dylib" : "so"; +let windowsTarget: string | undefined; +if (process.platform === "win32") { + const architecture = + process.arch === "arm64" + ? "aarch64" + : process.arch === "x64" + ? "x86_64" + : undefined; + if (architecture === undefined) + throw new Error("Windows native builds support x64 and arm64."); + windowsTarget = `${architecture}-pc-windows-msvc`; +} const cargoHome = resolve( root, process.env["CARGO_HOME"] ?? join(homedir(), ".cargo"), @@ -20,12 +31,15 @@ const inheritedFlags = []; const flags = [ ...inheritedFlags, + ...(windowsTarget === undefined ? [] : ["-C", "target-feature=+crt-static"]), `--remap-path-prefix=${root}=codex-security-native`, `--remap-path-prefix=${cargoHome}=cargo`, `--remap-path-prefix=${sysroot}=rust-toolchain`, ]; const target = resolve(root, process.env["CARGO_TARGET_DIR"] ?? "target"); -execFileSync("cargo", ["build", "--release", "--locked"], { +const args = ["build", "--release", "--locked"]; +if (windowsTarget !== undefined) args.push("--target", windowsTarget); +execFileSync("cargo", args, { cwd: root, stdio: "inherit", env: { @@ -38,10 +52,13 @@ execFileSync("cargo", ["build", "--release", "--locked"], { }); const library = join( target, + ...(windowsTarget === undefined ? [] : [windowsTarget]), "release", - `libcodex_security_native_unix.${extension}`, + windowsTarget === undefined + ? `libcodex_security_native.${process.platform === "darwin" ? "dylib" : "so"}` + : "codex_security_native.dll", ); -checkPrivatePaths(readFileSync(library), [root, cargoHome, sysroot]); +checkPrivatePaths(readFileSync(library), [root, cargoHome, sysroot, target]); mkdirSync(output, { recursive: true }); copyFileSync(library, binaryPath); console.log(`Built ${process.platform}-${process.arch} Node-API 8 primitives.`); diff --git a/plugins/codex-security/native/check.mts b/plugins/codex-security/native/check.mts index 41362fe85..acc949121 100644 --- a/plugins/codex-security/native/check.mts +++ b/plugins/codex-security/native/check.mts @@ -15,7 +15,10 @@ export function checkPrivatePaths( "/tmp/codex-security-python-", ...buildPaths, ]) { - if (bytes.includes(Buffer.from(marker))) { + if ( + bytes.includes(Buffer.from(marker)) || + bytes.includes(Buffer.from(marker, "utf16le")) + ) { throw new Error("Native payload contains a private build path."); } } @@ -73,8 +76,23 @@ if ( ); } floor = "macOS 11.0"; + } else if (process.platform === "win32") { + const header = bytes.readUInt32LE(0x3c); + const machine = process.arch === "arm64" ? 0xaa64 : 0x8664; + if ( + bytes.toString("ascii", 0, 2) !== "MZ" || + bytes.toString("ascii", header, header + 4) !== "PE\0\0" || + bytes.readUInt16LE(header + 4) !== machine + ) { + throw new Error( + "Native payload is not a PE image for this architecture.", + ); + } + floor = "Windows MSVC; Node 20 and 22 load proofs required"; } else { - throw new Error("This foundation verifies Linux and macOS artifacts only."); + throw new Error( + "This foundation verifies Linux, macOS, and Windows artifacts only.", + ); } console.log( JSON.stringify({ diff --git a/plugins/codex-security/native/proof-windows.mts b/plugins/codex-security/native/proof-windows.mts new file mode 100644 index 000000000..b310f0c58 --- /dev/null +++ b/plugins/codex-security/native/proof-windows.mts @@ -0,0 +1,579 @@ +import assert from "node:assert/strict"; +import { fork, type ChildProcess } from "node:child_process"; +import { + existsSync, + linkSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join, win32 } from "node:path"; +import { fileURLToPath } from "node:url"; +import { setImmediate } from "node:timers/promises"; +import { + loadWindowsBinding, + windowsFlags as flags, + type WindowsHandle, +} from "./windows-binding.mjs"; + +assert.equal(process.platform, "win32", "Windows proof requires Windows"); +const native = loadWindowsBinding(); +const shareAll = + flags.FILE_SHARE_READ | flags.FILE_SHARE_WRITE | flags.FILE_SHARE_DELETE; +const directoryFlags = + flags.FILE_FLAG_BACKUP_SEMANTICS | flags.FILE_FLAG_OPEN_REPARSE_POINT; +const readWrite = flags.GENERIC_READ | flags.GENERIC_WRITE; +const self = fileURLToPath(import.meta.url); +const pathBytes = (path: string) => + Buffer.from(win32.toNamespacedPath(path), "utf16le"); +const checked = (result: T): T => { + assert.equal(result.error, 0, `Win32 error ${result.error}`); + return result; +}; +const success = (error: number) => checked({ error }); + +function open( + path: string | Buffer, + access = readWrite | flags.DELETE | flags.FILE_READ_ATTRIBUTES, + share = shareAll, + disposition: number = flags.OPEN_EXISTING, + attributes: number = flags.FILE_ATTRIBUTE_NORMAL, +): WindowsHandle { + const result = checked( + native.openWindowsFile( + typeof path === "string" ? pathBytes(path) : path, + access, + share, + disposition, + attributes, + ), + ); + assert(result.handle); + return result.handle; +} + +function samePath(actual: Buffer, expected: string): void { + assert.equal( + win32.normalize(actual.toString("utf16le")).toLowerCase(), + win32.toNamespacedPath(expected).toLowerCase(), + ); +} + +function remove(path: string): void { + const result = native.openWindowsFile( + pathBytes(path), + flags.DELETE, + shareAll, + flags.OPEN_EXISTING, + directoryFlags, + ); + if (result.error === 2 || result.error === 3) return; + const handle = checked(result).handle!; + try { + success(handle.setDisposition(true)); + } finally { + success(handle.close()); + } +} + +function handleProof(root: string) { + const held = new Set(); + const rawPaths: string[] = []; + const keep = (handle: WindowsHandle) => { + held.add(handle); + return handle; + }; + const close = (handle: WindowsHandle) => { + success(handle.close()); + held.delete(handle); + }; + try { + const path = join(root, "data"); + const file = keep(open(path, undefined, undefined, flags.CREATE_NEW)); + const payload = Buffer.from("handle I/O 🔐\n"); + const input = Buffer.concat([ + Buffer.from("ignore"), + payload, + Buffer.from("tail"), + ]); + assert.equal( + checked(file.write(input, 6, payload.length)).value, + payload.length, + ); + success(file.flush()); + assert.equal(checked(file.size()).value, String(payload.length)); + assert.equal(checked(file.fileType()).value, 1); + assert.equal( + checked(file.attributes()).attributes & flags.FILE_ATTRIBUTE_DIRECTORY, + 0, + ); + assert.equal(checked(file.seek(0n, flags.FILE_BEGIN)).value, "0"); + const buffer = Buffer.alloc(payload.length + 6, 0x7e); + assert.equal( + checked(file.read(buffer, 3, payload.length)).value, + payload.length, + ); + assert.deepEqual(buffer.subarray(3, -3), payload); + assert.deepEqual(buffer.subarray(0, 3), Buffer.from("~~~")); + assert.deepEqual(buffer.subarray(-3), Buffer.from("~~~")); + assert.equal(checked(file.read(buffer, 0, 1)).value, 0); + const far = (1n << 53n) + 5n; + assert.equal( + checked(file.seek(far, flags.FILE_BEGIN)).value, + far.toString(), + ); + assert.equal( + checked(file.seek(-2n, flags.FILE_END)).value, + String(payload.length - 2), + ); + success(file.setEndOfFile()); + assert.equal(checked(file.size()).value, String(payload.length - 2)); + samePath(checked(file.finalPath(0)).path, path); + samePath(checked(file.finalPath(flags.FILE_NAME_OPENED)).path, path); + + const identity = checked(file.identity()); + assert.match(identity.volume, /^\d+$/u); + assert.equal(identity.fileId.length, 16); + const link = join(root, "hard-link"); + linkSync(path, link); + const second = keep(open(link)); + assert.deepEqual(checked(second.identity()), identity); + close(second); + assert.equal( + native.openWindowsFile( + pathBytes(path), + flags.GENERIC_READ, + 0, + flags.OPEN_EXISTING, + 0, + ).error, + 32, + ); + assert.equal( + native.openWindowsFile( + pathBytes(join(root, "missing")), + flags.GENERIC_READ, + shareAll, + flags.OPEN_EXISTING, + 0, + ).error, + 2, + ); + for (const [offset, length] of [ + [-1, 1], + [0, -1], + [0.5, 1], + [0, NaN], + [0, Infinity], + [0, 2 ** 32], + [buffer.length, 1], + ]) { + assert.throws(() => file.read(buffer, offset!, length!)); + assert.throws(() => file.write(buffer, offset!, length!)); + } + for (const invalid of [ + Buffer.from([0x41]), + Buffer.from("bad\0path", "utf16le"), + ]) { + assert.throws(() => + native.openWindowsFile(invalid, 0, 0, flags.OPEN_EXISTING, 0), + ); + } + assert.throws(() => + native.openWindowsFile( + pathBytes(path), + readWrite, + shareAll, + flags.OPEN_EXISTING, + flags.FILE_FLAG_OVERLAPPED, + ), + ); + assert.throws(() => file.seek(1n << 63n, flags.FILE_BEGIN)); + assert.throws(() => file.lock(-1n, 1n, true, true)); + assert.throws(() => file.lock(0n, 1n << 64n, true, true)); + close(file); + success(file.close()); + assert.equal(file.size().error, 6); + + const ancestor = join(root, "ancestor"); + const scan = join(ancestor, "scan"); + const child = join(scan, "child"); + for (const directory of [ancestor, scan, child]) { + success(native.createWindowsDirectory(pathBytes(directory))); + } + const directories = [ancestor, scan, child].map((directory) => + keep( + open( + directory, + flags.GENERIC_READ, + flags.FILE_SHARE_READ | flags.FILE_SHARE_WRITE, + flags.OPEN_EXISTING, + directoryFlags, + ), + ), + ); + for (const directory of [ancestor, scan, child]) { + assert.equal( + native.openWindowsFile( + pathBytes(directory), + flags.DELETE, + shareAll, + flags.OPEN_EXISTING, + directoryFlags, + ).error, + 32, + ); + assert.throws(() => renameSync(directory, directory + "-moved")); + } + for (const handle of directories.reverse()) close(handle); + renameSync(ancestor, ancestor + "-moved"); + const target = join(root, "target"); + mkdirSync(target); + writeFileSync(join(target, "sentinel"), "target unchanged"); + symlinkSync(target, ancestor, "junction"); + const junction = keep( + open( + ancestor, + flags.FILE_READ_ATTRIBUTES, + shareAll, + flags.OPEN_EXISTING, + directoryFlags, + ), + ); + const attributes = checked(junction.attributes()); + assert(attributes.attributes & flags.FILE_ATTRIBUTE_REPARSE_POINT); + assert(attributes.attributes & flags.FILE_ATTRIBUTE_DIRECTORY); + assert.equal(attributes.reparseTag, 0xa0000003); + samePath( + checked(junction.finalPath(flags.FILE_NAME_OPENED)).path, + ancestor, + ); + const followed = keep( + open( + ancestor, + flags.FILE_READ_ATTRIBUTES, + shareAll, + flags.OPEN_EXISTING, + flags.FILE_FLAG_BACKUP_SEMANTICS, + ), + ); + samePath(checked(followed.finalPath(0)).path, target); + assert( + !( + checked(followed.attributes()).attributes & + flags.FILE_ATTRIBUTE_REPARSE_POINT + ), + ); + close(followed); + close(junction); + remove(ancestor); + assert.equal( + readFileSync(join(target, "sentinel"), "utf8"), + "target unchanged", + ); + + const source = join(root, "source"); + const moved = join(root, "moved-source"); + const destination = join(root, "destination"); + const exact = keep(open(source, undefined, undefined, flags.CREATE_NEW)); + const exactPayload = Buffer.from("exact handle"); + assert.equal( + checked(exact.write(exactPayload, 0, exactPayload.length)).value, + exactPayload.length, + ); + success(exact.flush()); + const exactIdentity = checked(exact.identity()); + renameSync(source, moved); + writeFileSync(source, "replacement source"); + writeFileSync(destination, "old destination"); + assert([80, 183].includes(exact.rename(pathBytes(destination), false))); + success(exact.rename(pathBytes(destination), true)); + assert.equal(readFileSync(destination, "utf8"), "exact handle"); + assert.equal(readFileSync(source, "utf8"), "replacement source"); + assert(!existsSync(moved)); + assert.deepEqual(checked(exact.identity()), exactIdentity); + samePath(checked(exact.finalPath(0)).path, destination); + renameSync(destination, moved); + writeFileSync(destination, "replacement destination"); + success(exact.setDisposition(true)); + close(exact); + assert(!existsSync(moved)); + assert.equal(readFileSync(destination, "utf8"), "replacement destination"); + + let longDirectory = root; + for (let index = 0; index < 5; index++) { + longDirectory = join(longDirectory, `part-${index}-${"x".repeat(55)}`); + success(native.createWindowsDirectory(pathBytes(longDirectory))); + } + const rawDirectory = join(longDirectory, "directory-\udfff"); + success(native.createWindowsDirectory(pathBytes(rawDirectory))); + rawPaths.push(rawDirectory); + const rawPath = join(rawDirectory, "file-\ud800"); + // A lossy UTF-8 round trip would collide with this different filename. + const replacement = join(longDirectory, "directory-\ufffd"); + mkdirSync(replacement); + writeFileSync(join(replacement, "file-\ufffd"), "replacement sentinel"); + const raw = keep(open(rawPath, undefined, undefined, flags.CREATE_NEW)); + rawPaths.push(rawPath); + assert.equal( + checked(raw.write(exactPayload, 0, exactPayload.length)).value, + exactPayload.length, + ); + success(raw.flush()); + const finalName = checked(raw.finalPath(flags.FILE_NAME_OPENED)).path; + assert(finalName.length / 2 > 260); + assert( + finalName.includes( + Buffer.from("directory-\udfff\\file-\ud800", "utf16le"), + ), + ); + const reopened = keep(open(finalName)); + assert.deepEqual(checked(reopened.identity()), checked(raw.identity())); + const rawContents = Buffer.alloc(exactPayload.length); + assert.equal( + checked(reopened.read(rawContents, 0, rawContents.length)).value, + rawContents.length, + ); + assert.deepEqual(rawContents, exactPayload); + close(reopened); + close(raw); + assert.equal( + readFileSync(join(replacement, "file-\ufffd"), "utf8"), + "replacement sentinel", + ); + return { + handleReadWriteFlushSeekSizeAndEof: true, + exact64BitPositionAnd128BitIdentity: true, + numericMissingSharingAndClosedErrors: true, + ancestorReplacementBlockedUntilClose: true, + junctionMetadataAndFinalNames: true, + exactHandleRenameAndDeleteAfterNameReplacement: true, + rawUtf16AndLongPaths: true, + invalidFfiRepresentationsRejected: true, + }; + } finally { + for (const handle of held) handle.close(); + for (const path of rawPaths.reverse()) remove(path); + } +} + +async function ownershipProof(root: string): Promise { + assert(global.gc, "Run the Windows proof with --expose-gc"); + const path = join(root, "garbage-collected-handle"); + open(path, readWrite, 0, flags.CREATE_NEW); + await setImmediate(); + global.gc(); + await setImmediate(); + const reopened = open(path, readWrite, 0); + success(reopened.close()); + return true; +} + +interface Message { + type: string; + error?: number; +} +const send = (message: Message) => + new Promise((resolve, reject) => { + process.send!(message, (error) => (error ? reject(error) : resolve())); + }); + +async function worker(path: string): Promise { + const handle = open(path, readWrite, shareAll, flags.OPEN_ALWAYS); + process.on("disconnect", () => { + handle.close(); + process.exit(0); + }); + process.on("message", async (command: string) => { + if (command === "probe") { + const error = handle.lock(0n, 1n, true, true); + if (error === 0) success(handle.unlock(0n, 1n)); + await send({ type: "probe", error }); + } else if (command === "lock") { + await send({ type: "attempting" }); + success(handle.lock(0n, 1n, true, false)); + await send({ type: "acquired" }); + } else if (command === "unlock") { + success(handle.unlock(0n, 1n)); + await send({ type: "released" }); + } else if (command === "close") { + success(handle.close()); + await send({ type: "closed" }); + } else if (command === "exit") { + success(handle.close()); + process.disconnect!(); + } else throw new Error(`Unknown worker command: ${command}`); + }); + await send({ type: "ready" }); +} + +const peers = new Set(); +class Peer { + readonly child: ChildProcess; + readonly exited: Promise; + private messages: Message[] = []; + private pending?: (message: Message) => void; + private stderr = ""; + constructor(path: string) { + this.child = fork(self, ["worker", path], { + execPath: process.execPath, + execArgv: [], + stdio: ["ignore", "pipe", "pipe", "ipc"], + }); + peers.add(this); + this.child.stderr!.on("data", (chunk: Buffer) => { + this.stderr += chunk.toString(); + }); + this.child.on("message", (message: Message) => { + if (this.pending) { + const receive = this.pending; + this.pending = undefined; + receive(message); + } else this.messages.push(message); + }); + this.exited = new Promise((resolve) => + this.child.once("exit", () => resolve()), + ); + } + send(command: string): void { + this.child.send(command); + } + async next(type: string): Promise { + let timer: ReturnType | undefined; + try { + const message = await Promise.race([ + this.messages.length + ? Promise.resolve(this.messages.shift()!) + : new Promise((resolve) => { + this.pending = resolve; + }), + this.exited.then(() => { + throw new Error(`Worker exited before ${type}: ${this.stderr}`); + }), + new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new Error(`Worker timed out before ${type}: ${this.stderr}`), + ), + 30_000, + ); + }), + ]); + assert.equal(message.type, type); + return message; + } finally { + clearTimeout(timer); + } + } + async stop(kill = false): Promise { + if (kill) this.child.kill("SIGKILL"); + else this.send("exit"); + let timer: ReturnType | undefined; + try { + await Promise.race([ + this.exited, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Worker did not exit: ${this.stderr}`)), + 30_000, + ); + }), + ]); + } finally { + clearTimeout(timer); + } + peers.delete(this); + if (!kill) { + assert.equal(this.child.exitCode, 0, this.stderr); + assert.equal(this.stderr, ""); + } + } +} + +async function lockProof(root: string) { + const path = join(root, "byte-lock"); + const parent = open(path, readWrite, shareAll, flags.OPEN_ALWAYS); + const other = open(path, readWrite, shareAll); + try { + const high = (1n << 32n) + 5n; + success(parent.lock(high, 2n, true, true)); + assert.equal(other.lock(high, 1n, true, true), 33); + success(other.lock(5n, 1n, true, true)); + success(other.unlock(5n, 1n)); + success(parent.unlock(high, 2n)); + success(parent.lock(0n, 1n, true, true)); + const first = new Peer(path); + await first.next("ready"); + first.send("probe"); + assert.equal((await first.next("probe")).error, 33); + first.send("lock"); + await first.next("attempting"); + success(parent.unlock(0n, 1n)); + await first.next("acquired"); + assert.equal(parent.lock(0n, 1n, true, true), 33); + first.send("unlock"); + await first.next("released"); + success(parent.lock(0n, 1n, true, true)); + first.send("lock"); + await first.next("attempting"); + success(parent.close()); + await first.next("acquired"); + const second = new Peer(path); + await second.next("ready"); + second.send("lock"); + await second.next("attempting"); + await first.stop(true); + await second.next("acquired"); + assert.equal(other.lock(0n, 1n, true, true), 33); + second.send("close"); + await second.next("closed"); + success(other.lock(0n, 1n, true, true)); + success(other.unlock(0n, 1n)); + await second.stop(); + return { + byteZeroContention: true, + high64BitRanges: true, + blockingHandoff: true, + unlockCloseAndProcessDeathRelease: true, + }; + } finally { + parent.close(); + other.close(); + for (const peer of peers) await peer.stop(true); + } +} + +if (process.argv[2] === "worker") { + await worker(process.argv[3]!); +} else { + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), "codex-security-windows-")), + ); + try { + console.log( + JSON.stringify( + { + node: process.version, + platform: process.platform, + architecture: process.arch, + nodeApi: 8, + handles: handleProof(root), + garbageCollectionClosesHandle: await ownershipProof(root), + locks: await lockProof(root), + fixture: basename(root), + }, + null, + 2, + ), + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} diff --git a/plugins/codex-security/native/src/lib.rs b/plugins/codex-security/native/src/lib.rs index 713b39abd..5ec2a12ac 100644 --- a/plugins/codex-security/native/src/lib.rs +++ b/plugins/codex-security/native/src/lib.rs @@ -1,213 +1,4 @@ -use napi::bindgen_prelude::Buffer; -use napi_derive::napi; -use std::{ - ffi::{CStr, CString}, - io, -}; - -#[napi(object)] -pub struct SyscallResult { - pub value: i32, - pub errno: i32, -} - -fn result(value: i32) -> SyscallResult { - SyscallResult { - value, - errno: if value < 0 { - io::Error::last_os_error().raw_os_error().unwrap() - } else { - 0 - }, - } -} - -fn retry_eintr(mut operation: impl FnMut() -> i32) -> SyscallResult { - loop { - let value = result(operation()); - if value.errno != libc::EINTR { - return value; - } - } -} - -fn path(value: Buffer) -> napi::Result { - CString::new(value.as_ref()).map_err(|_| napi::Error::from_reason("Path contains a NUL byte")) -} - -#[napi] -pub fn open_at(directory: i32, name: Buffer, flags: i32, mode: u32) -> napi::Result { - let name = path(name)?; - let mode = mode as libc::mode_t; - // macOS mode_t is u16 and needs C integer promotion in this variadic call. - #[cfg(target_os = "macos")] - let mode = libc::c_int::from(mode); - Ok(retry_eintr(|| unsafe { - libc::openat(directory, name.as_ptr(), flags | libc::O_CLOEXEC, mode) - })) -} - -#[napi] -pub fn make_directory_at(directory: i32, name: Buffer, mode: u32) -> napi::Result { - let name = path(name)?; - Ok(result(unsafe { - libc::mkdirat(directory, name.as_ptr(), mode as libc::mode_t) - })) -} - -#[napi] -pub fn rename_at( - old_directory: i32, - old_name: Buffer, - new_directory: i32, - new_name: Buffer, -) -> napi::Result { - let old_name = path(old_name)?; - let new_name = path(new_name)?; - Ok(result(unsafe { - libc::renameat( - old_directory, - old_name.as_ptr(), - new_directory, - new_name.as_ptr(), - ) - })) -} - -#[napi] -pub fn unlink_at(directory: i32, name: Buffer) -> napi::Result { - let name = path(name)?; - Ok(result(unsafe { - libc::unlinkat(directory, name.as_ptr(), 0) - })) -} - -#[napi] -pub fn duplicate(descriptor: i32) -> SyscallResult { - result(unsafe { libc::fcntl(descriptor, libc::F_DUPFD_CLOEXEC, 0) }) -} - -#[napi] -pub fn file_lock(descriptor: i32, unlock: bool, nonblocking: bool) -> SyscallResult { - let flags = if unlock { - libc::LOCK_UN - } else { - libc::LOCK_EX | if nonblocking { libc::LOCK_NB } else { 0 } - }; - retry_eintr(|| unsafe { libc::flock(descriptor, flags) }) -} - -#[napi(object)] -pub struct MetadataResult { - pub errno: i32, - pub mode: u32, - pub device: String, - pub inode: String, -} - -#[napi] -// libc stat field widths differ between Linux and macOS. -#[allow(clippy::unnecessary_cast)] -pub fn stat_at(directory: i32, name: Buffer) -> napi::Result { - let name = path(name)?; - let mut stat = std::mem::MaybeUninit::::uninit(); - let code = unsafe { - libc::fstatat( - directory, - name.as_ptr(), - stat.as_mut_ptr(), - libc::AT_SYMLINK_NOFOLLOW, - ) - }; - if code < 0 { - return Ok(MetadataResult { - errno: io::Error::last_os_error().raw_os_error().unwrap(), - mode: 0, - device: String::new(), - inode: String::new(), - }); - } - let stat = unsafe { stat.assume_init() }; - Ok(MetadataResult { - errno: 0, - mode: stat.st_mode as u32, - device: (stat.st_dev as u64).to_string(), - inode: stat.st_ino.to_string(), - }) -} - -#[napi(object)] -pub struct ReadLinkResult { - pub errno: i32, - pub value: Buffer, -} - -#[napi] -pub fn read_link_at(directory: i32, name: Buffer) -> napi::Result { - let name = path(name)?; - let mut buffer = vec![0_u8; 256]; - loop { - let length = unsafe { - libc::readlinkat( - directory, - name.as_ptr(), - buffer.as_mut_ptr().cast(), - buffer.len(), - ) - }; - if length < 0 { - return Ok(ReadLinkResult { - errno: io::Error::last_os_error().raw_os_error().unwrap(), - value: Vec::new().into(), - }); - } - if (length as usize) < buffer.len() { - buffer.truncate(length as usize); - return Ok(ReadLinkResult { - errno: 0, - value: buffer.into(), - }); - } - buffer.resize(buffer.len() * 2, 0); - } -} - -#[napi(object, use_nullable = true)] -pub struct UserHomeResult { - pub errno: i32, - pub value: Option, -} - -#[napi] -pub fn user_home(username: Buffer) -> napi::Result { - let username = path(username)?; - let mut buffer = vec![0_u8; 1024]; - loop { - let mut entry = std::mem::MaybeUninit::::uninit(); - let mut found = std::ptr::null_mut(); - let code = unsafe { - libc::getpwnam_r( - username.as_ptr(), - entry.as_mut_ptr(), - buffer.as_mut_ptr().cast(), - buffer.len(), - &mut found, - ) - }; - if code == libc::ERANGE { - buffer.resize(buffer.len() * 2, 0); - continue; - } - let value = if code == 0 && !found.is_null() { - Some( - unsafe { CStr::from_ptr((*found).pw_dir) } - .to_bytes() - .to_vec() - .into(), - ) - } else { - None - }; - return Ok(UserHomeResult { errno: code, value }); - } -} +#[cfg(unix)] +mod unix; +#[cfg(windows)] +mod windows; diff --git a/plugins/codex-security/native/src/unix.rs b/plugins/codex-security/native/src/unix.rs new file mode 100644 index 000000000..713b39abd --- /dev/null +++ b/plugins/codex-security/native/src/unix.rs @@ -0,0 +1,213 @@ +use napi::bindgen_prelude::Buffer; +use napi_derive::napi; +use std::{ + ffi::{CStr, CString}, + io, +}; + +#[napi(object)] +pub struct SyscallResult { + pub value: i32, + pub errno: i32, +} + +fn result(value: i32) -> SyscallResult { + SyscallResult { + value, + errno: if value < 0 { + io::Error::last_os_error().raw_os_error().unwrap() + } else { + 0 + }, + } +} + +fn retry_eintr(mut operation: impl FnMut() -> i32) -> SyscallResult { + loop { + let value = result(operation()); + if value.errno != libc::EINTR { + return value; + } + } +} + +fn path(value: Buffer) -> napi::Result { + CString::new(value.as_ref()).map_err(|_| napi::Error::from_reason("Path contains a NUL byte")) +} + +#[napi] +pub fn open_at(directory: i32, name: Buffer, flags: i32, mode: u32) -> napi::Result { + let name = path(name)?; + let mode = mode as libc::mode_t; + // macOS mode_t is u16 and needs C integer promotion in this variadic call. + #[cfg(target_os = "macos")] + let mode = libc::c_int::from(mode); + Ok(retry_eintr(|| unsafe { + libc::openat(directory, name.as_ptr(), flags | libc::O_CLOEXEC, mode) + })) +} + +#[napi] +pub fn make_directory_at(directory: i32, name: Buffer, mode: u32) -> napi::Result { + let name = path(name)?; + Ok(result(unsafe { + libc::mkdirat(directory, name.as_ptr(), mode as libc::mode_t) + })) +} + +#[napi] +pub fn rename_at( + old_directory: i32, + old_name: Buffer, + new_directory: i32, + new_name: Buffer, +) -> napi::Result { + let old_name = path(old_name)?; + let new_name = path(new_name)?; + Ok(result(unsafe { + libc::renameat( + old_directory, + old_name.as_ptr(), + new_directory, + new_name.as_ptr(), + ) + })) +} + +#[napi] +pub fn unlink_at(directory: i32, name: Buffer) -> napi::Result { + let name = path(name)?; + Ok(result(unsafe { + libc::unlinkat(directory, name.as_ptr(), 0) + })) +} + +#[napi] +pub fn duplicate(descriptor: i32) -> SyscallResult { + result(unsafe { libc::fcntl(descriptor, libc::F_DUPFD_CLOEXEC, 0) }) +} + +#[napi] +pub fn file_lock(descriptor: i32, unlock: bool, nonblocking: bool) -> SyscallResult { + let flags = if unlock { + libc::LOCK_UN + } else { + libc::LOCK_EX | if nonblocking { libc::LOCK_NB } else { 0 } + }; + retry_eintr(|| unsafe { libc::flock(descriptor, flags) }) +} + +#[napi(object)] +pub struct MetadataResult { + pub errno: i32, + pub mode: u32, + pub device: String, + pub inode: String, +} + +#[napi] +// libc stat field widths differ between Linux and macOS. +#[allow(clippy::unnecessary_cast)] +pub fn stat_at(directory: i32, name: Buffer) -> napi::Result { + let name = path(name)?; + let mut stat = std::mem::MaybeUninit::::uninit(); + let code = unsafe { + libc::fstatat( + directory, + name.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if code < 0 { + return Ok(MetadataResult { + errno: io::Error::last_os_error().raw_os_error().unwrap(), + mode: 0, + device: String::new(), + inode: String::new(), + }); + } + let stat = unsafe { stat.assume_init() }; + Ok(MetadataResult { + errno: 0, + mode: stat.st_mode as u32, + device: (stat.st_dev as u64).to_string(), + inode: stat.st_ino.to_string(), + }) +} + +#[napi(object)] +pub struct ReadLinkResult { + pub errno: i32, + pub value: Buffer, +} + +#[napi] +pub fn read_link_at(directory: i32, name: Buffer) -> napi::Result { + let name = path(name)?; + let mut buffer = vec![0_u8; 256]; + loop { + let length = unsafe { + libc::readlinkat( + directory, + name.as_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + ) + }; + if length < 0 { + return Ok(ReadLinkResult { + errno: io::Error::last_os_error().raw_os_error().unwrap(), + value: Vec::new().into(), + }); + } + if (length as usize) < buffer.len() { + buffer.truncate(length as usize); + return Ok(ReadLinkResult { + errno: 0, + value: buffer.into(), + }); + } + buffer.resize(buffer.len() * 2, 0); + } +} + +#[napi(object, use_nullable = true)] +pub struct UserHomeResult { + pub errno: i32, + pub value: Option, +} + +#[napi] +pub fn user_home(username: Buffer) -> napi::Result { + let username = path(username)?; + let mut buffer = vec![0_u8; 1024]; + loop { + let mut entry = std::mem::MaybeUninit::::uninit(); + let mut found = std::ptr::null_mut(); + let code = unsafe { + libc::getpwnam_r( + username.as_ptr(), + entry.as_mut_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut found, + ) + }; + if code == libc::ERANGE { + buffer.resize(buffer.len() * 2, 0); + continue; + } + let value = if code == 0 && !found.is_null() { + Some( + unsafe { CStr::from_ptr((*found).pw_dir) } + .to_bytes() + .to_vec() + .into(), + ) + } else { + None + }; + return Ok(UserHomeResult { errno: code, value }); + } +} diff --git a/plugins/codex-security/native/src/windows.rs b/plugins/codex-security/native/src/windows.rs new file mode 100644 index 000000000..e77872408 --- /dev/null +++ b/plugins/codex-security/native/src/windows.rs @@ -0,0 +1,416 @@ +use napi::bindgen_prelude::{BigInt, Buffer}; +use napi_derive::napi; +use std::{ + mem::{offset_of, size_of, MaybeUninit}, + os::windows::io::{AsRawHandle, FromRawHandle, IntoRawHandle, OwnedHandle}, + ptr::{copy_nonoverlapping, null, null_mut}, +}; +use windows_sys::Win32::{ + Foundation::{CloseHandle, GetLastError, SetLastError, HANDLE, INVALID_HANDLE_VALUE}, + Storage::FileSystem::*, + System::IO::OVERLAPPED, +}; + +fn invalid(message: &str) -> napi::Error { + napi::Error::new(napi::Status::InvalidArg, message) +} + +fn status(success: i32) -> u32 { + if success == 0 { + unsafe { GetLastError() } + } else { + 0 + } +} + +fn wide_path(bytes: Buffer) -> napi::Result> { + if !bytes.len().is_multiple_of(2) { + return Err(invalid("Path must contain whole UTF-16LE code units")); + } + let mut path = bytes + .chunks_exact(2) + .map(|part| u16::from_le_bytes([part[0], part[1]])) + .collect::>(); + if path.contains(&0) { + return Err(invalid("Path contains a NUL code unit")); + } + path.push(0); + Ok(path) +} + +fn io_range(buffer: &Buffer, offset: f64, length: f64) -> napi::Result<(usize, u32)> { + if !offset.is_finite() + || !length.is_finite() + || offset.fract() != 0.0 + || length.fract() != 0.0 + || offset < 0.0 + || length < 0.0 + || length > u32::MAX as f64 + || offset + length > buffer.len() as f64 + { + return Err(invalid("I/O range must fit the buffer and a Win32 DWORD")); + } + Ok((offset as usize, length as u32)) +} + +fn unsigned(value: BigInt) -> napi::Result { + let (_, value, lossless) = value.get_u64(); + if !lossless { + return Err(invalid("Byte range must fit an unsigned 64-bit integer")); + } + Ok(value) +} + +fn overlapped(offset: u64) -> OVERLAPPED { + let mut value = OVERLAPPED::default(); + value.Anonymous.Anonymous.Offset = offset as u32; + value.Anonymous.Anonymous.OffsetHigh = (offset >> 32) as u32; + value +} + +#[napi] +pub struct WindowsHandle { + handle: Option, +} + +impl WindowsHandle { + fn raw(&self) -> HANDLE { + self.handle + .as_ref() + .map_or(INVALID_HANDLE_VALUE, AsRawHandle::as_raw_handle) + } +} + +#[napi(object, object_from_js = false)] +pub struct OpenResult { + pub error: u32, + pub handle: Option, +} + +#[napi(object)] +pub struct WindowsResult { + pub error: u32, + pub value: u32, +} + +#[napi(object)] +pub struct AttributesResult { + pub error: u32, + pub attributes: u32, + pub reparse_tag: u32, +} + +#[napi(object)] +pub struct IdentityResult { + pub error: u32, + pub volume: String, + pub file_id: Buffer, +} + +#[napi(object)] +pub struct PositionResult { + pub error: u32, + pub value: String, +} + +#[napi(object)] +pub struct PathResult { + pub error: u32, + pub path: Buffer, +} + +#[napi] +pub fn open_windows_file( + path: Buffer, + access: u32, + share: u32, + disposition: u32, + flags: u32, +) -> napi::Result { + // Pending overlapped I/O could retain pointers after these synchronous calls return. + if flags & FILE_FLAG_OVERLAPPED != 0 { + return Err(invalid("Overlapped handles are not supported")); + } + let path = wide_path(path)?; + let handle = unsafe { + CreateFileW( + path.as_ptr(), + access, + share, + null(), + disposition, + flags, + null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Ok(OpenResult { + error: unsafe { GetLastError() }, + handle: None, + }); + } + Ok(OpenResult { + error: 0, + handle: Some(WindowsHandle { + handle: Some(unsafe { OwnedHandle::from_raw_handle(handle) }), + }), + }) +} + +#[napi] +pub fn create_windows_directory(path: Buffer) -> napi::Result { + let path = wide_path(path)?; + Ok(status(unsafe { CreateDirectoryW(path.as_ptr(), null()) })) +} + +#[napi] +impl WindowsHandle { + #[napi] + pub fn close(&mut self) -> u32 { + self.handle.take().map_or(0, |handle| { + status(unsafe { CloseHandle(handle.into_raw_handle()) }) + }) + } + + #[napi] + pub fn attributes(&self) -> AttributesResult { + let mut info = FILE_ATTRIBUTE_TAG_INFO::default(); + let error = status(unsafe { + GetFileInformationByHandleEx( + self.raw(), + FileAttributeTagInfo, + (&mut info as *mut FILE_ATTRIBUTE_TAG_INFO).cast(), + size_of::() as u32, + ) + }); + AttributesResult { + error, + attributes: info.FileAttributes, + reparse_tag: info.ReparseTag, + } + } + + #[napi] + pub fn identity(&self) -> IdentityResult { + let mut info = FILE_ID_INFO::default(); + let error = status(unsafe { + GetFileInformationByHandleEx( + self.raw(), + FileIdInfo, + (&mut info as *mut FILE_ID_INFO).cast(), + size_of::() as u32, + ) + }); + IdentityResult { + error, + volume: info.VolumeSerialNumber.to_string(), + file_id: info.FileId.Identifier.to_vec().into(), + } + } + + #[napi] + pub fn file_type(&self) -> WindowsResult { + unsafe { SetLastError(0) }; + let value = unsafe { GetFileType(self.raw()) }; + WindowsResult { + error: if value == FILE_TYPE_UNKNOWN { + unsafe { GetLastError() } + } else { + 0 + }, + value, + } + } + + #[napi] + pub fn final_path(&self, flags: u32) -> napi::Result { + let mut path = vec![0_u16; 256]; + loop { + let capacity = u32::try_from(path.len()) + .map_err(|_| invalid("Final path exceeds the Win32 buffer size"))?; + let length = unsafe { + GetFinalPathNameByHandleW(self.raw(), path.as_mut_ptr(), capacity, flags) + }; + if length == 0 { + return Ok(PathResult { + error: unsafe { GetLastError() }, + path: Vec::new().into(), + }); + } + if length < capacity { + return Ok(PathResult { + error: 0, + path: path[..length as usize] + .iter() + .flat_map(|unit| unit.to_le_bytes()) + .collect::>() + .into(), + }); + } + path.resize(length as usize + 1, 0); + } + } + + #[napi] + pub fn read( + &self, + mut buffer: Buffer, + offset: f64, + length: f64, + ) -> napi::Result { + let (offset, length) = io_range(&buffer, offset, length)?; + let mut value = 0; + let error = status(unsafe { + ReadFile( + self.raw(), + buffer.as_mut_ptr().add(offset), + length, + &mut value, + null_mut(), + ) + }); + Ok(WindowsResult { error, value }) + } + + #[napi] + pub fn write(&self, buffer: Buffer, offset: f64, length: f64) -> napi::Result { + let (offset, length) = io_range(&buffer, offset, length)?; + let mut value = 0; + let error = status(unsafe { + WriteFile( + self.raw(), + buffer.as_ptr().add(offset), + length, + &mut value, + null_mut(), + ) + }); + Ok(WindowsResult { error, value }) + } + + #[napi] + pub fn seek(&self, distance: BigInt, origin: u32) -> napi::Result { + let (distance, lossless) = distance.get_i64(); + if !lossless { + return Err(invalid("Seek offset must fit a signed 64-bit integer")); + } + let mut value = 0; + let error = status(unsafe { SetFilePointerEx(self.raw(), distance, &mut value, origin) }); + Ok(PositionResult { + error, + value: value.to_string(), + }) + } + + #[napi] + pub fn size(&self) -> PositionResult { + let mut value = 0; + let error = status(unsafe { GetFileSizeEx(self.raw(), &mut value) }); + PositionResult { + error, + value: value.to_string(), + } + } + + #[napi] + pub fn set_end_of_file(&self) -> u32 { + status(unsafe { SetEndOfFile(self.raw()) }) + } + + #[napi] + pub fn flush(&self) -> u32 { + status(unsafe { FlushFileBuffers(self.raw()) }) + } + + #[napi] + pub fn rename(&self, destination: Buffer, replace: bool) -> napi::Result { + let path = wide_path(destination)?; + let name_bytes = (path.len() - 1) * size_of::(); + let size = offset_of!(FILE_RENAME_INFO, FileName) + .checked_add(name_bytes + size_of::()) + .ok_or_else(|| invalid("Rename path exceeds the Win32 buffer size"))? + .max(size_of::()); + let size_u32 = u32::try_from(size) + .map_err(|_| invalid("Rename path exceeds the Win32 buffer size"))?; + // Allocate with the generated structure's alignment, including its variable tail. + let mut storage = vec![ + MaybeUninit::::zeroed(); + size.div_ceil(size_of::()) + ]; + let info = storage.as_mut_ptr().cast::(); + unsafe { + (*info).Anonymous.ReplaceIfExists = replace; + (*info).RootDirectory = null_mut(); + (*info).FileNameLength = name_bytes as u32; + copy_nonoverlapping( + path.as_ptr(), + info.cast::() + .add(offset_of!(FILE_RENAME_INFO, FileName)) + .cast::(), + path.len(), + ); + } + Ok(status(unsafe { + SetFileInformationByHandle(self.raw(), FileRenameInfo, info.cast(), size_u32) + })) + } + + #[napi] + pub fn set_disposition(&self, delete: bool) -> u32 { + let info = FILE_DISPOSITION_INFO { DeleteFile: delete }; + status(unsafe { + SetFileInformationByHandle( + self.raw(), + FileDispositionInfo, + (&info as *const FILE_DISPOSITION_INFO).cast(), + size_of::() as u32, + ) + }) + } + + #[napi] + pub fn lock( + &self, + offset: BigInt, + length: BigInt, + exclusive: bool, + nonblocking: bool, + ) -> napi::Result { + let mut position = overlapped(unsigned(offset)?); + let length = unsigned(length)?; + let flags = (if exclusive { + LOCKFILE_EXCLUSIVE_LOCK + } else { + 0 + }) | (if nonblocking { + LOCKFILE_FAIL_IMMEDIATELY + } else { + 0 + }); + Ok(status(unsafe { + LockFileEx( + self.raw(), + flags, + 0, + length as u32, + (length >> 32) as u32, + &mut position, + ) + })) + } + + #[napi] + pub fn unlock(&self, offset: BigInt, length: BigInt) -> napi::Result { + let mut position = overlapped(unsigned(offset)?); + let length = unsigned(length)?; + Ok(status(unsafe { + UnlockFileEx( + self.raw(), + 0, + length as u32, + (length >> 32) as u32, + &mut position, + ) + })) + } +} diff --git a/plugins/codex-security/native/windows-binding.mts b/plugins/codex-security/native/windows-binding.mts new file mode 100644 index 000000000..478fb4ba8 --- /dev/null +++ b/plugins/codex-security/native/windows-binding.mts @@ -0,0 +1,70 @@ +import { createRequire } from "node:module"; +import { binaryPath } from "./binding.mjs"; + +export interface WindowsResult { + error: number; + value: T; +} + +/** Owns a synchronous Win32 handle. close() is idempotent; GC also closes it. */ +export interface WindowsHandle { + close(): number; + attributes(): { error: number; attributes: number; reparseTag: number }; + identity(): { error: number; volume: string; fileId: Buffer }; + fileType(): WindowsResult; + finalPath(flags: number): { error: number; path: Buffer }; + read(buffer: Buffer, offset: number, length: number): WindowsResult; + write(buffer: Buffer, offset: number, length: number): WindowsResult; + seek(distance: bigint, origin: number): WindowsResult; + size(): WindowsResult; + setEndOfFile(): number; + flush(): number; + rename(destination: Buffer, replace: boolean): number; + setDisposition(deleteFile: boolean): number; + lock( + offset: bigint, + length: bigint, + exclusive: boolean, + nonblocking: boolean, + ): number; + unlock(offset: bigint, length: bigint): number; +} + +/** Paths are UTF-16LE code units without a terminator, including lone surrogates. */ +export interface WindowsBinding { + openWindowsFile( + path: Buffer, + access: number, + share: number, + disposition: number, + flags: number, + ): { error: number; handle?: WindowsHandle | null }; + createWindowsDirectory(path: Buffer): number; +} + +export const windowsFlags = { + DELETE: 0x00010000, + FILE_READ_ATTRIBUTES: 0x00000080, + GENERIC_READ: 0x80000000, + GENERIC_WRITE: 0x40000000, + FILE_SHARE_READ: 1, + FILE_SHARE_WRITE: 2, + FILE_SHARE_DELETE: 4, + CREATE_NEW: 1, + OPEN_EXISTING: 3, + OPEN_ALWAYS: 4, + FILE_ATTRIBUTE_DIRECTORY: 0x00000010, + FILE_ATTRIBUTE_NORMAL: 0x00000080, + FILE_ATTRIBUTE_REPARSE_POINT: 0x00000400, + FILE_FLAG_BACKUP_SEMANTICS: 0x02000000, + FILE_FLAG_OPEN_REPARSE_POINT: 0x00200000, + FILE_FLAG_OVERLAPPED: 0x40000000, + FILE_NAME_OPENED: 8, + FILE_BEGIN: 0, + FILE_CURRENT: 1, + FILE_END: 2, +} as const; + +export function loadWindowsBinding(): WindowsBinding { + return createRequire(import.meta.url)(binaryPath) as WindowsBinding; +}