diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index 037109640..acfed6cbd 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -310,6 +310,12 @@ const FILESYSTEM = [ // Cargo membership uses path normalization, descriptor validation, symlinks, // and Rust native parsing (including long Windows source strings). 'test/unit/scope-resolution/rust-cargo-targets.test.ts', + // Dart pubspec discovery walks with a per-platform backend: Linux lists and + // opens through /proc/self/fd, macOS lists lexical paths and verifies the + // pinned descriptor chain around each step, and every other platform skips + // discovery. The macOS backend shipped listing /dev/fd/N, which macOS rejects + // with ENOTDIR, and stayed broken because this file only ran on Ubuntu. + 'test/unit/dart-package-imports.test.ts', // The durable ParsedFile store's prune tolerates a chunk directory it cannot // delete (#3204). The failures that motivate it — held handles, read-only // mounts — are Windows- and macOS-flavored, and the permission-based case diff --git a/gitnexus/src/core/ingestion/languages/dart/package-config.ts b/gitnexus/src/core/ingestion/languages/dart/package-config.ts index 4503a57bd..2040d6d94 100644 --- a/gitnexus/src/core/ingestion/languages/dart/package-config.ts +++ b/gitnexus/src/core/ingestion/languages/dart/package-config.ts @@ -39,6 +39,11 @@ export interface DartPackageConfigOptions { * is listed and before its entries are opened. */ readonly beforeEntryOpen?: (relativePath: string) => void | Promise; + /** + * Test seam. Production calls omit it. Invoked after the directory is + * opened and before it is listed. + */ + readonly beforeDirectoryList?: (relativePath: string) => void | Promise; } type ManifestRead = @@ -123,12 +128,13 @@ function directoryIdentity(stat: BigIntStats): string { /** * Path that lists the directory inode already open on `fd`. - * Linux uses `/proc/self/fd/N` and macOS uses `/dev/fd/N`. - * Other platforms have no such path; callers refuse instead of listing by name. + * Only Linux has one: `/proc/self/fd/N`. macOS refuses `opendir` on + * `/dev/fd/N` for a directory (ENOTDIR) and Node has no `fdopendir`, so the + * walker lists the lexical path and verifies the pinned chain around it. + * Every other platform returns null and is not walked. */ export function descriptorDirectoryPath(fd: number): string | null { if (process.platform === 'linux') return `/proc/self/fd/${fd}`; - if (process.platform === 'darwin') return `/dev/fd/${fd}`; return null; } @@ -264,11 +270,33 @@ async function openChildFile( throw Object.assign(new Error('no descriptor anchor'), { code: 'ENOTSUP' }); } -async function listOpenedDirectory(handle: FileHandle, entryLimit: number): Promise { - const listing = descriptorDirectoryPath(handle.fd); - if (listing === null) { +/** + * List the directory open on the last frame of `frames`. Linux lists the + * pinned inode through its descriptor. macOS re-checks the pinned chain, lists + * the lexical path, then re-checks the chain, so a path replaced around the + * listing fails the walk instead of being listed. + */ +async function listOpenedDirectory( + frames: readonly WalkFrame[], + entryLimit: number, + beforeList?: () => void | Promise, +): Promise { + const frame = frames[frames.length - 1]; + if (frame === undefined) { + throw Object.assign(new Error('no directory to list'), { code: 'EINVAL' }); + } + const anchored = descriptorDirectoryPath(frame.handle.fd); + if (anchored === null && process.platform !== 'darwin') { throw Object.assign(new Error('no descriptor listing'), { code: 'ENOTSUP' }); } + if (anchored === null) await assertPinnedChain(frames); + if (beforeList) await beforeList(); + const entries = await readDirectoryBounded(anchored ?? frame.absolute, entryLimit); + if (anchored === null) await assertPinnedChain(frames); + return entries; +} + +async function readDirectoryBounded(listing: string, entryLimit: number): Promise { const dir = await opendir(listing); const entries: Dirent[] = []; try { @@ -295,8 +323,16 @@ async function listOpenedDirectory(handle: FileHandle, entryLimit: number): Prom */ export async function readDirectoryNoFollow(directory: string): Promise { const opened = await openVerifiedDirectory(directory); + const frame: WalkFrame = { + relative: '', + absolute: directory, + handle: opened.handle, + identity: opened.identity, + entries: [], + next: 0, + }; try { - return await listOpenedDirectory(opened.handle, DART_PUBSPEC_DIRECTORY_ENTRY_LIMIT); + return await listOpenedDirectory([frame], DART_PUBSPEC_DIRECTORY_ENTRY_LIMIT); } finally { await opened.handle.close(); } @@ -345,7 +381,12 @@ export async function loadDartPackageConfig( const fillFrame = async (frame: WalkFrame): Promise => { if (++visited > directoryLimit) return incomplete('directory-limit', frame.relative || '.'); try { - frame.entries = await listOpenedDirectory(frame.handle, directoryEntryLimit); + const beforeList = options?.beforeDirectoryList; + frame.entries = await listOpenedDirectory( + stack, + directoryEntryLimit, + beforeList ? () => beforeList(frame.relative) : undefined, + ); } catch (error) { const reason = (error as NodeJS.ErrnoException).code === 'E2BIG' ? 'directory-entries' : 'read-directory'; diff --git a/gitnexus/test/unit/dart-package-imports.test.ts b/gitnexus/test/unit/dart-package-imports.test.ts index 0e33bc463..4f98e3937 100644 --- a/gitnexus/test/unit/dart-package-imports.test.ts +++ b/gitnexus/test/unit/dart-package-imports.test.ts @@ -485,6 +485,35 @@ describe.skipIf(!pubspecWalkAnchored())('Dart pubspec package discovery', () => ).rejects.toThrow(/Dart pubspec discovery failed \((read-directory|read-pubspec)\)/); }); + // Linux lists the opened inode through /proc/self/fd/N. macOS cannot list a + // descriptor (opendir on /dev/fd/N is ENOTDIR), so it lists the path and + // re-checks the pinned chain afterwards; the swap must fail that check. + it('lists the opened directory, or refuses, when its path is replaced before listing', async () => { + const outside = await fixture({ 'pubspec.yaml': 'name: foreign' }); + const root = await fixture({ + 'pkg/pubspec.yaml': 'name: data', + 'pkg/nested/pubspec.yaml': 'name: nested_data', + }); + const pkg = path.join(root, 'pkg'); + const load = loadDartPackageConfig(root, { + beforeDirectoryList: async (relative) => { + if (relative !== 'pkg') return; + await rename(pkg, path.join(root, 'pkg-moved')); + await symlink(outside, pkg, 'dir'); + }, + }); + if (process.platform === 'darwin') { + await expect(load).rejects.toThrow('Dart pubspec discovery failed (read-directory): pkg'); + return; + } + expect((await load).packages).toEqual( + new Map([ + ['data', 'pkg/lib'], + ['nested_data', 'pkg/nested/lib'], + ]), + ); + }); + it('reads listed manifests from the opened directory inode after that path is replaced', async () => { if (descriptorEntryPath(0, 'pubspec.yaml') === null) return; const outside = await fixture({