diff --git a/.github/workflows/native-windows.yml b/.github/workflows/native-windows.yml index 061e5e050..4fb1a19e4 100644 --- a/.github/workflows/native-windows.yml +++ b/.github/workflows/native-windows.yml @@ -51,7 +51,7 @@ 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 diff --git a/plugins/codex-security/native/README.md b/plugins/codex-security/native/README.md index d55fc63fd..bf0a2a1bc 100644 --- a/plugins/codex-security/native/README.md +++ b/plugins/codex-security/native/README.md @@ -39,6 +39,8 @@ 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 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. +Five additional operations avoid Node's lossy Windows string conversions. `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. `windowsDirectoryNames` returns every name as UTF-16LE; iteration failures return their Windows error and an empty array. `windowsDirectoryEntries` also returns the cached directory attribute and exact symbolic-link status, distinguishing directory symlinks from junctions without opening each entry. `windows-files.mts` exposes these through `entriesWithTypes` with `isDirectory()` and `isSymbolicLink()` alongside the existing names-only `entries`; product commands do not use this adapter yet. + Build on Windows after compiling the TypeScript tools, then run: ```sh @@ -49,6 +51,8 @@ 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. +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. 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-`. Package, release, container, and test workflows prepare this artifact before building the plugin. 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..f804baab6 --- /dev/null +++ b/plugins/codex-security/native/examples/windows-wide-launcher.rs @@ -0,0 +1,178 @@ +// 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, + mem::size_of, + os::windows::ffi::{OsStrExt, OsStringExt}, + path::{Path, PathBuf}, + process::Command, + ptr::null_mut, + }; + use windows_sys::Win32::Security::*; + + fn raw(prefix: &str, unit: u16) -> OsString { + OsString::from_wide(&prefix.encode_utf16().chain([unit]).collect::>()) + } + + fn deny_file_access(path: &Path, operation: impl FnOnce() -> io::Result<()>) -> io::Result<()> { + let path = path + .as_os_str() + .encode_wide() + .chain([0]) + .collect::>(); + let mut length = 0; + unsafe { + GetFileSecurityW( + path.as_ptr(), + DACL_SECURITY_INFORMATION, + null_mut(), + 0, + &mut length, + ) + }; + if length == 0 { + return Err(io::Error::last_os_error()); + } + let mut saved = vec![0_usize; (length as usize).div_ceil(size_of::())]; + let mut control = 0; + let mut revision = 0; + if unsafe { + GetFileSecurityW( + path.as_ptr(), + DACL_SECURITY_INFORMATION, + saved.as_mut_ptr().cast(), + length, + &mut length, + ) == 0 + || GetSecurityDescriptorControl( + saved.as_mut_ptr().cast(), + &mut control, + &mut revision, + ) == 0 + } { + return Err(io::Error::last_os_error()); + } + let mut acl = ACL::default(); + let mut descriptor = SECURITY_DESCRIPTOR::default(); + let descriptor = (&mut descriptor as *mut SECURITY_DESCRIPTOR).cast(); + if unsafe { + InitializeAcl(&mut acl, size_of::() as u32, ACL_REVISION) == 0 + || InitializeSecurityDescriptor(descriptor, 1) == 0 + || SetSecurityDescriptorDacl(descriptor, 1, &acl, 0) == 0 + || SetFileSecurityW( + path.as_ptr(), + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + descriptor, + ) == 0 + } { + return Err(io::Error::last_os_error()); + } + let result = operation(); + let protection = if control & SE_DACL_PROTECTED != 0 { + PROTECTED_DACL_SECURITY_INFORMATION + } else { + UNPROTECTED_DACL_SECURITY_INFORMATION + }; + // Restore the owned fixture's original DACL even if the child proof fails. + if unsafe { + SetFileSecurityW( + path.as_ptr(), + DACL_SECURITY_INFORMATION | protection, + saved.as_mut_ptr().cast(), + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + result + } + + 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 denied = cwd.join(raw("denied-", 0xdfff)); + fs::write(&denied, "directory enumeration does not open this file")?; + 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"), + ]; + deny_file_access(&denied, || { + 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")); + } + Ok(()) + })?; + 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..a1f2cd5ab --- /dev/null +++ b/plugins/codex-security/native/proof-windows-wide.mts @@ -0,0 +1,275 @@ +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 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-🔐-東京", + ]; + assert.deepEqual( + files.entries(widePath(".")).map(pathText).sort(), + [ + ...names, + "empty", + "trailing", + "trailing.", + "space", + "space ", + "directory-\udc80", + "file-link", + "directory-link", + "dangling-directory-link", + "denied-\udfff", + ].sort(), + ); + const listed = files.entriesWithTypes(widePath(".")); + assert.deepEqual( + listed.map((entry) => pathText(entry.name)).sort(), + files.entries(widePath(".")).map(pathText).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.stat(widePath("denied-\udfff")), { winerror: 5 }); + assert.equal( + listed + .find((entry) => entry.name.equals(widePath("denied-\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: [], + }); + assert.deepEqual(native.windowsDirectoryNames(widePath("empty")), { + error: 0, + value: [], + }); + assert.deepEqual(native.windowsDirectoryNames(widePath("missing")), { + error: 3, + value: [], + }); + const notDirectory = native.windowsDirectoryNames(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}/`, + `${drive}.\\..\\${name}\\`, + `${win32.join(cwd, name)}\\`, + ]) { + samePath(files.realpath(widePath(input)), win32.join(cwd, name)); + } + } + samePath(files.realpath(widePath(`${drive}///`)), `${drive}\\`); + 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.entries(directory).map(pathText), ["child"]); + const ordinaryDirectory = widePath( + win32.join(cwd, `directory-${name.slice(0, -1)}`), + ); + files.mkdir(ordinaryDirectory); + assert.deepEqual(files.entries(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)); + + for (const malformed of [Buffer.from([0x61]), widePath("bad\0value")]) { + assert.throws(() => native.windowsEnvironment(malformed)); + assert.throws(() => native.windowsAbsolutePath(malformed)); + assert.throws(() => native.windowsDirectoryNames(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 b310f0c58..a9667bd4a 100644 --- a/plugins/codex-security/native/proof-windows.mts +++ b/plugins/codex-security/native/proof-windows.mts @@ -16,6 +16,8 @@ import { tmpdir } from "node:os"; import { basename, join, win32 } from "node:path"; 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, @@ -250,6 +252,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(), false); + 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, @@ -565,6 +582,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), fixture: basename(root), diff --git a/plugins/codex-security/native/src/windows.rs b/plugins/codex-security/native/src/windows.rs index e77872408..f38eecc6c 100644 --- a/plugins/codex-security/native/src/windows.rs +++ b/plugins/codex-security/native/src/windows.rs @@ -1,12 +1,20 @@ use napi::bindgen_prelude::{BigInt, Buffer}; use napi_derive::napi; use std::{ + ffi::OsString, mem::{offset_of, size_of, MaybeUninit}, - os::windows::io::{AsRawHandle, FromRawHandle, IntoRawHandle, OwnedHandle}, + os::windows::{ + ffi::{OsStrExt, OsStringExt}, + io::{AsRawHandle, FromRawHandle, IntoRawHandle, OwnedHandle}, + }, + path::Path, ptr::{copy_nonoverlapping, null, null_mut}, }; use windows_sys::Win32::{ - Foundation::{CloseHandle, GetLastError, SetLastError, HANDLE, INVALID_HANDLE_VALUE}, + Foundation::{ + CloseHandle, GetLastError, SetLastError, ERROR_FILE_NOT_FOUND, ERROR_NO_MORE_FILES, + ERROR_PATH_NOT_FOUND, HANDLE, INVALID_HANDLE_VALUE, + }, Storage::FileSystem::*, System::IO::OVERLAPPED, }; @@ -38,6 +46,197 @@ 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 DirectoryResult { + pub error: u32, + pub value: Vec, +} + +#[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 = wide_path(path)?; + let mut absolute = vec![0_u16; 256]; + loop { + let capacity = u32::try_from(absolute.len()) + .map_err(|_| invalid("Absolute path exceeds the Win32 buffer size"))?; + let length = + unsafe { GetFullPathNameW(path.as_ptr(), capacity, absolute.as_mut_ptr(), null_mut()) }; + if length == 0 { + return Ok(BufferResult { + error: unsafe { GetLastError() }, + value: Vec::new().into(), + }); + } + if length < capacity { + return Ok(BufferResult { + error: 0, + value: wide_bytes(absolute[..length as usize].iter().copied()), + }); + } + absolute.resize(length as usize + 1, 0); + } +} + +#[napi] +pub fn windows_directory_names(path: Buffer) -> napi::Result { + let result = windows_directory_entries(path)?; + Ok(DirectoryResult { + error: result.error, + value: result.value.into_iter().map(|entry| entry.name).collect(), + }) +} + +#[napi] +pub fn windows_directory_entries(path: Buffer) -> napi::Result { + let path = os_string(path)?; + let failure = |error| DirectoryEntriesResult { + error, + value: Vec::new(), + }; + if path.is_empty() { + return Ok(failure(ERROR_PATH_NOT_FOUND)); + } + let pattern = Path::new(&path).join("*"); + let pattern = directory_search_path(wide_bytes(pattern.as_os_str().encode_wide()))?; + if pattern.error != 0 { + return Ok(failure(pattern.error)); + } + let pattern = wide_path(pattern.value)?; + let mut data = WIN32_FIND_DATAW::default(); + let handle = unsafe { FindFirstFileW(pattern.as_ptr(), &mut data) }; + if handle == INVALID_HANDLE_VALUE { + let error = unsafe { GetLastError() }; + // Like read_dir, a successful empty search is an empty iterator. + return Ok(failure(if error == ERROR_FILE_NOT_FOUND { + 0 + } else { + error + })); + } + struct FindHandle(HANDLE); + impl Drop for FindHandle { + fn drop(&mut self) { + unsafe { FindClose(self.0) }; + } + } + let handle = FindHandle(handle); + let mut value = Vec::new(); + loop { + let end = data.cFileName.iter().position(|unit| *unit == 0).unwrap(); + let name = &data.cFileName[..end]; + if name != [b'.' as u16] && name != [b'.' as u16, b'.' as u16] { + value.push(DirectoryEntry { + name: wide_bytes(name.iter().copied()), + is_directory: data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY != 0, + is_symbolic_link: data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 + && data.dwReserved0 == 0xa000000c, + }); + } + if unsafe { FindNextFileW(handle.0, &mut data) } == 0 { + let error = unsafe { GetLastError() }; + return Ok(if error == ERROR_NO_MORE_FILES { + DirectoryEntriesResult { error: 0, value } + } else { + failure(error) + }); + } + } +} + +fn directory_search_path(path: Buffer) -> napi::Result { + const SEP: u16 = b'\\' as u16; + const ALT: u16 = b'/' as u16; + const COLON: u16 = b':' as u16; + const DOT: u16 = b'.' as u16; + const QUERY: u16 = b'?' as u16; + let units = wide_path(path.to_vec().into())?; + let verbatim = + units.starts_with(&[SEP, SEP, QUERY, SEP]) || units.starts_with(&[SEP, QUERY, QUERY, SEP]); + let short_absolute = units.len() < 248 + && (matches!(units.as_slice(), [drive, COLON, SEP | ALT, ..] if ![SEP, ALT].contains(drive)) + || matches!(units.as_slice(), [SEP | ALT, SEP | ALT, ..])); + if verbatim || short_absolute { + return Ok(BufferResult { + error: 0, + value: path, + }); + } + // Match read_dir's conversion of relative and long search paths to verbatim paths. + let absolute = windows_absolute_path(path)?; + if absolute.error != 0 { + return Ok(absolute); + } + let units = wide_path(absolute.value)?; + let units = &units[..units.len() - 1]; + let (prefix, rest): (&[u16], &[u16]) = match units { + [_, COLON, SEP, ..] => (&[SEP, SEP, QUERY, SEP], units), + [SEP, SEP, DOT, SEP, rest @ ..] => (&[SEP, SEP, QUERY, SEP], rest), + [SEP, SEP, QUERY, SEP, ..] | [SEP, QUERY, QUERY, SEP, ..] => (&[], units), + [SEP, SEP, rest @ ..] => ( + &[ + SEP, + SEP, + QUERY, + SEP, + b'U' as u16, + b'N' as u16, + b'C' as u16, + SEP, + ], + rest, + ), + _ => (&[], units), + }; + Ok(BufferResult { + error: 0, + value: wide_bytes(prefix.iter().chain(rest).copied()), + }) +} + 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 478fb4ba8..6e57f3b86 100644 --- a/plugins/codex-security/native/windows-binding.mts +++ b/plugins/codex-security/native/windows-binding.mts @@ -32,6 +32,15 @@ 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; + windowsDirectoryNames(path: Buffer): WindowsResult; + windowsDirectoryEntries( + path: Buffer, + ): WindowsResult< + { name: Buffer; isDirectory: boolean; isSymbolicLink: boolean }[] + >; openWindowsFile( path: Buffer, access: number, @@ -51,6 +60,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..d188a5310 --- /dev/null +++ b/plugins/codex-security/native/windows-files.mts @@ -0,0 +1,236 @@ +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 { + // GetFullPathNameW normalizes even explicit verbatim paths. + if (pathText(path).startsWith("\\\\?\\")) return path; + 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 { + // Match CPython's Windows normalization of UNC and device prefixes. + const textPath = pathText(path).replaceAll("/", "\\"); + let normalizedText = textPath; + if (textPath.startsWith("\\\\")) { + const first = textPath.indexOf("\\", 2); + const end = first === -1 ? -1 : textPath.indexOf("\\", first + 1); + if (end !== -1) { + const tail = textPath.slice(end + 1).replace(/^\\+/u, ""); + normalizedText = + textPath.slice(0, end + 1) + + win32.normalize(`\\${tail}`).slice(1).replace(/\\+$/u, ""); + } + } else { + if (textPath[1] === ":" && textPath.slice(2, 4) === ".\\") { + // Windows normpath retains the first drive-relative dot until a parent consumes it. + const parts = ["."]; + for (const part of textPath.slice(4).split("\\")) { + if (part === "" || part === ".") continue; + if (part === ".." && parts.length && parts.at(-1) !== "..") + parts.pop(); + else parts.push(part); + } + normalizedText = textPath.slice(0, 2) + parts.join("\\"); + } else normalizedText = win32.normalize(textPath); + const root = win32.parse(normalizedText).root; + normalizedText = + root + normalizedText.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 entries(path: Buffer): Buffer[] { + const result = native.windowsDirectoryNames(operationPath(path)); + check(result.error, path); + return result.value; + } + + 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, + entries, + entriesWithTypes, + mkdir, + readInto, + writeFile, + }; +}