diff --git a/.github/workflows/native-windows.yml b/.github/workflows/native-windows.yml index 258ddf40a..c51516d61 100644 --- a/.github/workflows/native-windows.yml +++ b/.github/workflows/native-windows.yml @@ -51,13 +51,15 @@ jobs: run: | cargo fmt --check if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - cargo clippy --locked --target ${{ matrix.target }} -- -D warnings + cargo clippy --locked --target ${{ matrix.target }} --lib --example windows-wide-launcher -- -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 } + node --test windows-files.test.mjs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $nativeNode = (Get-Command node).Source $env:PATH = "" & $nativeNode --expose-gc proof-windows.mjs @@ -74,6 +76,8 @@ jobs: run: | node check.mjs if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node --test windows-files.test.mjs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $nativeNode = (Get-Command node).Source $env:PATH = "" & $nativeNode --expose-gc proof-windows.mjs diff --git a/plugins/codex-security/native/README.md b/plugins/codex-security/native/README.md index bbc3175de..976206620 100644 --- a/plugins/codex-security/native/README.md +++ b/plugins/codex-security/native/README.md @@ -39,6 +39,10 @@ Windows uses `windows-binding.mts` and the same Rust crate. `WindowsHandle` owns 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 exclusive whole-file locking. Rust's `File` supplies ordinary I/O, cursor-preserving truncation, `sync_all` for flush, and locks. Calls return numeric Windows errors, including 6 for closed handles and 33 for nonblocking lock contention. Buffer ranges, path encoding, and 64-bit seek arguments are checked before use. 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. +Four additional operations preserve Windows strings at the Node boundary. `windowsArguments` returns the complete OS argument vector, including the executable and Node options, using Rust's CRT-compatible parser. `windowsEnvironment` reads one wide environment name and distinguishes an absent value (`null`) from an empty buffer. `windowsAbsolutePath` resolves against the native current directory and drive directories without requiring the destination to exist. `windowsDirectoryEntries` uses `std::fs::read_dir` and cached `DirEntry::file_type()` values without opening each child; names remain UTF-16LE, and construction or iteration failures return their numeric Windows error and an empty array. Directory symlinks and junctions have both directory and symbolic-link flags. The typed adapter exposes this one enumerator through `entriesWithTypes`; product commands do not use it yet. + +`windows-files.mts` leaves ordinary absolute-path resolution and canonicalization to `GetFullPathNameW` and `GetFinalPathNameByHandleW`, trimming trailing separators below the root. Its small verbatim-path normalizer preserves drive and UNC share roots when resolving dot segments, including literal trailing dots and spaces. `stat(path, false)` retains exact symbolic-link and reparse-point metadata so callers can reject junction traversal independently of the enumerator's link label. The SDK's public runtime floor remains Node 22.13.0. Node 20.0.0 is an additional native-foundation compatibility proof; it does not change the SDK engine requirement. + Build on Windows after compiling the TypeScript tools, then run: ```sh @@ -53,6 +57,8 @@ The `native-windows` workflow builds x64 and arm64 with MSVC and a static CRT. I node --expose-gc plugins/codex-security/native/proof-windows.mjs python plugins/codex-security/scripts ``` +The build also compiles the test-only `windows-wide-launcher` Rust example. It starts a Node proof child with lone surrogates in arguments, environment values, and its working directory. That child checks complete directory iteration, distinct surrogate and replacement-character files, canonical paths, bounded reads, output truncation, and recursive long paths through the typed adapter. A Rust file guard with sharing disabled remains open while the child enumerates its name; an explicit data read fails with a sharing violation. Attribute-only access is not blocked by Windows file sharing. Root-normalization tables run on the same matrix. The launcher cleans up the wide fixtures and is never included in the uploaded or bundled native payloads. + ## Package inputs The `native-artifacts` workflow calls all three platform workflows and combines their eight verified payloads into `native-universal-`. PR validation jobs share one artifact assembled by `node-ci`; release and standalone validation runs assemble their own. The standalone MCP builder and npm package include the same complete `mcp/native` tree; neither compiles nor downloads code at runtime. diff --git a/plugins/codex-security/native/build.mts b/plugins/codex-security/native/build.mts index 4b4b61663..ca07cd0c0 100644 --- a/plugins/codex-security/native/build.mts +++ b/plugins/codex-security/native/build.mts @@ -40,7 +40,14 @@ const flags = [ ]; const target = resolve(root, process.env["CARGO_TARGET_DIR"] ?? "target"); const args = ["build", "--release", "--locked"]; -if (windowsTarget !== undefined) args.push("--target", windowsTarget); +if (windowsTarget !== undefined) + args.push( + "--target", + windowsTarget, + "--lib", + "--example", + "windows-wide-launcher", + ); execFileSync("cargo", args, { cwd: root, stdio: "inherit", @@ -63,4 +70,15 @@ const library = join( checkPrivatePaths(readFileSync(library), [root, cargoHome, sysroot, target]); mkdirSync(output, { recursive: true }); copyFileSync(library, binaryPath); +if (windowsTarget !== undefined) + copyFileSync( + join( + target, + windowsTarget, + "release", + "examples", + "windows-wide-launcher.exe", + ), + join(output, "windows-wide-launcher.exe"), + ); console.log(`Built ${nativeTarget} Node-API 8 primitives.`); diff --git a/plugins/codex-security/native/examples/windows-wide-launcher.rs b/plugins/codex-security/native/examples/windows-wide-launcher.rs new file mode 100644 index 000000000..d9f3bfc69 --- /dev/null +++ b/plugins/codex-security/native/examples/windows-wide-launcher.rs @@ -0,0 +1,105 @@ +// Test-only launcher: Node's Windows startup has already replaced lone surrogates. +#[cfg(not(windows))] +fn main() {} + +#[cfg(windows)] +fn main() -> std::io::Result<()> { + use std::{ + env, + ffi::OsString, + fs, io, + os::windows::{ffi::OsStringExt, fs::OpenOptionsExt}, + path::{Path, PathBuf}, + process::Command, + }; + + fn raw(prefix: &str, unit: u16) -> OsString { + OsString::from_wide(&prefix.encode_utf16().chain([unit]).collect::>()) + } + + fn run(node: OsString, script: OsString, root: &Path) -> io::Result<()> { + let cwd = root.join(raw("cwd-", 0xd800)); + fs::create_dir(&cwd)?; + let replacement = root.join("cwd-\u{fffd}"); + fs::create_dir(&replacement)?; + fs::write(replacement.join("sentinel"), "replacement cwd untouched")?; + let names = [ + raw("high-", 0xd800), + raw("high-", 0xfffd), + raw("low-", 0xdc80), + raw("low-", 0xfffd), + raw("tail-", 0xdfff), + raw("tail-", 0xfffd), + OsString::from("unicode-🔐-東京"), + ]; + for (index, name) in names.iter().enumerate() { + fs::write(cwd.join(name), format!("sentinel-{index}"))?; + } + fs::create_dir(cwd.join("empty"))?; + fs::create_dir(cwd.join(raw("directory-", 0xdc80)))?; + std::os::windows::fs::symlink_file(&names[0], cwd.join("file-link"))?; + std::os::windows::fs::symlink_dir("empty", cwd.join("directory-link"))?; + std::os::windows::fs::symlink_dir( + raw("missing-", 0xdfff), + cwd.join("dangling-directory-link"), + )?; + let locked = cwd.join(raw("locked-", 0xdfff)); + fs::write(&locked, "directory enumeration does not open this file")?; + fs::write(root.join(raw("parent-", 0xd800)), "parent sentinel")?; + let verbatim = fs::canonicalize(&cwd)?; + for (name, contents) in [ + ("trailing", "ordinary dot sibling"), + ("trailing.", "literal dot file"), + ("space", "ordinary space sibling"), + ("space ", "literal space file"), + ] { + fs::write(verbatim.join(name), contents)?; + } + let arguments = [ + raw("arg-high-", 0xd800), + raw("arg-low-", 0xdc80), + raw("arg-tail-", 0xdfff), + OsString::from("replacement-\u{fffd}"), + OsString::from("Unicode 🔐 東京"), + OsString::from(""), + OsString::from("space and\ttab"), + OsString::from("quoted \"value\" and trailing\\"), + OsString::from("backslash\\\"quote"), + ]; + let guard = fs::OpenOptions::new() + .read(true) + .share_mode(0) + .open(&locked)?; + let status = Command::new(node) + .arg(script) + .arg("wide-worker") + .arg(root) + .args(arguments) + .current_dir(&cwd) + .env("CODEX_SECURITY_WIDE_VALUE", raw("value-", 0xd800)) + .env("CODEX_SECURITY_WIDE_EMPTY", "") + .env_remove("CODEX_SECURITY_WIDE_ABSENT") + .env(raw("CODEX_SECURITY_WIDE_NAME_", 0xdfff), "wide name value") + .env("CODEX_SECURITY_WIDE_LONG", "x".repeat(1024)) + .env("USERPROFILE", &cwd) + .status()?; + if !status.success() { + return Err(io::Error::other("Wide Windows child proof failed")); + } + drop(guard); + if fs::read(replacement.join("sentinel"))? != b"replacement cwd untouched" { + return Err(io::Error::other("Replacement cwd was changed")); + } + Ok(()) + } + + let mut args = env::args_os().skip(1); + let node = args.next().expect("Node executable path"); + let script = args.next().expect("Windows wide proof script"); + let root = PathBuf::from(args.next().expect("Proof fixture directory")).join("wide-process"); + fs::create_dir(&root)?; + let result = run(node, script, &root); + let cleanup = fs::remove_dir_all(&root); + result?; + cleanup +} diff --git a/plugins/codex-security/native/proof-windows-wide.mts b/plugins/codex-security/native/proof-windows-wide.mts new file mode 100644 index 000000000..fd14f8315 --- /dev/null +++ b/plugins/codex-security/native/proof-windows-wide.mts @@ -0,0 +1,288 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { join, win32 } from "node:path"; +import { fileURLToPath } from "node:url"; +import { output } from "./binding.mjs"; +import { loadWindowsBinding } from "./windows-binding.mjs"; +import { pathText, widePath, windowsFileSystem } from "./windows-files.mjs"; + +const self = fileURLToPath(import.meta.url); + +export function wideProcessProof(root: string): Record { + const child = spawnSync( + join(output, "windows-wide-launcher.exe"), + [process.execPath, self, root], + { encoding: "utf8", maxBuffer: Infinity, timeout: 30_000 }, + ); + assert.equal(child.error, undefined); + assert.equal(child.status, 0, child.stderr); + assert.equal(child.stderr, ""); + return JSON.parse(child.stdout) as Record; +} + +function worker(root: string): Record { + const native = loadWindowsBinding(); + const files = windowsFileSystem(native); + const cwd = win32.join(root, "cwd-\ud800"); + const expectedArguments = [ + "arg-high-\ud800", + "arg-low-\udc80", + "arg-tail-\udfff", + "replacement-\ufffd", + "Unicode 🔐 東京", + "", + "space and\ttab", + 'quoted "value" and trailing\\', + 'backslash\\"quote', + ]; + const arguments_ = native.windowsArguments().map(pathText); + assert.deepEqual(arguments_.slice(4), expectedArguments); + assert.equal(arguments_[2], "wide-worker"); + assert.equal(arguments_[3], root); + + function environment(name: string): Buffer | null { + return native.windowsEnvironment(widePath(name)); + } + assert.deepEqual( + environment("CODEX_SECURITY_WIDE_VALUE"), + widePath("value-\ud800"), + ); + assert.deepEqual(environment("CODEX_SECURITY_WIDE_EMPTY"), Buffer.alloc(0)); + assert.equal(environment("CODEX_SECURITY_WIDE_ABSENT"), null); + assert.deepEqual( + environment("CODEX_SECURITY_WIDE_NAME_\udfff"), + widePath("wide name value"), + ); + assert.deepEqual( + environment("CODEX_SECURITY_WIDE_LONG"), + widePath("x".repeat(1024)), + ); + assert.deepEqual(environment("USERPROFILE"), widePath(cwd)); + + function samePath(actual: Buffer, expected: string): void { + assert.equal( + win32.toNamespacedPath(pathText(actual)).toLowerCase(), + win32.toNamespacedPath(expected).toLowerCase(), + ); + } + samePath(files.absolute(widePath(".")), cwd); + samePath( + files.absolute(widePath("missing/../high-\ud800")), + win32.join(cwd, "high-\ud800"), + ); + samePath(files.absolute(widePath(cwd)), cwd); + const emptyAbsolute = native.windowsAbsolutePath(Buffer.alloc(0)); + assert(Number.isInteger(emptyAbsolute.error)); + assert.deepEqual(emptyAbsolute.value, Buffer.alloc(0)); + const drive = win32.parse(cwd).root.slice(0, 2); + assert.match(drive, /^[a-z]:$/iu); + samePath( + files.absolute(widePath(`${drive}high-\ud800`)), + win32.join(cwd, "high-\ud800"), + ); + samePath( + files.absolute(widePath("\\rooted-\ud800")), + `${drive}\\rooted-\ud800`, + ); + samePath(files.realpath(widePath(".")), cwd); + + const names = [ + "high-\ud800", + "high-\ufffd", + "low-\udc80", + "low-\ufffd", + "tail-\udfff", + "tail-\ufffd", + "unicode-🔐-東京", + ]; + const listed = files.entriesWithTypes(widePath(".")); + assert.deepEqual( + listed.map((entry) => pathText(entry.name)).sort(), + [ + ...names, + "empty", + "trailing", + "trailing.", + "space", + "space ", + "directory-\udc80", + "file-link", + "directory-link", + "dangling-directory-link", + "locked-\udfff", + ].sort(), + ); + for (const spelling of [".", `${drive}.`, cwd, win32.toNamespacedPath(cwd)]) { + const result = native.windowsDirectoryEntries(widePath(spelling)); + assert.equal(result.error, 0); + assert.deepEqual( + result.value.map((entry) => pathText(entry.name)).sort(), + listed.map((entry) => pathText(entry.name)).sort(), + ); + } + const directories = listed + .filter((entry) => entry.isDirectory()) + .map((entry) => pathText(entry.name)) + .sort(); + assert.deepEqual(directories, [ + "dangling-directory-link", + "directory-link", + "directory-\udc80", + "empty", + ]); + assert.deepEqual( + listed + .filter((entry) => entry.isSymbolicLink()) + .map((entry) => pathText(entry.name)) + .sort(), + ["dangling-directory-link", "directory-link", "file-link"], + ); + assert.throws( + () => files.readInto(widePath("locked-\udfff"), Buffer.alloc(1)), + { winerror: 32 }, + ); + assert.equal( + listed + .find((entry) => entry.name.equals(widePath("locked-\udfff"))) + ?.isDirectory(), + false, + ); + assert.deepEqual(native.windowsDirectoryEntries(widePath("empty")), { + error: 0, + value: [], + }); + assert.deepEqual(native.windowsDirectoryEntries(widePath("missing")), { + error: 3, + value: [], + }); + assert.deepEqual(native.windowsDirectoryEntries(Buffer.alloc(0)), { + error: 3, + value: [], + }); + const notDirectory = native.windowsDirectoryEntries(widePath(names[0]!)); + assert.notEqual(notDirectory.error, 0); + assert(Number.isInteger(notDirectory.error)); + assert.deepEqual(notDirectory.value, []); + for (const [index, name] of names.entries()) { + const buffer = Buffer.alloc(64); + const length = files.readInto(widePath(name), buffer); + assert.equal(buffer.subarray(0, length).toString(), `sentinel-${index}`); + assert(files.stat(widePath(name)).isFile()); + assert(!files.stat(widePath(name), false).isSymbolicLink()); + samePath(files.realpath(widePath(name)), win32.join(cwd, name)); + for (const input of [ + `${name}/`, + `${name}\\`, + `${drive}${name}/`, + `${win32.join(cwd, name)}\\`, + ]) { + samePath(files.realpath(widePath(input)), win32.join(cwd, name)); + } + } + samePath(files.realpath(widePath(`${drive}///`)), `${drive}\\`); + samePath( + files.realpath(widePath(`${drive}.\\..\\parent-\ud800`)), + win32.join(root, "parent-\ud800"), + ); + assert(files.stat(widePath(".")).isDirectory()); + const bounded = Buffer.alloc(4); + assert.equal(files.readInto(widePath(names[0]!), bounded), 4); + assert.equal(bounded.toString(), "sent"); + + const rawOutput = widePath("output-\ud800"); + const replacementOutput = widePath("output-\ufffd"); + files.writeFile( + replacementOutput, + Buffer.from("replacement output untouched"), + ); + files.writeFile(rawOutput, Buffer.from("a longer initial output")); + files.writeFile(rawOutput, Buffer.from("short")); + const contents = Buffer.alloc(64); + assert.equal(files.readInto(rawOutput, contents), 5); + assert.equal(contents.subarray(0, 5).toString(), "short"); + files.writeFile(rawOutput, Buffer.alloc(0)); + assert.equal(files.readInto(rawOutput, contents), 0); + const replacementLength = files.readInto(replacementOutput, contents); + assert.equal( + contents.subarray(0, replacementLength).toString(), + "replacement output untouched", + ); + + for (const [name, literal, ordinary] of [ + ["trailing.", "literal dot file", "ordinary dot sibling"], + ["space ", "literal space file", "ordinary space sibling"], + ] as const) { + const exact = widePath(win32.toNamespacedPath(win32.join(cwd, name))); + assert.deepEqual(files.absolute(exact), exact); + const length = files.readInto(exact, contents); + assert.equal(contents.subarray(0, length).toString(), literal); + samePath(files.realpath(exact), pathText(exact)); + files.writeFile(exact, Buffer.from("updated literal file")); + const ordinaryLength = files.readInto( + widePath(name.slice(0, -1)), + contents, + ); + assert.equal(contents.subarray(0, ordinaryLength).toString(), ordinary); + const directory = widePath( + win32.toNamespacedPath(win32.join(cwd, `directory-${name}`)), + ); + files.mkdir(directory); + files.writeFile( + widePath(`${pathText(directory)}\\child`), + Buffer.from("literal directory"), + ); + assert.deepEqual( + files.entriesWithTypes(directory).map((entry) => pathText(entry.name)), + ["child"], + ); + const ordinaryDirectory = widePath( + win32.join(cwd, `directory-${name.slice(0, -1)}`), + ); + files.mkdir(ordinaryDirectory); + assert.deepEqual(files.entriesWithTypes(ordinaryDirectory), []); + } + + const longDirectory = win32.join( + cwd, + ...Array.from({ length: 6 }, (_, i) => `${i}-${"x".repeat(48)}`), + "directory-\udfff", + ); + assert(files.absolute(widePath(longDirectory)).length > 512); + files.mkdir(widePath(longDirectory)); + files.mkdir(widePath(longDirectory)); + assert(files.stat(widePath(longDirectory)).isDirectory()); + const longFile = widePath(win32.join(longDirectory, "file-\udc80")); + files.writeFile(longFile, Buffer.from("long raw path")); + const longLength = files.readInto(longFile, contents); + assert.equal(contents.subarray(0, longLength).toString(), "long raw path"); + samePath(files.realpath(longFile), pathText(longFile)); + const longEntries = native.windowsDirectoryEntries(widePath(longDirectory)); + assert.equal(longEntries.error, 0); + assert.deepEqual( + longEntries.value.map((entry) => pathText(entry.name)), + ["file-\udc80"], + ); + + for (const malformed of [Buffer.from([0x61]), widePath("bad\0value")]) { + assert.throws(() => native.windowsEnvironment(malformed)); + assert.throws(() => native.windowsAbsolutePath(malformed)); + assert.throws(() => native.windowsDirectoryEntries(malformed)); + } + return { + rawArgumentsAndCrtQuoting: true, + rawEnvironmentEmptyAndUnset: true, + rawCwdAndDriveRelativePaths: true, + completeWideDirectoryIteration: true, + cachedDirectoryAttributesWithoutFileAccess: true, + cachedSymlinkTagsIncludingDanglingDirectories: true, + existingFilesWithTrailingSeparators: true, + distinctRawAndReplacementFiles: true, + canonicalPathsBoundedReadsAndTruncation: true, + verbatimTrailingDotsAndSpaces: true, + recursiveLongWideDirectories: true, + numericErrorsAndFfiRepresentations: true, + }; +} + +if (process.argv[2] === "wide-worker") + console.log(JSON.stringify(worker(process.argv[3]!))); diff --git a/plugins/codex-security/native/proof-windows.mts b/plugins/codex-security/native/proof-windows.mts index 6c1997826..dd6278029 100644 --- a/plugins/codex-security/native/proof-windows.mts +++ b/plugins/codex-security/native/proof-windows.mts @@ -17,6 +17,8 @@ import { basename, join, win32 } from "node:path"; import { createInterface } from "node:readline"; import { fileURLToPath } from "node:url"; import { setImmediate } from "node:timers/promises"; +import { wideProcessProof } from "./proof-windows-wide.mjs"; +import { windowsFileSystem } from "./windows-files.mjs"; import { loadWindowsBinding, windowsFlags as flags, @@ -282,6 +284,21 @@ function handleProof(root: string) { assert(attributes.attributes & flags.FILE_ATTRIBUTE_REPARSE_POINT); assert(attributes.attributes & flags.FILE_ATTRIBUTE_DIRECTORY); assert.equal(attributes.reparseTag, 0xa0000003); + const files = windowsFileSystem(native); + const junctionEntry = files + .entriesWithTypes(pathBytes(root)) + .find((entry) => + entry.name.equals(Buffer.from(basename(ancestor), "utf16le")), + ); + assert(junctionEntry?.isDirectory()); + assert.equal(junctionEntry?.isSymbolicLink(), true); + const junctionStat = files.stat(pathBytes(ancestor), false); + assert(junctionStat.isDirectory()); + assert(junctionStat.isReparsePoint()); + assert(!junctionStat.isSymbolicLink()); + const targetStat = files.stat(pathBytes(ancestor)); + assert(targetStat.isDirectory()); + assert(!targetStat.isReparsePoint()); samePath( checked(junction.finalPath(flags.FILE_NAME_OPENED)).path, ancestor, @@ -662,6 +679,7 @@ if (process.argv[2] === "worker") { architecture: process.arch, nodeApi: 8, handles: handleProof(root), + wideProcessAndPaths: wideProcessProof(root), garbageCollectionClosesHandle: await ownershipProof(root), locks: await lockProof(root), pythonCompatibility: diff --git a/plugins/codex-security/native/src/windows.rs b/plugins/codex-security/native/src/windows.rs index aee44ba64..f89f32534 100644 --- a/plugins/codex-security/native/src/windows.rs +++ b/plugins/codex-security/native/src/windows.rs @@ -1,10 +1,15 @@ use napi::bindgen_prelude::{BigInt, Buffer}; use napi_derive::napi; use std::{ - fs::{File, TryLockError}, + ffi::OsString, + fs::{self, File, TryLockError}, io::{self, Read, Seek, SeekFrom, Write}, mem::{offset_of, size_of, MaybeUninit}, - os::windows::io::{AsRawHandle, FromRawHandle}, + os::windows::{ + ffi::{OsStrExt, OsStringExt}, + fs::FileTypeExt, + io::{AsRawHandle, FromRawHandle}, + }, ptr::{copy_nonoverlapping, null, null_mut}, }; use windows_sys::Win32::{ @@ -63,6 +68,106 @@ fn wide_path(bytes: Buffer) -> napi::Result> { Ok(path) } +fn wide_bytes(units: impl IntoIterator) -> Buffer { + units + .into_iter() + .flat_map(u16::to_le_bytes) + .collect::>() + .into() +} + +fn os_string(bytes: Buffer) -> napi::Result { + let path = wide_path(bytes)?; + Ok(OsString::from_wide(&path[..path.len() - 1])) +} + +#[napi(object)] +pub struct BufferResult { + pub error: u32, + pub value: Buffer, +} + +#[napi(object)] +pub struct DirectoryEntry { + pub name: Buffer, + pub is_directory: bool, + pub is_symbolic_link: bool, +} + +#[napi(object)] +pub struct DirectoryEntriesResult { + pub error: u32, + pub value: Vec, +} + +#[napi] +pub fn windows_arguments() -> Vec { + std::env::args_os() + .map(|argument| wide_bytes(argument.encode_wide())) + .collect() +} + +#[napi] +pub fn windows_environment(name: Buffer) -> napi::Result> { + Ok(std::env::var_os(os_string(name)?).map(|value| wide_bytes(value.encode_wide()))) +} + +#[napi] +pub fn windows_absolute_path(path: Buffer) -> napi::Result { + let path = os_string(path)?; + if path.is_empty() { + // Rust rejects empty paths before Win32; retain the native error contract. + let mut value = [0_u16; 256]; + let error = unsafe { + GetFullPathNameW([0_u16].as_ptr(), 256, value.as_mut_ptr(), null_mut()); + GetLastError() + }; + return Ok(BufferResult { + error, + value: Vec::new().into(), + }); + } + match std::path::absolute(path) { + Ok(value) => Ok(BufferResult { + error: 0, + value: wide_bytes(value.as_os_str().encode_wide()), + }), + Err(error) => Ok(BufferResult { + error: error + .raw_os_error() + .ok_or_else(|| invalid(&error.to_string()))? as u32, + value: Vec::new().into(), + }), + } +} + +#[napi] +pub fn windows_directory_entries(path: Buffer) -> napi::Result { + let path = os_string(path)?; + let entries = || -> io::Result> { + fs::read_dir(path)? + .map(|entry| { + let entry = entry?; + let kind = entry.file_type()?; + Ok(DirectoryEntry { + name: wide_bytes(entry.file_name().encode_wide()), + is_directory: kind.is_dir() || kind.is_symlink_dir(), + is_symbolic_link: kind.is_symlink(), + }) + }) + .collect() + }; + match entries() { + Ok(value) => Ok(DirectoryEntriesResult { error: 0, value }), + Err(error) => Ok(DirectoryEntriesResult { + error: error + .raw_os_error() + .ok_or_else(|| invalid(&error.to_string()))? as u32, + value: Vec::new(), + }), + } +} + fn io_range(buffer: &Buffer, offset: f64, length: f64) -> napi::Result<(usize, u32)> { if !offset.is_finite() || !length.is_finite() diff --git a/plugins/codex-security/native/windows-binding.mts b/plugins/codex-security/native/windows-binding.mts index b80facf21..1a393ad5a 100644 --- a/plugins/codex-security/native/windows-binding.mts +++ b/plugins/codex-security/native/windows-binding.mts @@ -28,6 +28,14 @@ export interface WindowsHandle { /** Paths are UTF-16LE code units without a terminator, including lone surrogates. */ export interface WindowsBinding { + windowsArguments(): Buffer[]; + windowsEnvironment(name: Buffer): Buffer | null; + windowsAbsolutePath(path: Buffer): WindowsResult; + windowsDirectoryEntries( + path: Buffer, + ): WindowsResult< + { name: Buffer; isDirectory: boolean; isSymbolicLink: boolean }[] + >; openWindowsFile( path: Buffer, access: number, @@ -47,6 +55,7 @@ export const windowsFlags = { FILE_SHARE_WRITE: 2, FILE_SHARE_DELETE: 4, CREATE_NEW: 1, + CREATE_ALWAYS: 2, OPEN_EXISTING: 3, OPEN_ALWAYS: 4, FILE_ATTRIBUTE_DIRECTORY: 0x00000010, diff --git a/plugins/codex-security/native/windows-files.mts b/plugins/codex-security/native/windows-files.mts new file mode 100644 index 000000000..1a1d7db27 --- /dev/null +++ b/plugins/codex-security/native/windows-files.mts @@ -0,0 +1,214 @@ +import { win32 } from "node:path"; +import { + windowsFlags as flags, + type WindowsBinding, + type WindowsHandle, +} from "./windows-binding.mjs"; + +export const widePath = (path: string): Buffer => Buffer.from(path, "utf16le"); +export const pathText = (path: Buffer): string => path.toString("utf16le"); + +export function windowsFileSystem(native: WindowsBinding) { + function check(error: number, path: Buffer): void { + if (error === 0) return; + const code = new Map([ + [2, "ENOENT"], + [3, "ENOENT"], + [267, "ENOTDIR"], + [1921, "ELOOP"], + ]).get(error); + throw Object.assign( + new Error(`Windows filesystem error ${error}: ${pathText(path)}`), + { code, winerror: error }, + ); + } + + function absolute(path: Buffer): Buffer { + const result = native.windowsAbsolutePath(path); + check(result.error, path); + return result.value; + } + + function operationPath(path: Buffer): Buffer { + const resolved = absolute(path); + const text = pathText(resolved); + if (text.startsWith("\\\\?\\") || text.startsWith("\\\\.\\")) + return resolved; + return widePath( + text.startsWith("\\\\") + ? `\\\\?\\UNC\\${text.slice(2)}` + : `\\\\?\\${text}`, + ); + } + + function open( + path: Buffer, + access = 0, + disposition: number = flags.OPEN_EXISTING, + follow = true, + ): WindowsHandle { + const result = native.openWindowsFile( + operationPath(path), + access, + flags.FILE_SHARE_READ | flags.FILE_SHARE_WRITE | flags.FILE_SHARE_DELETE, + disposition, + flags.FILE_FLAG_BACKUP_SEMANTICS | + (follow ? 0 : flags.FILE_FLAG_OPEN_REPARSE_POINT), + ); + check(result.error, path); + return result.handle!; + } + + function finalPath(path: Buffer): Buffer { + const handle = open(path); + try { + const result = handle.finalPath(0); + check(result.error, path); + return result.path; + } finally { + check(handle.close(), path); + } + } + + function realpath(path: Buffer): Buffer { + let normalizedText: string; + if (pathText(path).startsWith("\\\\?\\")) { + // Verbatim paths bypass Win32 dot parsing; normalize only below their root. + const text = pathText(path).replaceAll("/", "\\"); + const root = + /^\\\\\?\\(?:UNC\\[^\\]+\\[^\\]+(?:\\|$)|[^\\]+\\)/iu.exec(text)?.[0] ?? + win32.parse(text).root; + normalizedText = + root + + win32.join("\\", text.slice(root.length)).slice(1).replace(/\\+$/u, ""); + } else { + const text = pathText(absolute(path)); + const root = win32.parse(text).root; + normalizedText = root + text.slice(root.length).replace(/\\+$/u, ""); + } + const normalized = widePath(normalizedText); + const resolved = finalPath(normalized); + if (pathText(normalized).startsWith("\\\\?\\")) return resolved; + const text = pathText(resolved); + const shortened = text.startsWith("\\\\?\\UNC\\") + ? `\\\\${text.slice(8)}` + : text.startsWith("\\\\?\\") + ? text.slice(4) + : text; + // Like pathlib, remove the device prefix only if that spelling resolves too. + const candidate = widePath(shortened); + try { + if (finalPath(candidate).equals(resolved)) return candidate; + } catch { + // Extended paths can be valid when their ordinary spelling is not. + } + return resolved; + } + + function stat(path: Buffer, follow = true) { + const handle = open( + path, + flags.FILE_READ_ATTRIBUTES, + flags.OPEN_EXISTING, + follow, + ); + try { + const info = handle.attributes(); + check(info.error, path); + const type = handle.fileType(); + check(type.error, path); + const link = !follow && info.reparseTag === 0xa000000c; + const directory = + (info.attributes & flags.FILE_ATTRIBUTE_DIRECTORY) !== 0; + return { + isDirectory: () => !link && directory, + isFile: () => !link && !directory && type.value === 1, + isSymbolicLink: () => link, + isReparsePoint: () => + (info.attributes & flags.FILE_ATTRIBUTE_REPARSE_POINT) !== 0, + }; + } finally { + check(handle.close(), path); + } + } + + function entriesWithTypes(path: Buffer) { + const result = native.windowsDirectoryEntries(operationPath(path)); + check(result.error, path); + return result.value.map(({ name, isDirectory, isSymbolicLink }) => ({ + name, + isDirectory: () => isDirectory, + isSymbolicLink: () => isSymbolicLink, + })); + } + + function mkdir(path: Buffer): void { + const resolved = absolute(path); + const parent = widePath(win32.dirname(pathText(resolved))); + let error = native.createWindowsDirectory(operationPath(resolved)); + if (error === 3 && !parent.equals(resolved)) { + mkdir(parent); + error = native.createWindowsDirectory(operationPath(resolved)); + } + if (error !== 0) { + try { + if (stat(resolved).isDirectory()) return; + } catch { + // Report the original creation error. + } + check(error, path); + } + } + + function readInto(path: Buffer, buffer: Buffer): number { + const handle = open(path, flags.GENERIC_READ); + let length = 0; + try { + while (length < buffer.length) { + const result = handle.read( + buffer, + length, + Math.min(buffer.length - length, 0xffffffff), + ); + check(result.error, path); + if (result.value === 0) break; + length += result.value; + } + } finally { + check(handle.close(), path); + } + return length; + } + + function writeFile(path: Buffer, buffer: Buffer): void { + const handle = open(path, flags.GENERIC_WRITE, flags.CREATE_ALWAYS); + let offset = 0; + try { + while (offset < buffer.length) { + const result = handle.write( + buffer, + offset, + Math.min(buffer.length - offset, 0xffffffff), + ); + check(result.error, path); + if (result.value === 0) + throw new Error( + `Windows file write made no progress: ${pathText(path)}`, + ); + offset += result.value; + } + } finally { + check(handle.close(), path); + } + } + + return { + absolute, + realpath, + stat, + entriesWithTypes, + mkdir, + readInto, + writeFile, + }; +} diff --git a/plugins/codex-security/native/windows-files.test.mts b/plugins/codex-security/native/windows-files.test.mts new file mode 100644 index 000000000..29291731a --- /dev/null +++ b/plugins/codex-security/native/windows-files.test.mts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { win32 } from "node:path"; +import { type WindowsBinding } from "./windows-binding.mjs"; +import { pathText, widePath, windowsFileSystem } from "./windows-files.mjs"; + +const opened = new Error("Captured native open"); + +for (const [input, expected] of [ + ["\\\\?\\C:\\\\..\\file", "\\\\?\\C:\\file"], + ["\\\\?\\UNC\\server\\share\\\\..\\file", "\\\\?\\UNC\\server\\share\\file"], + [ + "\\\\?\\UNC\\server\\share\\..\\other\\file", + "\\\\?\\UNC\\server\\share\\other\\file", + ], + ["\\\\?\\UNC\\server\\share\\child\\..\\..\\", "\\\\?\\UNC\\server\\share\\"], + ["\\\\?\\C:\\..\\file-\ud800", "\\\\?\\C:\\file-\ud800"], + ["\\\\?\\C:\\child\\.\\..\\", "\\\\?\\C:\\"], + ["\\\\?\\C:\\trailing.\\", "\\\\?\\C:\\trailing."], + ["\\\\?\\UNC\\server\\share\\space \\", "\\\\?\\UNC\\server\\share\\space "], +] as const) { + test(`verbatim realpath preserves its root: ${JSON.stringify(input)}`, () => { + const native = { + windowsAbsolutePath(path: Buffer) { + return { error: 0, value: path }; + }, + openWindowsFile(path: Buffer) { + assert.equal(pathText(path), expected); + throw opened; + }, + } as unknown as WindowsBinding; + assert.throws( + () => windowsFileSystem(native).realpath(widePath(input)), + (error) => error === opened, + ); + }); +} + +for (const [input, absolute] of [ + ["C:.\\..\\sentinel", "C:\\parent\\sentinel"], + ["\\\\server\\share\\..\\file\\", "\\\\server\\share\\file\\"], + ["C:/", "C:\\"], + ["C:\\file\\", "C:\\file\\"], +] as const) { + test(`ordinary realpath uses native absolute resolution: ${JSON.stringify(input)}`, () => { + let absoluteCalls = 0; + const native = { + windowsAbsolutePath(path: Buffer) { + if (absoluteCalls++ === 0) { + assert.equal(pathText(path), input); + return { error: 0, value: widePath(absolute) }; + } + return { error: 0, value: path }; + }, + openWindowsFile(path: Buffer) { + const root = win32.parse(absolute).root; + const trimmed = root + absolute.slice(root.length).replace(/\\+$/u, ""); + assert.equal(pathText(path), win32.toNamespacedPath(trimmed)); + throw opened; + }, + } as unknown as WindowsBinding; + assert.throws( + () => windowsFileSystem(native).realpath(widePath(input)), + (error) => error === opened, + ); + }); +}