Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/readdir-entry-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computerd": patch
---

Directory listings on the mount now report each entry's type and inode, on synced and local-only paths alike. Before, every entry came back as `DT_UNKNOWN`, which `bun install` treats as nothing to link: with its cache under a local-only path such as `/workspace/.cache`, it left packages out of `node_modules` and failed with `failed to enqueue lifecycle scripts ...: ENOENT`. Tools that read `d_type`, such as `find` and `git`, no longer need an `lstat` per entry to learn it.
35 changes: 35 additions & 0 deletions packages/computerd/src/fuse/driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1069,3 +1069,38 @@ test("FUSE ftruncate does not depend on fuse-native binding this", async () => {
expect(await status((cb) => ops.flush("/ftruncate-this.txt", fh, cb))).toBe(0);
expect(Buffer.from(vfs.readFileSync("/ftruncate-this.txt")).toString("utf8")).toBe("abc");
});

test("readdir gives each entry the type and inode getattr reports", async () => {
// libfuse takes d_type from these stats. Without them every entry is
// DT_UNKNOWN, and readers that trust d_type skip it: bun's hardlink
// install walking a cache on the mount linked nothing.
const { vfs } = await createNodeVirtualFileSystem();
vfs.mkdirSync("/dir/sub", { recursive: true });
vfs.writeFileSync("/dir/file.txt", Buffer.from("x"));
const ops = makeFUSEOps(vfs);
// Created through the driver and not yet flushed, so only the driver
// knows about it.
expect((await callback((cb) => ops.create("/dir/pending.txt", 0o644, cb))).errno).toBe(0);

const listing = await new Promise<{ errno: number; names: string[]; stats: unknown[] }>(
(resolve) =>
ops.readdir("/dir", (errno, names, stats = []) => resolve({ errno, names, stats })),
);
expect(listing.errno).toBe(0);
expect([...listing.names].sort()).toEqual(["file.txt", "pending.txt", "sub"]);
expect(listing.stats).toHaveLength(listing.names.length);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved

const S_IFMT = 0o170000;
const types: Record<string, number> = {};
for (const [i, name] of listing.names.entries()) {
const fromListing = listing.stats[i] as { mode: number; ino: number };
const fromGetattr = (await callback((cb) => ops.getattr(`/dir/${name}`, cb))).result as {
mode: number;
ino: number;
};
expect(fromListing.mode, name).toBe(fromGetattr.mode);
expect(fromListing.ino, name).toBe(fromGetattr.ino);
types[name] = fromListing.mode & S_IFMT;
}
expect(types).toEqual({ "file.txt": 0o100000, "pending.txt": 0o100000, sub: 0o040000 });
});
76 changes: 52 additions & 24 deletions packages/computerd/src/fuse/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,18 @@ function blocksForSize(size: number): number {

type StatusCallback = (errnoOrBytes: number) => void;
type ResultCallback<T> = (errno: number, result: T) => void;
/**
* `stats`, when given, is one per name. libfuse takes each entry's type
* (d_type) and, under use_ino, its inode number from it; without it every
* entry is DT_UNKNOWN.
*/
export type ReaddirCallback = (errno: number, names: string[], stats?: FuseStat[]) => void;
type NotImplementedOperation = (...args: unknown[]) => void;

export interface FuseOps {
init(cb?: StatusCallback): void;
error: NotImplementedOperation;
readdir(path: string, cb: ResultCallback<string[]>): void;
readdir(path: string, cb: ReaddirCallback): void;
getattr(path: string, cb: ResultCallback<FuseStat | null>): void;
fgetattr(path: string, fh: number, cb: ResultCallback<FuseStat | null>): void;
open(path: string, flags: number, cb: ResultCallback<number>): void;
Expand Down Expand Up @@ -294,6 +300,7 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO
return parent === "." ? "/" : parent;
};
const baseName = (path: string): string => posix.basename(path);
const childPath = (parent: string, name: string): string => posix.join(parent, name);
const isPendingCreate = (path: string): boolean => files.get(path)?.pendingCreate === true;
const exists = (path: string): boolean => isPendingCreate(path) || vfs.existsSync(toVfs(path));
const getBufferStats = (): FuseBufferStats => {
Expand Down Expand Up @@ -460,6 +467,31 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO
};
};

function statPath(path: string): FuseStat {
const entry = files.get(path);
const stat =
entry?.pendingCreate === true ? pendingStat(entry) : statNode(vfs.lstatSync(toVfs(path)));
// File content lives outside the VFS, so prefer our size. The
// buffered size can outrun the persisted inode before a spill,
// so recompute block accounting from it to keep st_blocks
// coherent with the size the kernel sees.
if (entry !== undefined) {
stat.size = entry.size;
stat.blocks = blocksForSize(entry.size);
}
const override = meta.get(path);
if (override) {
if (override.mode !== undefined) {
stat.mode = (stat.mode & 0o170000) | (override.mode & 0o7777);
}
if (override.uid !== undefined) stat.uid = override.uid;
if (override.gid !== undefined) stat.gid = override.gid;
if (override.atime) stat.atime = override.atime;
if (override.mtime) stat.mtime = override.mtime;
}
return stat;
}

return {
init(cb) {
cb?.(0);
Expand All @@ -476,36 +508,32 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO
if (entry.pendingCreate && parentPath(pendingPath) === path)
entries.add(baseName(pendingPath));
}
cb(0, [...entries]);
// Without a stat per entry, libfuse reports every entry as
// DT_UNKNOWN. POSIX allows that, but readers that trust d_type
// skip such entries: bun's hardlink install walks its cache that
// way and links nothing into node_modules.
const names: string[] = [];
const stats: FuseStat[] = [];
for (const name of entries) {
let stat: FuseStat;
try {
stat = statPath(childPath(path, name));
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
} catch {
// Listed but gone by the time it was stat'd.
continue;
}
names.push(name);
stats.push(stat);
}
cb(0, names, stats);
} catch (error) {
cb(toErrno(error), []);
}
},

getattr(path, cb) {
try {
const entry = files.get(path);
const stat =
entry?.pendingCreate === true ? pendingStat(entry) : statNode(vfs.lstatSync(toVfs(path)));
// File content lives outside the VFS, so prefer our size. The
// buffered size can outrun the persisted inode before a spill,
// so recompute block accounting from it to keep st_blocks
// coherent with the size the kernel sees.
if (entry !== undefined) {
stat.size = entry.size;
stat.blocks = blocksForSize(entry.size);
}
const override = meta.get(path);
if (override) {
if (override.mode !== undefined) {
stat.mode = (stat.mode & 0o170000) | (override.mode & 0o7777);
}
if (override.uid !== undefined) stat.uid = override.uid;
if (override.gid !== undefined) stat.gid = override.gid;
if (override.atime) stat.atime = override.atime;
if (override.mtime) stat.mtime = override.mtime;
}
cb(0, stat);
cb(0, statPath(path));
} catch (error) {
cb(toErrno(error), null);
}
Expand Down
101 changes: 101 additions & 0 deletions packages/computerd/src/fuse/passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,107 @@ describe("withLocalPassthrough: directory listing", () => {
});
});

describe("withLocalPassthrough: readdir entry types", () => {
// libfuse takes d_type, and under use_ino the inode, from the stat given
// with each name. Without one every entry is DT_UNKNOWN, which readers
// that trust d_type skip: bun's hardlink install walking a cache under a
// local-only path linked nothing.
let root: string;

beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "computerd-passthrough-"));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});

const S_IFMT = 0o170000;
type EntryStat = { mode: number; ino: number };
// The listing as name -> stat, failing unless every name has one.
const list = (ops: FuseOps, path: string) => {
let out = { code: -1, names: [] as string[], byName: new Map<string, EntryStat>() };
ops.readdir(path, (code, names, stats = []) => {
expect(stats).toHaveLength(names.length);
out = { code, names, byName: new Map(names.map((name, i) => [name, stats[i] as EntryStat])) };
});
return out;
};
const typeOf = (stat: EntryStat | undefined): number | undefined =>
stat === undefined ? undefined : stat.mode & S_IFMT;

test("a local directory reports each entry's lstat type and inode", () => {
const { ops } = withLocalPassthrough(recordingOps().ops, {
root,
ignore: resolveMountIgnore(["**/.cache"], MOUNT),
mountPoint: MOUNT,
});
const dir = join(root, ".cache/bun/react@19");
mkdirSync(join(dir, "cjs"), { recursive: true });
writeFileSync(join(dir, "package.json"), "{}");
symlinkSync("package.json", join(dir, "link.json"));

const { code, names, byName } = list(ops, "/.cache/bun/react@19");
expect(code).toBe(0);
expect(typeOf(byName.get("cjs"))).toBe(constants.S_IFDIR);
expect(typeOf(byName.get("package.json"))).toBe(constants.S_IFREG);
expect(typeOf(byName.get("link.json"))).toBe(constants.S_IFLNK);
for (const name of names) {
expect(byName.get(name)?.ino, name).toBe(lstatSync(join(dir, name)).ino);
}
});

test("a synced directory keeps the VFS stats and adds local children's", () => {
const vfsStat = { mode: constants.S_IFREG | 0o644, ino: 42 };
const source = recordingOps();
const vfsOps = new Proxy(source.ops, {
get(target, property: string) {
if (property === "readdir") {
return (_path: string, cb: (code: number, names: string[], stats?: unknown[]) => void) =>
cb(0, ["src.ts"], [vfsStat]);
}
return Reflect.get(target, property);
},
});
const { ops } = withLocalPassthrough(vfsOps, {
root,
ignore: resolveMountIgnore(["**/node_modules"], MOUNT),
mountPoint: MOUNT,
});
mkdirSync(join(root, "app/node_modules"), { recursive: true });

const { code, byName } = list(ops, "/app");
expect(code).toBe(0);
expect(byName.get("src.ts")).toBe(vfsStat);
expect(typeOf(byName.get("node_modules"))).toBe(constants.S_IFDIR);
expect(byName.get("node_modules")?.ino).toBe(lstatSync(join(root, "app/node_modules")).ino);
});

test("a local-only child shadows a synced entry of the same name", () => {
// getattr routes the name to the local side, so the listing has to
// agree with it, including when stale synced content remains.
const source = recordingOps();
const vfsOps = new Proxy(source.ops, {
get(target, property: string) {
if (property === "readdir") {
return (_path: string, cb: (code: number, names: string[], stats?: unknown[]) => void) =>
cb(0, ["node_modules"], [{ mode: constants.S_IFREG | 0o644, ino: 7 }]);
}
return Reflect.get(target, property);
},
});
const { ops } = withLocalPassthrough(vfsOps, {
root,
ignore: resolveMountIgnore(["**/node_modules"], MOUNT),
mountPoint: MOUNT,
});
mkdirSync(join(root, "node_modules"), { recursive: true });

const { names, byName } = list(ops, "/");
expect(names).toEqual(["node_modules"]);
expect(typeOf(byName.get("node_modules"))).toBe(constants.S_IFDIR);
});
});

describe("withLocalPassthrough: symlinks", () => {
let root: string;

Expand Down
47 changes: 41 additions & 6 deletions packages/computerd/src/fuse/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,24 +232,44 @@ export function withLocalPassthrough(
// A VFS directory may still contain local-only children: the
// entries live on disk but the parent does not. Merge both
// sides so `ls` shows what a command inside the container sees.
ops.readdir(path, (code, names) => {
ops.readdir(path, (code, names, stats) => {
if (code !== 0) {
cb(code, names);
cb(code, names, stats);
return;
}
const extra = localChildren(path);
if (extra.length === 0) {
cb(0, names);
cb(0, names, stats);
return;
}
const merged = new Set([...(names ?? []), ...extra]);
cb(0, [...merged]);
if (stats === undefined || stats.length !== names.length) {
const merged = new Set([...(names ?? []), ...extra]);
cb(0, [...merged]);
return;
}
// A local-only child shadows a synced entry of the same name,
// as getattr does, so its stat is the one reported.
const merged = new Map<string, FuseStat>();
names.forEach((name, i) => {
const stat = stats[i];
if (stat !== undefined) merged.set(name, stat);
});
for (const [name, stat] of statLocalEntries(localPath(path), extra)) {
merged.set(name, stat);
}
cb(0, [...merged.keys()], [...merged.values()]);
});
return;
}
localOps += 1;
try {
cb(0, fs.readdirSync(localPath(path)));
const directory = localPath(path);
const entries = statLocalEntries(directory, fs.readdirSync(directory) as string[]);
cb(
0,
entries.map(([name]) => name),
entries.map(([, stat]) => stat),
);
} catch (error) {
cb(toErrno(error), []);
}
Expand Down Expand Up @@ -758,6 +778,21 @@ export function withLocalPassthrough(
return names.filter((name) => isLocal(parent === "" ? name : `${parent}/${name}`));
}

// lstat, as getattr does, so the type and inode match what a lookup of
// the same name returns. An entry removed between the listing and the
// stat is left out.
function statLocalEntries(directory: string, names: string[]): Array<[string, FuseStat]> {
const entries: Array<[string, FuseStat]> = [];
for (const name of names) {
try {
entries.push([name, statToFuse(fs.lstatSync(join(directory, name)))]);
} catch {
// Gone since the listing.
}
}
Comment on lines +787 to +792

@devin-ai-integration devin-ai-integration Bot Oct 19, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Readable local directories appear empty

For a readable local directory without search permission, statLocalEntries drops every name when child lstatSync returns EACCES. The listing appears empty even though directory reads can return those names.

Learn more

Reading a directory requires read permission, but looking up its children also requires search permission. The local listing obtains names using readdirSync, then this helper discards any child that cannot be stat'd. For a mode 0444 directory accessed by a nonprivileged daemon user, readdirSync succeeds but every child lstatSync fails with EACCES, so FUSE reports no children.

Example: A local-only /cache directory containing pkg has mode 0444. Its owner can enumerate pkg, but cannot look up /cache/pkg. The FUSE listing incorrectly returns [] rather than ['pkg'].

Recommended fix: Only omit a child when its stat fails because it disappeared (ENOENT/ENOTDIR); keep other names with a stat fallback that reports unknown type, or propagate a suitable error instead of silently hiding them. Ensure the merge path handles a missing stat without losing names.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

return entries;
}

function hasLocalContents(path: string): boolean {
try {
return fs.readdirSync(localPath(path)).length > 0;
Expand Down
Loading