@gmickel/gno 1.34.4 → 1.34.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +11 -1
  2. package/THIRD_PARTY_NOTICES.md +16 -0
  3. package/browser-extension/artifacts/{gno-browser-clipper-v1.34.4.zip → gno-browser-clipper-v1.34.6.zip} +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.34.6.zip.sha256 +1 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/package.json +5 -1
  7. package/spec/cli.md +16 -10
  8. package/src/cli/pager.ts +29 -14
  9. package/src/ingestion/sync.ts +368 -84
  10. package/src/serve/public/globals.built.css +1 -1
  11. package/src/serve/watch-reconciliation-fallback-disk.ts +220 -0
  12. package/src/serve/watch-reconciliation-fallback.ts +404 -0
  13. package/src/serve/watch-reconciliation-shared.ts +343 -0
  14. package/src/serve/watch-reconciliation.ts +122 -0
  15. package/src/serve/watch-service-events.ts +261 -0
  16. package/src/serve/watch-service-flush-generation.ts +140 -0
  17. package/src/serve/watch-service-flush-helpers.ts +147 -0
  18. package/src/serve/watch-service-flush.ts +433 -0
  19. package/src/serve/watch-service-hosts.ts +109 -0
  20. package/src/serve/watch-service-lifecycle.ts +219 -0
  21. package/src/serve/watch-service-run-flush.ts +236 -0
  22. package/src/serve/watch-service-snapshot.ts +101 -0
  23. package/src/serve/watch-service-state.ts +146 -0
  24. package/src/serve/watch-service.ts +265 -306
  25. package/src/serve/watch-snapshot-handles.ts +285 -0
  26. package/src/serve/watch-snapshot-libc.ts +391 -0
  27. package/src/serve/watch-snapshot-ops.ts +399 -0
  28. package/src/serve/watch-snapshot-resolve.ts +246 -0
  29. package/src/serve/watch-snapshot-scan.ts +297 -0
  30. package/src/serve/watch-snapshot-types.ts +350 -0
  31. package/src/serve/watch-snapshot.ts +51 -0
  32. package/src/store/index.ts +1 -1
  33. package/src/store/sqlite/adapter.ts +191 -0
  34. package/src/store/types.ts +66 -0
  35. package/vendor/fts5-snowball/README.md +5 -1
  36. package/vendor/fts5-snowball/darwin-x64/fts5stemmer.dylib +0 -0
  37. package/browser-extension/artifacts/gno-browser-clipper-v1.34.4.zip.sha256 +0 -1
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Production anchored directory handles (openat / fdopendir).
3
+ *
4
+ * Uses Bun FFI for fd-relative open/enumerate and node:fs structure ops for
5
+ * open(O_DIRECTORY|O_NOFOLLOW) + fstat bigint metadata. No new dependencies.
6
+ *
7
+ * Windows and runtimes without a safe anchored path report
8
+ * `supportsAnchoredHandles: false` so callers fall back rather than claiming
9
+ * strict no-follow.
10
+ *
11
+ * @module src/serve/watch-snapshot-handles
12
+ */
13
+
14
+ // node:fs — open/fstat/close with O_DIRECTORY|O_NOFOLLOW; no Bun equivalent
15
+ import { closeSync, fstatSync, openSync } from "node:fs";
16
+ // node:path — Bun has no path utilities
17
+ import { join } from "node:path";
18
+
19
+ import type { LoadedLibc } from "./watch-snapshot-libc";
20
+ import type {
21
+ WatcherDirHandle,
22
+ WatcherSnapshotFs,
23
+ WatcherSnapshotStat,
24
+ } from "./watch-snapshot-types";
25
+
26
+ import { loadLibc, openatOrThrow, readDirNames } from "./watch-snapshot-libc";
27
+ import { isMissingFsError } from "./watch-snapshot-types";
28
+
29
+ type NativeDir = {
30
+ fd: number;
31
+ };
32
+
33
+ const nativeHandles = new WeakMap<object, NativeDir>();
34
+
35
+ function asHandle(native: NativeDir): WatcherDirHandle {
36
+ const handle = {} as WatcherDirHandle;
37
+ nativeHandles.set(handle as object, native);
38
+ return handle;
39
+ }
40
+
41
+ function requireNative(handle: WatcherDirHandle): NativeDir {
42
+ const native = nativeHandles.get(handle as object);
43
+ if (!native) {
44
+ throw Object.assign(new Error("Invalid or closed directory handle"), {
45
+ code: "EBADF",
46
+ });
47
+ }
48
+ return native;
49
+ }
50
+
51
+ function mapStats(stat: {
52
+ isFile(): boolean;
53
+ isDirectory(): boolean;
54
+ isSymbolicLink(): boolean;
55
+ dev: bigint | number;
56
+ ino: bigint | number;
57
+ size: bigint | number;
58
+ mtimeNs?: bigint | number;
59
+ ctimeNs?: bigint | number;
60
+ }): WatcherSnapshotStat {
61
+ return {
62
+ isFile: () => stat.isFile(),
63
+ isDirectory: () => stat.isDirectory(),
64
+ isSymbolicLink: () => stat.isSymbolicLink(),
65
+ dev: stat.dev,
66
+ ino: stat.ino,
67
+ size: stat.size,
68
+ mtimeNs: stat.mtimeNs,
69
+ ctimeNs: stat.ctimeNs,
70
+ };
71
+ }
72
+
73
+ function createNativeAnchoredFs(libc: LoadedLibc): WatcherSnapshotFs {
74
+ return {
75
+ supportsAnchoredHandles: true,
76
+
77
+ async openDir(absPath: string): Promise<WatcherDirHandle> {
78
+ const fd = openSync(absPath, libc.openDirFlags);
79
+ return asHandle({ fd });
80
+ },
81
+
82
+ async readDir(handle: WatcherDirHandle, maxNames: number) {
83
+ const native = requireNative(handle);
84
+ return readDirNames(libc, native.fd, maxNames);
85
+ },
86
+
87
+ async lstatChild(
88
+ handle: WatcherDirHandle,
89
+ name: string
90
+ ): Promise<WatcherSnapshotStat> {
91
+ const native = requireNative(handle);
92
+ const fd = openatOrThrow(
93
+ libc,
94
+ native.fd,
95
+ name,
96
+ libc.openLstatFlags,
97
+ "openat"
98
+ );
99
+ try {
100
+ const stat = fstatSync(fd, { bigint: true });
101
+ return mapStats(stat);
102
+ } finally {
103
+ closeSync(fd);
104
+ }
105
+ },
106
+
107
+ async openChildDir(
108
+ handle: WatcherDirHandle,
109
+ name: string
110
+ ): Promise<WatcherDirHandle> {
111
+ const native = requireNative(handle);
112
+ const fd = openatOrThrow(
113
+ libc,
114
+ native.fd,
115
+ name,
116
+ libc.openChildFlags,
117
+ "openat"
118
+ );
119
+ // Confirm real directory (O_DIRECTORY should enforce; double-check kind).
120
+ try {
121
+ const stat = fstatSync(fd, { bigint: true });
122
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
123
+ closeSync(fd);
124
+ throw Object.assign(
125
+ new Error(`Expected real directory child: ${name}`),
126
+ { code: "ENOTDIR" }
127
+ );
128
+ }
129
+ } catch (cause) {
130
+ try {
131
+ closeSync(fd);
132
+ } catch {
133
+ // ignore close errors when fstat failed
134
+ }
135
+ throw cause;
136
+ }
137
+ return asHandle({ fd });
138
+ },
139
+
140
+ async closeDir(handle: WatcherDirHandle): Promise<void> {
141
+ const native = nativeHandles.get(handle as object);
142
+ if (!native) {
143
+ return;
144
+ }
145
+ nativeHandles.delete(handle as object);
146
+ try {
147
+ closeSync(native.fd);
148
+ } catch (cause) {
149
+ if (!isMissingFsError(cause)) {
150
+ // EBADF after double-close is fine; surface others.
151
+ const code =
152
+ cause && typeof cause === "object" && "code" in cause
153
+ ? String(cause.code)
154
+ : "";
155
+ if (code !== "EBADF") {
156
+ throw cause;
157
+ }
158
+ }
159
+ }
160
+ },
161
+ };
162
+ }
163
+
164
+ function createUnsupportedFs(reason: string): WatcherSnapshotFs {
165
+ const fail = async (): Promise<never> => {
166
+ throw Object.assign(new Error(reason), { code: "ENOTSUP" });
167
+ };
168
+ return {
169
+ supportsAnchoredHandles: false,
170
+ openDir: fail,
171
+ readDir: fail,
172
+ lstatChild: fail,
173
+ openChildDir: fail,
174
+ closeDir: async () => undefined,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Production filesystem adapter.
180
+ * Unix + working libc FFI → anchored handles; otherwise explicit unsupported.
181
+ */
182
+ export function createDefaultWatcherFs(): WatcherSnapshotFs {
183
+ if (process.platform === "win32") {
184
+ return createUnsupportedFs(
185
+ "Anchored no-follow directory handles are not available on Windows; watcher uses fallback reconciliation"
186
+ );
187
+ }
188
+ const libc = loadLibc();
189
+ if (!libc) {
190
+ return createUnsupportedFs(
191
+ "Anchored no-follow directory handles unavailable on this runtime"
192
+ );
193
+ }
194
+ return createNativeAnchoredFs(libc);
195
+ }
196
+
197
+ /**
198
+ * Path-backed handle adapter for deterministic unit tests.
199
+ * NOT production-safe against TOCTOU path swaps — tests that need race
200
+ * coverage must inject genuine pin-by-identity handles.
201
+ */
202
+ export function createPathBackedWatcherFs(ops: {
203
+ readdir(absPath: string): Promise<string[]>;
204
+ lstat(absPath: string): Promise<WatcherSnapshotStat>;
205
+ }): WatcherSnapshotFs {
206
+ type PathHandle = { absPath: string };
207
+ const table = new WeakMap<object, PathHandle>();
208
+
209
+ const wrap = (absPath: string): WatcherDirHandle => {
210
+ const handle = {} as WatcherDirHandle;
211
+ table.set(handle as object, { absPath });
212
+ return handle;
213
+ };
214
+
215
+ const unwrap = (handle: WatcherDirHandle): PathHandle => {
216
+ const value = table.get(handle as object);
217
+ if (!value) {
218
+ throw Object.assign(new Error("Invalid or closed directory handle"), {
219
+ code: "EBADF",
220
+ });
221
+ }
222
+ return value;
223
+ };
224
+
225
+ return {
226
+ supportsAnchoredHandles: true,
227
+
228
+ async openDir(absPath: string): Promise<WatcherDirHandle> {
229
+ const stat = await ops.lstat(absPath);
230
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
231
+ throw Object.assign(new Error(`Not a real directory: ${absPath}`), {
232
+ code: "ENOTDIR",
233
+ });
234
+ }
235
+ return wrap(absPath);
236
+ },
237
+
238
+ async readDir(handle: WatcherDirHandle, maxNames: number) {
239
+ if (!Number.isInteger(maxNames) || maxNames < 0) {
240
+ throw Object.assign(
241
+ new Error("maxNames must be a non-negative integer"),
242
+ { code: "EINVAL" }
243
+ );
244
+ }
245
+ const listed = await ops.readdir(unwrap(handle).absPath);
246
+ const names: string[] = [];
247
+ for (const name of listed) {
248
+ if (name === "" || name === "." || name === "..") {
249
+ continue;
250
+ }
251
+ // Cap storage at maxNames; the next name is overflow-only evidence.
252
+ if (names.length >= maxNames) {
253
+ return { status: "overflow" as const };
254
+ }
255
+ names.push(name);
256
+ }
257
+ return { status: "ok" as const, names };
258
+ },
259
+
260
+ async lstatChild(
261
+ handle: WatcherDirHandle,
262
+ name: string
263
+ ): Promise<WatcherSnapshotStat> {
264
+ return ops.lstat(join(unwrap(handle).absPath, name));
265
+ },
266
+
267
+ async openChildDir(
268
+ handle: WatcherDirHandle,
269
+ name: string
270
+ ): Promise<WatcherDirHandle> {
271
+ const absPath = join(unwrap(handle).absPath, name);
272
+ const stat = await ops.lstat(absPath);
273
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
274
+ throw Object.assign(new Error(`Not a real directory: ${absPath}`), {
275
+ code: "ENOTDIR",
276
+ });
277
+ }
278
+ return wrap(absPath);
279
+ },
280
+
281
+ async closeDir(handle: WatcherDirHandle): Promise<void> {
282
+ table.delete(handle as object);
283
+ },
284
+ };
285
+ }
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Libc FFI boundary for anchored watcher directory handles.
3
+ *
4
+ * Loads system libc with platform-correct sonames, parses dirent records
5
+ * using d_reclen/d_namlen bounds, and clears errno around readdir.
6
+ *
7
+ * @module src/serve/watch-snapshot-libc
8
+ */
9
+
10
+ // bun:ffi — no Bun high-level openat/fdopendir; libc is required for fd-relative ops
11
+ import { type Pointer, dlopen, FFIType, ptr, toArrayBuffer } from "bun:ffi";
12
+ // node:fs — flag constants for openat; no Bun equivalent
13
+ import { constants as fsConstants } from "node:fs";
14
+
15
+ export type LibcSymbols = {
16
+ openat: (dirfd: number, path: Pointer, flags: number) => number;
17
+ dup: (fd: number) => number;
18
+ close: (fd: number) => number;
19
+ fdopendir: (fd: number) => Pointer | null;
20
+ readdir: (dirp: Pointer) => Pointer | null;
21
+ closedir: (dirp: Pointer) => number;
22
+ errnoPtr: () => Pointer;
23
+ };
24
+
25
+ /** Platform dirent field layout (little-endian). */
26
+ export type DirentLayout = {
27
+ /** Offset of d_reclen (uint16). Both Darwin and Linux: 16. */
28
+ dReclenOffset: number;
29
+ /**
30
+ * Offset of d_namlen (uint16) when present.
31
+ * Darwin: 18. Linux glibc has no d_namlen (null).
32
+ */
33
+ dNamlenOffset: number | null;
34
+ /** Offset of d_name char array. Darwin: 21. Linux glibc: 19. */
35
+ dNameOffset: number;
36
+ };
37
+
38
+ export type LoadedLibc = {
39
+ /** Strong reference so FFI symbols stay live for the process lifetime. */
40
+ library: ReturnType<typeof dlopen>;
41
+ symbols: LibcSymbols;
42
+ dirent: DirentLayout;
43
+ openChildFlags: number;
44
+ openDirFlags: number;
45
+ openLstatFlags: number;
46
+ };
47
+
48
+ let cachedLibc: LoadedLibc | null | undefined;
49
+
50
+ /**
51
+ * Deterministic libc soname candidates.
52
+ * Never rely solely on `libc.<suffix>` (Linux libc.so is often a linker script).
53
+ */
54
+ export function libcLoadCandidates(
55
+ platform: string = process.platform
56
+ ): string[] {
57
+ if (platform === "darwin") {
58
+ return ["libSystem.B.dylib", "libc.dylib"];
59
+ }
60
+ if (platform === "linux") {
61
+ return ["libc.so.6", "libc.so"];
62
+ }
63
+ return [];
64
+ }
65
+
66
+ function direntLayoutFor(platform: string): DirentLayout {
67
+ if (platform === "darwin") {
68
+ // struct dirent: d_ino(8) d_seekoff(8) d_reclen(2) d_namlen(2) d_type(1) d_name[]
69
+ return { dReclenOffset: 16, dNamlenOffset: 18, dNameOffset: 21 };
70
+ }
71
+ // glibc struct dirent: d_ino(8) d_off(8) d_reclen(2) d_type(1) d_name[]
72
+ return { dReclenOffset: 16, dNamlenOffset: null, dNameOffset: 19 };
73
+ }
74
+
75
+ function openLibcSymbols(
76
+ path: string,
77
+ errnoName: string
78
+ ): ReturnType<typeof dlopen> | null {
79
+ try {
80
+ return dlopen(path, {
81
+ openat: {
82
+ args: [FFIType.i32, FFIType.cstring, FFIType.i32],
83
+ returns: FFIType.i32,
84
+ },
85
+ dup: { args: [FFIType.i32], returns: FFIType.i32 },
86
+ close: { args: [FFIType.i32], returns: FFIType.i32 },
87
+ fdopendir: { args: [FFIType.i32], returns: FFIType.ptr },
88
+ readdir: { args: [FFIType.ptr], returns: FFIType.ptr },
89
+ closedir: { args: [FFIType.ptr], returns: FFIType.i32 },
90
+ [errnoName]: { args: [], returns: FFIType.ptr },
91
+ });
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ export function loadLibc(): LoadedLibc | null {
98
+ if (cachedLibc !== undefined) {
99
+ return cachedLibc;
100
+ }
101
+ if (process.platform === "win32") {
102
+ cachedLibc = null;
103
+ return null;
104
+ }
105
+
106
+ const platform = process.platform;
107
+ const errnoName = platform === "darwin" ? "__error" : "__errno_location";
108
+ const candidates = libcLoadCandidates(platform);
109
+
110
+ let library: ReturnType<typeof dlopen> | null = null;
111
+ for (const candidate of candidates) {
112
+ library = openLibcSymbols(candidate, errnoName);
113
+ if (library) {
114
+ break;
115
+ }
116
+ }
117
+ if (!library) {
118
+ cachedLibc = null;
119
+ return null;
120
+ }
121
+
122
+ const raw = library.symbols as Record<string, unknown>;
123
+ const openat = asLibcFn<LibcSymbols["openat"]>(raw.openat);
124
+ const dup = asLibcFn<LibcSymbols["dup"]>(raw.dup);
125
+ const close = asLibcFn<LibcSymbols["close"]>(raw.close);
126
+ const fdopendir = asLibcFn<LibcSymbols["fdopendir"]>(raw.fdopendir);
127
+ const readdir = asLibcFn<LibcSymbols["readdir"]>(raw.readdir);
128
+ const closedir = asLibcFn<LibcSymbols["closedir"]>(raw.closedir);
129
+ const errnoFn = asLibcFn<LibcSymbols["errnoPtr"]>(raw[errnoName]);
130
+ if (
131
+ !openat ||
132
+ !dup ||
133
+ !close ||
134
+ !fdopendir ||
135
+ !readdir ||
136
+ !closedir ||
137
+ !errnoFn
138
+ ) {
139
+ cachedLibc = null;
140
+ return null;
141
+ }
142
+
143
+ const O_RDONLY = fsConstants.O_RDONLY;
144
+ const O_DIRECTORY = fsConstants.O_DIRECTORY;
145
+ const O_NOFOLLOW = fsConstants.O_NOFOLLOW;
146
+
147
+ let openLstatFlags: number;
148
+ if (platform === "darwin") {
149
+ // Darwin O_SYMLINK opens the symlink inode itself (lstat-like).
150
+ // Without it, O_RDONLY would follow — refuse rather than silent follow.
151
+ // O_NONBLOCK is required: plain O_RDONLY|O_SYMLINK can block forever on a
152
+ // FIFO with no writer (and similarly hang on some device nodes).
153
+ const O_SYMLINK = (fsConstants as { O_SYMLINK?: number }).O_SYMLINK;
154
+ if (O_SYMLINK === undefined || O_SYMLINK === 0) {
155
+ cachedLibc = null;
156
+ return null;
157
+ }
158
+ const O_NONBLOCK = fsConstants.O_NONBLOCK ?? 0;
159
+ openLstatFlags = O_RDONLY | O_SYMLINK | O_NONBLOCK;
160
+ } else {
161
+ // Linux O_PATH|O_NOFOLLOW is the portable no-follow open for any type.
162
+ // O_PATH does not block on FIFOs/sockets; no extra O_NONBLOCK needed.
163
+ const O_PATH = (fsConstants as { O_PATH?: number }).O_PATH ?? 0o10_000_000;
164
+ openLstatFlags = O_RDONLY | O_PATH | O_NOFOLLOW;
165
+ }
166
+
167
+ const openDirFlags = O_RDONLY | O_DIRECTORY | O_NOFOLLOW;
168
+
169
+ cachedLibc = {
170
+ library,
171
+ symbols: {
172
+ openat,
173
+ dup,
174
+ close,
175
+ fdopendir,
176
+ readdir,
177
+ closedir,
178
+ errnoPtr: errnoFn,
179
+ },
180
+ dirent: direntLayoutFor(platform),
181
+ openChildFlags: openDirFlags,
182
+ openDirFlags,
183
+ openLstatFlags,
184
+ };
185
+ return cachedLibc;
186
+ }
187
+
188
+ function asLibcFn<T extends (...args: never[]) => unknown>(
189
+ value: unknown
190
+ ): T | null {
191
+ if (typeof value !== "function") {
192
+ return null;
193
+ }
194
+ return value as T;
195
+ }
196
+
197
+ export function errnoError(
198
+ errno: number,
199
+ syscall: string,
200
+ path: string
201
+ ): NodeJS.ErrnoException {
202
+ const error = new Error(
203
+ `${syscall} failed (errno ${errno}): ${path}`
204
+ ) as NodeJS.ErrnoException;
205
+ error.errno = errno;
206
+ error.syscall = syscall;
207
+ error.path = path;
208
+ // Map common POSIX errno values used on darwin/linux.
209
+ if (errno === 2) {
210
+ error.code = "ENOENT";
211
+ } else if (errno === 13) {
212
+ error.code = "EACCES";
213
+ } else if (errno === 20) {
214
+ error.code = "ENOTDIR";
215
+ } else if (errno === 40 || errno === 62) {
216
+ // Linux ELOOP=40, Darwin ELOOP=62
217
+ error.code = "ELOOP";
218
+ } else if (errno === 17) {
219
+ error.code = "EEXIST";
220
+ } else {
221
+ error.code = "EIO";
222
+ }
223
+ return error;
224
+ }
225
+
226
+ export function readErrno(libc: LoadedLibc): number {
227
+ const p = libc.symbols.errnoPtr();
228
+ if (!p) {
229
+ return 0;
230
+ }
231
+ const view = new Int32Array(toArrayBuffer(p, 0, 4));
232
+ return view[0] ?? 0;
233
+ }
234
+
235
+ /** Clear thread errno so readdir EOF (null + errno 0) is distinguishable from error. */
236
+ export function clearErrno(libc: LoadedLibc): void {
237
+ const p = libc.symbols.errnoPtr();
238
+ if (!p) {
239
+ return;
240
+ }
241
+ const view = new Int32Array(toArrayBuffer(p, 0, 4));
242
+ view[0] = 0;
243
+ }
244
+
245
+ /**
246
+ * Parse d_name from a dirent pointer using platform reclen/namlen bounds.
247
+ * Returns null for malformed records (caller treats as scan failure).
248
+ */
249
+ export function parseDirentName(
250
+ ent: Pointer,
251
+ layout: DirentLayout
252
+ ): string | null {
253
+ // Header must cover through d_name start (includes reclen and optional namlen).
254
+ if (layout.dNameOffset < 18) {
255
+ return null;
256
+ }
257
+ let header: DataView;
258
+ try {
259
+ header = new DataView(toArrayBuffer(ent, 0, layout.dNameOffset));
260
+ } catch {
261
+ return null;
262
+ }
263
+
264
+ const reclen = header.getUint16(layout.dReclenOffset, true);
265
+ // Record must at least hold the fixed header + one byte of d_name room.
266
+ if (reclen < layout.dNameOffset + 1) {
267
+ return null;
268
+ }
269
+ const maxNameBytes = reclen - layout.dNameOffset;
270
+ // POSIX NAME_MAX is typically 255; reject absurd reclen-derived lengths.
271
+ if (maxNameBytes > 1024) {
272
+ return null;
273
+ }
274
+
275
+ let nameLen: number;
276
+ if (layout.dNamlenOffset !== null) {
277
+ const namlen = header.getUint16(layout.dNamlenOffset, true);
278
+ if (namlen > maxNameBytes) {
279
+ return null;
280
+ }
281
+ nameLen = namlen;
282
+ } else {
283
+ let bytes: Uint8Array;
284
+ try {
285
+ bytes = new Uint8Array(
286
+ toArrayBuffer(ent, layout.dNameOffset, maxNameBytes)
287
+ );
288
+ } catch {
289
+ return null;
290
+ }
291
+ let end = 0;
292
+ while (end < bytes.length && bytes[end] !== 0) {
293
+ end += 1;
294
+ }
295
+ // Linux dirents are NUL-terminated within d_reclen; missing NUL → malformed.
296
+ if (end === bytes.length) {
297
+ return null;
298
+ }
299
+ return Buffer.from(bytes.subarray(0, end)).toString();
300
+ }
301
+
302
+ if (nameLen === 0) {
303
+ return "";
304
+ }
305
+ try {
306
+ const nameBytes = new Uint8Array(
307
+ toArrayBuffer(ent, layout.dNameOffset, nameLen)
308
+ );
309
+ return Buffer.from(nameBytes).toString();
310
+ } catch {
311
+ return null;
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Enumerate child names via fdopendir/readdir, stopping after `maxNames + 1`
317
+ * observed entries so overflow is proven without materializing an unbounded list.
318
+ */
319
+ export function readDirNames(
320
+ libc: LoadedLibc,
321
+ dirfd: number,
322
+ maxNames: number
323
+ ): { status: "ok"; names: string[] } | { status: "overflow" } {
324
+ if (!Number.isInteger(maxNames) || maxNames < 0) {
325
+ throw Object.assign(new Error("maxNames must be a non-negative integer"), {
326
+ code: "EINVAL",
327
+ });
328
+ }
329
+ // fdopendir consumes the fd on success — operate on a dup so the handle stays live.
330
+ const dupFd = libc.symbols.dup(dirfd);
331
+ if (dupFd < 0) {
332
+ throw errnoError(readErrno(libc), "dup", "");
333
+ }
334
+ const dirp = libc.symbols.fdopendir(dupFd);
335
+ if (!dirp) {
336
+ libc.symbols.close(dupFd);
337
+ throw errnoError(readErrno(libc), "fdopendir", "");
338
+ }
339
+ const names: string[] = [];
340
+ try {
341
+ while (true) {
342
+ clearErrno(libc);
343
+ const ent = libc.symbols.readdir(dirp);
344
+ if (!ent) {
345
+ const err = readErrno(libc);
346
+ if (err !== 0) {
347
+ throw errnoError(err, "readdir", "");
348
+ }
349
+ break;
350
+ }
351
+ const name = parseDirentName(ent, libc.dirent);
352
+ if (name === null) {
353
+ throw Object.assign(new Error("Malformed dirent record"), {
354
+ code: "EIO",
355
+ syscall: "readdir",
356
+ });
357
+ }
358
+ if (name === "" || name === "." || name === "..") {
359
+ continue;
360
+ }
361
+ // maxNames+1th name proves overflow; do not store it or continue.
362
+ if (names.length >= maxNames) {
363
+ return { status: "overflow" };
364
+ }
365
+ names.push(name);
366
+ }
367
+ } finally {
368
+ libc.symbols.closedir(dirp);
369
+ }
370
+ return { status: "ok", names };
371
+ }
372
+
373
+ export function openatOrThrow(
374
+ libc: LoadedLibc,
375
+ dirfd: number,
376
+ name: string,
377
+ flags: number,
378
+ syscall: string
379
+ ): number {
380
+ if (name.includes("\0") || name.includes("/") || name.includes("\\")) {
381
+ throw Object.assign(new Error(`Invalid directory entry name: ${name}`), {
382
+ code: "EINVAL",
383
+ });
384
+ }
385
+ const nameBuf = Buffer.from(`${name}\0`);
386
+ const fd = libc.symbols.openat(dirfd, ptr(nameBuf), flags);
387
+ if (fd < 0) {
388
+ throw errnoError(readErrno(libc), syscall, name);
389
+ }
390
+ return fd;
391
+ }