Skip to content

Commit 418f993

Browse files
committed
fix(plugin): add native account lookup and portable proof fixtures
1 parent ae02182 commit 418f993

4 files changed

Lines changed: 91 additions & 9 deletions

File tree

‎plugins/codex-security/native/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
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.
44

5-
The eight 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.
5+
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.
66

77
`openAt` and `fileLock` retry EINTR, matching the current Python helpers. Other operations return their native errno. `readDescriptor` retries one interrupted Node read without losing earlier chunks. Blocking locks must run outside the main JavaScript event loop; a process that holds a lock releases it on close or exit. A Python signal handler can raise during a blocked call, so later routing must preserve cancellation through the worker lifecycle.
88

@@ -17,7 +17,7 @@ cargo +1.97.1 fmt --check --manifest-path plugins/codex-security/native/Cargo.to
1717
cargo +1.97.1 clippy --locked --manifest-path plugins/codex-security/native/Cargo.toml -- -D warnings
1818
```
1919

20-
The proof runs without Python. It checks directory replacement, byte paths, unreadable-file metadata, long raw symlinks, descriptor duplication, Node descriptor I/O, contention, unlock, and process-death release. CI invokes it with an empty `PATH`. During migration, the same protocol can compare the existing Python lock helper:
20+
The proof runs without Python. It checks directory replacement, byte paths, unreadable-file metadata, long raw symlinks, descriptor duplication, Node descriptor I/O, account lookup, contention, unlock, and process-death release. Linux exercises undecodable filename bytes; macOS uses valid UTF-8 filenames required by APFS. CI invokes it with an empty `PATH`. During migration, the same protocol can compare the existing Python lock helper:
2121

2222
```sh
2323
node plugins/codex-security/native/proof.mjs python3 plugins/codex-security/scripts

‎plugins/codex-security/native/binding.mts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export interface UnixBinding {
4343
unlock: boolean,
4444
nonblocking: boolean,
4545
): SyscallResult;
46+
userHome(username: Buffer): { errno: number; value: Buffer | null };
4647
}
4748

4849
export function loadBinding(): UnixBinding {

‎plugins/codex-security/native/proof.mts‎

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import {
1717
symlinkSync,
1818
writeFileSync,
1919
} from "node:fs";
20-
import { tmpdir } from "node:os";
20+
import { tmpdir, userInfo } from "node:os";
21+
import { randomUUID } from "node:crypto";
2122
import { basename, join } from "node:path";
2223
import { createInterface } from "node:readline";
2324
import { fileURLToPath } from "node:url";
@@ -63,6 +64,41 @@ const readLinkAt = (fd: number, path: string | Buffer) =>
6364
checked(native.readLinkAt(fd, bytes(path))).value;
6465
const rawPath = (parent: string, name: Buffer) =>
6566
Buffer.concat([Buffer.from(parent + "/"), name]);
67+
// APFS requires valid UTF-8 names; Linux also exercises undecodable bytes.
68+
const fixtureName = (prefix: string, byte: number) =>
69+
process.platform === "darwin"
70+
? Buffer.from(`${prefix}-é`)
71+
: Buffer.from([prefix.charCodeAt(0), byte]);
72+
73+
function accountProof() {
74+
let currentHomeMatches: boolean | null = null;
75+
try {
76+
const current = userInfo({ encoding: "buffer" });
77+
assert.deepEqual(
78+
checked(native.userHome(current.username)).value,
79+
current.homedir,
80+
);
81+
currentHomeMatches = true;
82+
} catch (error) {
83+
// A container can run a numeric UID with no account database entry.
84+
const system = error as { code?: string; info?: { code?: string } };
85+
assert.equal(system.code, "ERR_SYSTEM_ERROR");
86+
assert.equal(system.info?.code, "ENOENT");
87+
}
88+
const other = checked(native.userHome(Buffer.from("root"))).value;
89+
assert(other !== null && other[0] === 0x2f);
90+
assert.equal(
91+
checked(native.userHome(Buffer.from(`codex-${randomUUID().slice(0, 8)}`)))
92+
.value,
93+
null,
94+
);
95+
assert.throws(() => native.userHome(Buffer.from([0])));
96+
return {
97+
currentHomeMatches,
98+
namedHomeWithoutGit: true,
99+
missingAccount: true,
100+
};
101+
}
66102

67103
const directoryFlags =
68104
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW;
@@ -109,8 +145,8 @@ function descriptorProof(root: string) {
109145
const originalParent = keep(openAt(rootFd, "artifacts", directoryFlags));
110146
const parentFd = keep(duplicate(originalParent));
111147
close(originalParent);
112-
const unreadable = Buffer.from([0x75, 0xff]);
113-
const rawLink = Buffer.from([0x6c, 0xfe]);
148+
const unreadable = fixtureName("u", 0xff);
149+
const rawLink = fixtureName("l", 0xfe);
114150
const rawTarget = Buffer.concat([
115151
Buffer.from("component/".repeat(80)),
116152
Buffer.from([0xff]),
@@ -154,9 +190,9 @@ function descriptorProof(root: string) {
154190
assert.equal(readLinkAt(rootFd, "artifacts").toString(), outside);
155191
mkdirAt(parentFd, "nested", 0o700);
156192

157-
const rawDirectory = Buffer.from([0x64, 0xfd]);
158-
const rawSource = Buffer.from([0x73, 0xfc]);
159-
const rawDestination = Buffer.from([0x74, 0xfb]);
193+
const rawDirectory = fixtureName("d", 0xfd);
194+
const rawSource = fixtureName("s", 0xfc);
195+
const rawDestination = fixtureName("t", 0xfb);
160196
mkdirAt(parentFd, rawDirectory, 0o700);
161197
assert.equal(statAt(parentFd, rawDirectory).mode & 0o777, 0o700);
162198
const rawDirectoryFd = keep(openAt(parentFd, rawDirectory, directoryFlags));
@@ -445,6 +481,7 @@ if (process.argv[2] === "lock-worker") {
445481
const root = mkdtempSync(join(tmpdir(), "codex-security-native-"));
446482
try {
447483
const descriptors = descriptorProof(root);
484+
const accounts = accountProof();
448485
const locks = await lockProof(root);
449486
const pythonCompatibility =
450487
python && scripts ? await lockProof(root, python, scripts) : undefined;
@@ -456,6 +493,7 @@ if (process.argv[2] === "lock-worker") {
456493
architecture: process.arch,
457494
nodeApi: 8,
458495
descriptors,
496+
accounts,
459497
locks,
460498
pythonCompatibility,
461499
fixture: basename(root),

‎plugins/codex-security/native/src/lib.rs‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
use napi::bindgen_prelude::Buffer;
22
use napi_derive::napi;
3-
use std::{ffi::CString, io};
3+
use std::{
4+
ffi::{CStr, CString},
5+
io,
6+
};
47

58
#[napi(object)]
69
pub struct SyscallResult {
@@ -168,3 +171,43 @@ pub fn read_link_at(directory: i32, name: Buffer) -> napi::Result<ReadLinkResult
168171
buffer.resize(buffer.len() * 2, 0);
169172
}
170173
}
174+
175+
#[napi(object, use_nullable = true)]
176+
pub struct UserHomeResult {
177+
pub errno: i32,
178+
pub value: Option<Buffer>,
179+
}
180+
181+
#[napi]
182+
pub fn user_home(username: Buffer) -> napi::Result<UserHomeResult> {
183+
let username = path(username)?;
184+
let mut buffer = vec![0_u8; 1024];
185+
loop {
186+
let mut entry = std::mem::MaybeUninit::<libc::passwd>::uninit();
187+
let mut found = std::ptr::null_mut();
188+
let code = unsafe {
189+
libc::getpwnam_r(
190+
username.as_ptr(),
191+
entry.as_mut_ptr(),
192+
buffer.as_mut_ptr().cast(),
193+
buffer.len(),
194+
&mut found,
195+
)
196+
};
197+
if code == libc::ERANGE {
198+
buffer.resize(buffer.len() * 2, 0);
199+
continue;
200+
}
201+
let value = if code == 0 && !found.is_null() {
202+
Some(
203+
unsafe { CStr::from_ptr((*found).pw_dir) }
204+
.to_bytes()
205+
.to_vec()
206+
.into(),
207+
)
208+
} else {
209+
None
210+
};
211+
return Ok(UserHomeResult { errno: code, value });
212+
}
213+
}

0 commit comments

Comments
 (0)