fix(dart): list pubspec directories by path on macOS

macOS refuses opendir on /dev/fd/N for a directory (ENOTDIR) and Node has
no fdopendir, so every Dart pubspec walk failed on darwin at the repo root.
The darwin backend now lists the lexical path and re-checks the pinned
descriptor chain before and after, the same pattern it already uses for
child opens. Linux still lists through /proc/self/fd/N.

Adds a beforeDirectoryList test seam with a swap-before-listing test, and
registers the test file in the cross-platform list so macOS CI runs it.
This commit is contained in:
Christian C. Berclaz 2026-10-03 13:58:57 +02:00
parent c51bad71fa
commit f0c0491d1d
No known key found for this signature in database
3 changed files with 84 additions and 8 deletions

View file

@ -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

View file

@ -39,6 +39,11 @@ export interface DartPackageConfigOptions {
* is listed and before its entries are opened.
*/
readonly beforeEntryOpen?: (relativePath: string) => void | Promise<void>;
/**
* Test seam. Production calls omit it. Invoked after the directory is
* opened and before it is listed.
*/
readonly beforeDirectoryList?: (relativePath: string) => void | Promise<void>;
}
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<Dirent[]> {
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<void>,
): Promise<Dirent[]> {
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<Dirent[]> {
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<Dirent[]> {
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<void> => {
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';

View file

@ -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({