Compare commits

...

2 Commits

Author SHA1 Message Date
Claude Bot
b200ec7f3e test: add regression test for #27095 (DT_UNKNOWN file skipping on NFS)
Verifies that all install backends (clonefile, hardlink, copyfile)
correctly install every file from packages with deeply nested directory
structures. This guards against silent file skipping when the directory
walker encounters unknown entry types.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-18 08:23:11 +00:00
Jarred Sumner
57c0a6d099 fix(install): handle DT_UNKNOWN entries on NFS/FUSE filesystems
On NFS, FUSE, and some bind mounts, getdents64 returns DT_UNKNOWN for
d_type instead of DT_DIR/DT_REG. The directory walker and all install
backends (hardlink, copyfile, clonefile) were silently skipping these
entries, causing `bun install` to produce incomplete node_modules.

Walker: add `resolve_unknown_entry_types` option that falls back to
fstatat() to resolve unknown entry types.

Hardlinker: handle .unknown by optimistically trying linkat, falling
back to makePath on EPERM/EISDIR (the entry was a directory).

FileCopier (isolated): handle .unknown by trying openat as a file,
falling back to makePath on EISDIR.

PackageInstall (clonefile): handle .unknown by trying clonefileat,
falling back to mkdirat on EPERM/EISDIR.

PackageInstall (copyfile): handle .unknown by trying openat as a file,
falling back to makePath on EISDIR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-18 08:14:05 +00:00
5 changed files with 164 additions and 15 deletions

View File

@@ -383,6 +383,7 @@ pub const PackageInstall = struct {
&[_]bun.OSPathSlice{},
&[_]bun.OSPathSlice{},
) catch |err| return Result.fail(err, .opening_cache_dir, @errorReturnTrace());
walker_.resolve_unknown_entry_types = true;
defer walker_.deinit();
const FileCopier = struct {
@@ -520,6 +521,7 @@ pub const PackageInstall = struct {
else
&[_]bun.OSPathSlice{},
) catch |err| bun.handleOom(err);
state.walker.resolve_unknown_entry_types = true;
if (!Environment.isWindows) {
state.subdir = destbase.makeOpenPath(bun.span(destpath), .{

View File

@@ -12,12 +12,16 @@ pub const FileCopier = struct {
return .{
.src_path = src_path,
.dest_subpath = dest_subpath,
.walker = try .walk(
src_dir,
bun.default_allocator,
&.{},
skip_dirnames,
),
.walker = walker: {
var w = try Walker.walk(
src_dir,
bun.default_allocator,
&.{},
skip_dirnames,
);
w.resolve_unknown_entry_types = true;
break :walker w;
},
};
}

View File

@@ -15,12 +15,16 @@ pub fn init(
.src_dir = folder_dir,
.src = src,
.dest = dest,
.walker = try .walk(
folder_dir,
bun.default_allocator,
&.{},
skip_dirnames,
),
.walker = walker: {
var w = try Walker.walk(
folder_dir,
bun.default_allocator,
&.{},
skip_dirnames,
);
w.resolve_unknown_entry_types = true;
break :walker w;
},
};
}

View File

@@ -6,6 +6,7 @@ skip_filenames: []const u64 = &[_]u64{},
skip_dirnames: []const u64 = &[_]u64{},
skip_all: []const u64 = &[_]u64{},
seed: u64 = 0,
resolve_unknown_entry_types: bool = false,
const NameBufferList = std.array_list.Managed(bun.OSPathChar);
@@ -38,7 +39,22 @@ pub fn next(self: *Walker) bun.sys.Maybe(?WalkerEntry) {
.err => |err| return .initErr(err),
.result => |res| {
if (res) |base| {
switch (base.kind) {
// Some filesystems (NFS, FUSE, bind mounts) don't provide
// d_type and return DT_UNKNOWN. Optionally resolve via
// fstatat so callers get accurate types for recursion.
// This only affects POSIX; Windows always provides types.
const kind: std.fs.Dir.Entry.Kind = if (comptime !Environment.isWindows)
(if (base.kind == .unknown and self.resolve_unknown_entry_types) brk: {
const dir_fd = top.iter.iter.dir;
break :brk switch (bun.sys.fstatat(dir_fd, base.name.sliceAssumeZ())) {
.result => |stat_buf| bun.sys.kindFromMode(stat_buf.mode),
.err => continue, // skip entries we can't stat
};
} else base.kind)
else
base.kind;
switch (kind) {
.directory => {
if (std.mem.indexOfScalar(
u64,
@@ -78,7 +94,7 @@ pub fn next(self: *Walker) bun.sys.Maybe(?WalkerEntry) {
const cur_len = self.name_buffer.items.len;
bun.handleOom(self.name_buffer.append(0));
if (base.kind == .directory) {
if (kind == .directory) {
const new_dir = switch (bun.openDirForIterationOSPath(top.iter.iter.dir, base.name.slice())) {
.result => |fd| fd,
.err => |err| return .initErr(err),
@@ -95,7 +111,7 @@ pub fn next(self: *Walker) bun.sys.Maybe(?WalkerEntry) {
.dir = top.iter.iter.dir,
.basename = self.name_buffer.items[dirname_len..cur_len :0],
.path = self.name_buffer.items[0..cur_len :0],
.kind = base.kind,
.kind = kind,
});
} else {
var item = self.stack.pop().?;

View File

@@ -0,0 +1,123 @@
import { file, spawn, write } from "bun";
import { afterAll, beforeAll, expect, test } from "bun:test";
import { existsSync } from "fs";
import { readdir } from "fs/promises";
import { VerdaccioRegistry, bunEnv, bunExe } from "harness";
import { join } from "path";
// Issue #27095: bun install silently skips files when linking packages from
// cache to node_modules on NFS/FUSE/bind-mount filesystems that return
// DT_UNKNOWN for d_type.
//
// The fix adds resolve_unknown_entry_types to the walker so it falls back to
// fstatat() for unknown entries. This test verifies that all backends
// (clonefile, hardlink, copyfile) correctly install every file from a package
// with a deeply nested directory structure.
const registry = new VerdaccioRegistry();
beforeAll(async () => {
await registry.start();
});
afterAll(() => {
registry.stop();
});
/** Recursively count all files and directories under `dir`. */
async function countEntriesRecursive(dir: string): Promise<number> {
let count = 0;
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
count++;
if (entry.isDirectory()) {
count += await countEntriesRecursive(join(dir, entry.name));
}
}
return count;
}
for (const backend of ["clonefile", "hardlink", "copyfile"]) {
test(`all files installed with backend: ${backend} (#27095)`, async () => {
const { packageJson, packageDir } = await registry.createTestDir({
bunfigOpts: { linker: "isolated" },
});
// Create a file dependency with a nested directory tree.
// This mimics what happens with packages like typescript that have
// many files in deeply nested directories - the exact scenario
// where DT_UNKNOWN would cause silent skipping.
const files: Record<string, string> = {
"package.json": JSON.stringify({ name: "nested-pkg", version: "1.0.0" }),
"index.js": "module.exports = 'root';",
"lib/a.js": "module.exports = 'a';",
"lib/b.js": "module.exports = 'b';",
"lib/types/a.d.ts": "export declare const a: string;",
"lib/types/b.d.ts": "export declare const b: string;",
"lib/types/nested/c.d.ts": "export declare const c: string;",
"lib/types/nested/d.d.ts": "export declare const d: string;",
"lib/types/nested/deep/e.d.ts": "export declare const e: string;",
};
// Write the nested package files
await Promise.all(
Object.entries(files).map(([path, content]) => write(join(packageDir, "nested-pkg", path), content)),
);
await write(
packageJson,
JSON.stringify({
name: "test-27095",
dependencies: {
"nested-pkg": "file:./nested-pkg",
},
}),
);
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "install", "--backend", backend],
cwd: packageDir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const err = await stderr.text();
const out = await stdout.text();
expect(err).not.toContain("error");
// Verify every single file was installed
const installedBase = join(
packageDir,
"node_modules",
".bun",
"nested-pkg@file+nested-pkg",
"node_modules",
"nested-pkg",
);
// Check each expected file exists and has correct content
for (const [path, expectedContent] of Object.entries(files)) {
const fullPath = join(installedBase, path);
expect(existsSync(fullPath)).toBe(true);
if (path.endsWith(".json")) {
expect(await file(fullPath).json()).toEqual(JSON.parse(expectedContent));
} else {
expect(await file(fullPath).text()).toBe(expectedContent);
}
}
// Verify the nested directories exist
expect(existsSync(join(installedBase, "lib"))).toBe(true);
expect(existsSync(join(installedBase, "lib", "types"))).toBe(true);
expect(existsSync(join(installedBase, "lib", "types", "nested"))).toBe(true);
expect(existsSync(join(installedBase, "lib", "types", "nested", "deep"))).toBe(true);
// Verify total count matches (9 files + 4 directories = 13 entries)
const totalEntries = await countEntriesRecursive(installedBase);
expect(totalEntries).toBe(13);
expect(await exited).toBe(0);
});
}