Files
bun.sh/src/install/bin.zig
Jarred Sumner 528620e9ae Add postinstall optimizer with native binlink support and script skipping (#24283)
## Summary

This PR introduces a new postinstall optimization system that
significantly reduces the need to run lifecycle scripts for certain
packages by intelligently handling their requirements at install time.

## Key Features

### 1. Native Binlink Optimization

When packages like `esbuild` ship platform-specific binaries as optional
dependencies, we now:
- Detect the native binlink pattern (enabled by default for `esbuild`)
- Find the matching platform-specific dependency based on target CPU/OS
- Link binaries directly from the platform-specific package (e.g.,
`@esbuild/darwin-arm64`)
- Fall back gracefully if the platform-specific package isn't found

**Result**: No postinstall scripts needed for esbuild and similar
packages.

### 2. Lifecycle Script Skipping

For packages like `sharp` that run heavy postinstall scripts:
- Skip lifecycle scripts entirely (enabled by default for `sharp`)
- Prevents downloading large binaries or compiling native code
unnecessarily
- Reduces install time and potential failures in restricted environments

## Configuration

Both features can be configured via `package.json`:

```json
{
  "nativeDependencies": ["esbuild", "my-custom-package"],
  "ignoreScripts": ["sharp", "another-package"]
}
```

Set to empty arrays to disable defaults:
```json
{
  "nativeDependencies": [],
  "ignoreScripts": []
}
```

Environment variable overrides:
- `BUN_FEATURE_FLAG_DISABLE_NATIVE_DEPENDENCY_LINKER=1` - disable native
binlink
- `BUN_FEATURE_FLAG_DISABLE_IGNORE_SCRIPTS=1` - disable script ignoring

## Implementation Details

### Core Components

- **`postinstall_optimizer.zig`**: New file containing the optimizer
logic
- `PostinstallOptimizer` enum with `native_binlink` and `ignore`
variants
  - `List` type to track optimization strategies per package hash
  - Defaults for `esbuild` (native binlink) and `sharp` (ignore)
  
- **`Bin.Linker` changes**: Extended to support separate target paths
  - `target_node_modules_path`: Where to find the actual binary
  - `target_package_name`: Name of the package containing the binary
  - Fallback logic when native binlink optimization fails

### Modified Components

- **PackageInstaller.zig**: Checks optimizer before:
  - Enqueueing lifecycle scripts
  - Linking binaries (with platform-specific package resolution)
  
- **isolated_install/Installer.zig**: Similar checks for isolated linker
mode
  - `maybeReplaceNodeModulesPath()` resolves platform-specific packages
  - Retry logic without optimization on failure

- **Lockfile**: Added `postinstall_optimizer` field to persist
configuration

## Changes Included

- Updated `esbuild` from 0.21.5 to 0.25.11 (testing with latest)
- VS Code launch config updates for debugging install with new flags
- New feature flags in `env_var.zig`

## Test Plan

- [x] Existing install tests pass
- [ ] Test esbuild install without postinstall scripts running
- [ ] Test sharp install with scripts skipped
- [ ] Test custom package.json configuration
- [ ] Test fallback when platform-specific package not found
- [ ] Test feature flag overrides

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Native binlink optimization: installs platform-specific binaries when
available, with a safe retry fallback and verbose logging option.
* Per-package postinstall controls to optionally skip lifecycle scripts.
* New feature flags to disable native binlink optimization and to
disable lifecycle-script ignoring.

* **Tests**
* End-to-end tests and test packages added to validate native binlink
behavior across install scenarios and linker modes.

* **Documentation**
  * Bench README and sample app migrated to a Next.js-based setup.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
2025-11-03 20:36:22 -08:00

1107 lines
45 KiB
Zig

/// Normalized `bin` field in [package.json](https://docs.npmjs.com/cli/v8/configuring-npm/package-json#bin)
/// Can be a:
/// - file path (relative to the package root)
/// - directory (relative to the package root)
/// - map where keys are names of the binaries and values are file paths to the binaries
pub const Bin = extern struct {
tag: Tag = Tag.none,
_padding_tag: [3]u8 = .{0} ** 3,
// Largest member must be zero initialized
value: Value = Value{ .map = ExternalStringList{} },
pub fn count(this: *const Bin, buf: []const u8, extern_strings: []const ExternalString, comptime StringBuilder: type, builder: StringBuilder) u32 {
switch (this.tag) {
.file => builder.count(this.value.file.slice(buf)),
.named_file => {
builder.count(this.value.named_file[0].slice(buf));
builder.count(this.value.named_file[1].slice(buf));
},
.dir => builder.count(this.value.dir.slice(buf)),
.map => {
const list = this.value.map.get(extern_strings);
for (list) |*extern_string| {
builder.count(extern_string.slice(buf));
}
return @as(u32, @truncate(list.len));
},
else => {},
}
return 0;
}
pub fn eql(
l: *const Bin,
r: *const Bin,
l_buf: string,
l_extern_strings: []const ExternalString,
r_buf: string,
r_extern_strings: []const ExternalString,
) bool {
if (l.tag != r.tag) return false;
return switch (l.tag) {
.none => true,
.file => l.value.file.eql(r.value.file, l_buf, r_buf),
.dir => l.value.dir.eql(r.value.dir, l_buf, r_buf),
.named_file => l.value.named_file[0].eql(r.value.named_file[0], l_buf, r_buf) and
l.value.named_file[1].eql(r.value.named_file[1], l_buf, r_buf),
.map => {
const l_list = l.value.map.get(l_extern_strings);
const r_list = r.value.map.get(r_extern_strings);
if (l_list.len != r_list.len) return false;
// assuming these maps are small without duplicate keys
var i: usize = 0;
outer: while (i < l_list.len) : (i += 2) {
var j: usize = 0;
while (j < r_list.len) : (j += 2) {
if (l_list[i].hash == r_list[j].hash) {
if (l_list[i + 1].hash != r_list[j + 1].hash) {
return false;
}
continue :outer;
}
}
// not found
return false;
}
return true;
},
};
}
pub fn clone(this: *const Bin, buf: []const u8, prev_external_strings: []const ExternalString, all_extern_strings: []ExternalString, extern_strings_slice: []ExternalString, comptime StringBuilder: type, builder: StringBuilder) Bin {
switch (this.tag) {
.none => {
return Bin{
.tag = .none,
.value = Value.init(.{ .none = {} }),
};
},
.file => {
return Bin{
.tag = .file,
.value = Value.init(.{ .file = builder.append(String, this.value.file.slice(buf)) }),
};
},
.named_file => {
return Bin{
.tag = .named_file,
.value = Value.init(
.{
.named_file = [2]String{
builder.append(String, this.value.named_file[0].slice(buf)),
builder.append(String, this.value.named_file[1].slice(buf)),
},
},
),
};
},
.dir => {
return Bin{
.tag = .dir,
.value = Value.init(.{ .dir = builder.append(String, this.value.dir.slice(buf)) }),
};
},
.map => {
for (this.value.map.get(prev_external_strings), 0..) |extern_string, i| {
extern_strings_slice[i] = builder.append(ExternalString, extern_string.slice(buf));
}
return Bin{
.tag = .map,
.value = Value.init(.{ .map = ExternalStringList.init(all_extern_strings, extern_strings_slice) }),
};
},
}
unreachable;
}
/// Used for packages read from text lockfile.
pub fn parseAppend(
allocator: std.mem.Allocator,
bin_expr: JSON.Expr,
buf: *String.Buf,
extern_strings: *std.ArrayListUnmanaged(ExternalString),
) OOM!Bin {
switch (bin_expr.data) {
.e_object => |obj| {
switch (obj.properties.len) {
0 => {},
1 => {
const bin_name = obj.properties.ptr[0].key.?.asString(allocator) orelse return .{};
const value = obj.properties.ptr[0].value.?.asString(allocator) orelse return .{};
return .{
.tag = .named_file,
.value = .{
.named_file = .{
try buf.append(bin_name),
try buf.append(value),
},
},
};
},
else => {
const current_len = extern_strings.items.len;
const num_props: usize = obj.properties.len * 2;
try extern_strings.ensureTotalCapacityPrecise(
allocator,
current_len + num_props,
);
var new = extern_strings.items.ptr[current_len .. current_len + num_props];
extern_strings.items.len += num_props;
var i: usize = 0;
for (obj.properties.slice()) |bin_prop| {
const key = bin_prop.key.?;
const value = bin_prop.value.?;
const key_str = key.asString(allocator) orelse return .{};
const value_str = value.asString(allocator) orelse return .{};
new[i] = try buf.appendExternal(key_str);
i += 1;
new[i] = try buf.appendExternal(value_str);
i += 1;
}
if (comptime Environment.allow_assert) {
bun.assert(i == new.len);
}
return .{
.tag = .map,
.value = .{
.map = ExternalStringList.init(extern_strings.items, new),
},
};
},
}
},
.e_string => |str| {
if (str.data.len > 0) {
return .{
.tag = .file,
.value = .{
.file = try buf.append(str.data),
},
};
}
},
else => {},
}
return .{};
}
pub fn parseAppendFromDirectories(allocator: std.mem.Allocator, bin_expr: JSON.Expr, buf: *String.Buf) OOM!Bin {
if (bin_expr.asString(allocator)) |bin_str| {
return .{
.tag = .dir,
.value = .{
.dir = try buf.append(bin_str),
},
};
}
return .{};
}
pub fn toJson(
this: *const Bin,
comptime style: enum { single_line, multi_line },
indent: if (style == .multi_line) *u32 else void,
buf: string,
extern_strings: []const ExternalString,
writer: anytype,
writeIndent: *const fn (anytype, *u32) @TypeOf(writer).Error!void,
) @TypeOf(writer).Error!void {
bun.debugAssert(this.tag != .none);
if (comptime style == .single_line) {
switch (this.tag) {
.none => {},
.file => {
try writer.print("{}", .{this.value.file.fmtJson(buf, .{})});
},
.named_file => {
try writer.writeByte('{');
try writer.print(" {}: {} ", .{
this.value.named_file[0].fmtJson(buf, .{}),
this.value.named_file[1].fmtJson(buf, .{}),
});
try writer.writeByte('}');
},
.dir => {
try writer.print("{}", .{this.value.dir.fmtJson(buf, .{})});
},
.map => {
try writer.writeByte('{');
const list = this.value.map.get(extern_strings);
var first = true;
var i: usize = 0;
while (i < list.len) : (i += 2) {
if (!first) {
try writer.writeByte(',');
}
first = false;
try writer.print(" {}: {}", .{
list[i].value.fmtJson(buf, .{}),
list[i + 1].value.fmtJson(buf, .{}),
});
}
try writer.writeAll(" }");
},
}
return;
}
switch (this.tag) {
.none => {},
.file => {
try writer.print("{}", .{this.value.file.fmtJson(buf, .{})});
},
.named_file => {
try writer.writeAll("{\n");
indent.* += 1;
try writeIndent(writer, indent);
try writer.print("{}: {},\n", .{
this.value.named_file[0].fmtJson(buf, .{}),
this.value.named_file[1].fmtJson(buf, .{}),
});
indent.* -= 1;
try writeIndent(writer, indent);
try writer.writeByte('}');
},
.dir => {
try writer.print("{}", .{this.value.dir.fmtJson(buf, .{})});
},
.map => {
try writer.writeByte('{');
indent.* += 1;
const list = this.value.map.get(extern_strings);
var any = false;
var i: usize = 0;
while (i < list.len) : (i += 2) {
if (!any) {
any = true;
try writer.writeByte('\n');
}
try writeIndent(writer, indent);
try writer.print("{}: {},\n", .{
list[i].value.fmtJson(buf, .{}),
list[i + 1].value.fmtJson(buf, .{}),
});
}
if (!any) {
try writer.writeByte('}');
indent.* -= 1;
return;
}
indent.* -= 1;
try writeIndent(writer, indent);
try writer.writeByte('}');
},
}
}
pub fn init() Bin {
return bun.serializable(Bin{ .tag = .none, .value = Value.init(.{ .none = {} }) });
}
pub const Value = extern union {
/// no "bin", or empty "bin"
none: void,
/// "bin" is a string
/// ```
/// "bin": "./bin/foo",
/// ```
file: String,
// Single-entry map
///```
/// "bin": {
/// "babel": "./cli.js",
/// }
///```
named_file: [2]String,
/// "bin" is a directory
///```
/// "dirs": {
/// "bin": "./bin",
/// }
///```
dir: String,
// "bin" is a map
///```
/// "bin": {
/// "babel": "./cli.js",
/// "babel-cli": "./cli.js",
/// }
///```
map: ExternalStringList,
/// To avoid undefined memory between union values, we must zero initialize the union first.
pub fn init(field: anytype) Value {
return bun.serializableInto(Value, field);
}
};
pub const Tag = enum(u8) {
/// no bin field
none = 0,
/// "bin" is a string
/// ```
/// "bin": "./bin/foo",
/// ```
file = 1,
// Single-entry map
///```
/// "bin": {
/// "babel": "./cli.js",
/// }
///```
named_file = 2,
/// "bin" is a directory
///```
/// "dirs": {
/// "bin": "./bin",
/// }
///```
dir = 3,
// "bin" is a map of more than one
///```
/// "bin": {
/// "babel": "./cli.js",
/// "babel-cli": "./cli.js",
/// "webpack-dev-server": "./cli.js",
/// }
///```
map = 4,
};
pub const NamesIterator = struct {
bin: Bin,
i: usize = 0,
done: bool = false,
dir_iterator: ?std.fs.Dir.Iterator = null,
package_name: String,
destination_node_modules: std.fs.Dir = bun.invalid_fd.stdDir(),
buf: bun.PathBuffer = undefined,
string_buffer: []const u8,
extern_string_buf: []const ExternalString,
fn nextInDir(this: *NamesIterator) !?[]const u8 {
if (this.done) return null;
if (this.dir_iterator == null) {
var target = this.bin.value.dir.slice(this.string_buffer);
if (strings.hasPrefixComptime(target, "./") or strings.hasPrefixComptime(target, ".\\")) {
target = target[2..];
}
var parts = [_][]const u8{ this.package_name.slice(this.string_buffer), target };
const dir = this.destination_node_modules;
const joined = path.joinStringBuf(&this.buf, &parts, .auto);
this.buf[joined.len] = 0;
const joined_: [:0]u8 = this.buf[0..joined.len :0];
var child_dir = try bun.openDir(dir, joined_);
this.dir_iterator = child_dir.iterate();
}
var iter = &this.dir_iterator.?;
if (iter.next() catch null) |entry| {
this.i += 1;
return entry.name;
} else {
this.done = true;
this.dir_iterator.?.dir.close();
this.dir_iterator = null;
return null;
}
}
/// next filename, e.g. "babel" instead of "cli.js"
pub fn next(this: *NamesIterator) !?[]const u8 {
switch (this.bin.tag) {
.file => {
if (this.i > 0) return null;
this.i += 1;
this.done = true;
const base = std.fs.path.basename(this.package_name.slice(this.string_buffer));
if (strings.hasPrefixComptime(base, "./") or strings.hasPrefixComptime(base, ".\\"))
return strings.copy(&this.buf, base[2..]);
return strings.copy(&this.buf, base);
},
.named_file => {
if (this.i > 0) return null;
this.i += 1;
this.done = true;
const base = std.fs.path.basename(this.bin.value.named_file[0].slice(this.string_buffer));
if (strings.hasPrefixComptime(base, "./") or strings.hasPrefixComptime(base, ".\\"))
return strings.copy(&this.buf, base[2..]);
return strings.copy(&this.buf, base);
},
.dir => return try this.nextInDir(),
.map => {
if (this.i >= this.bin.value.map.len) return null;
const index = this.i;
this.i += 2;
this.done = this.i >= this.bin.value.map.len;
const current_string = this.bin.value.map.get(
this.extern_string_buf,
)[index];
const base = std.fs.path.basename(
current_string.slice(
this.string_buffer,
),
);
if (strings.hasPrefixComptime(base, "./") or strings.hasPrefixComptime(base, ".\\"))
return strings.copy(&this.buf, base[2..]);
return strings.copy(&this.buf, base);
},
else => return null,
}
}
};
pub const PriorityQueueContext = struct {
dependencies: *const std.ArrayListUnmanaged(Dependency),
string_buf: *const std.ArrayListUnmanaged(u8),
pub fn lessThan(this: PriorityQueueContext, a: Install.DependencyID, b: Install.DependencyID) std.math.Order {
const deps = this.dependencies.items;
const buf = this.string_buf.items;
const a_name = deps[a].name.slice(buf);
const b_name = deps[b].name.slice(buf);
return strings.order(a_name, b_name);
}
};
pub const PriorityQueue = std.PriorityQueue(Install.DependencyID, PriorityQueueContext, PriorityQueueContext.lessThan);
// https://github.com/npm/npm-normalize-package-bin/blob/574e6d7cd21b2f3dee28a216ec2053c2551f7af9/lib/index.js#L38
pub fn normalizedBinName(name: []const u8) []const u8 {
if (std.mem.lastIndexOfAny(u8, name, "/\\:")) |i| {
return name[i + 1 ..];
}
return name;
}
pub const Linker = struct {
bin: Bin,
/// Usually will be the same as `node_modules_path`.
/// Used to support native bin linking.
target_node_modules_path: *bun.AbsPath(.{}),
/// Usually will be the same as `package_name`.
/// Used to support native bin linking.
target_package_name: strings.StringOrTinyString,
// Hash map of seen destination paths for this `node_modules/.bin` folder. PackageInstaller will reset it before
// linking each tree.
seen: ?*bun.StringHashMap(void),
node_modules_path: *bun.AbsPath(.{}),
/// Used for generating relative paths
package_name: strings.StringOrTinyString,
global_bin_path: stringZ = "",
string_buf: []const u8,
extern_string_buf: []const ExternalString,
abs_target_buf: []u8,
abs_dest_buf: []u8,
rel_buf: []u8,
err: ?anyerror = null,
skipped_due_to_missing_bin: bool = false,
pub var umask: bun.Mode = 0;
var has_set_umask = false;
pub fn ensureUmask() void {
if (!has_set_umask) {
has_set_umask = true;
umask = bun.sys.umask(0);
}
}
fn unlinkBinOrShim(abs_dest: [:0]const u8) void {
if (comptime !Environment.isWindows) {
_ = bun.sys.unlink(abs_dest);
return;
}
var dest_buf: bun.WPathBuffer = undefined;
const abs_dest_w = strings.convertUTF8toUTF16InBuffer(&dest_buf, abs_dest);
@memcpy(dest_buf[abs_dest_w.len..][0..".bunx\x00".len], comptime strings.literal(u16, ".bunx\x00"));
const abs_bunx_file: [:0]const u16 = dest_buf[0 .. abs_dest_w.len + ".bunx".len :0];
_ = bun.sys.unlinkW(abs_bunx_file);
@memcpy(dest_buf[abs_dest_w.len..][0..".exe\x00".len], comptime strings.literal(u16, ".exe\x00"));
const abs_exe_file: [:0]const u16 = dest_buf[0 .. abs_dest_w.len + ".exe".len :0];
_ = bun.sys.unlinkW(abs_exe_file);
}
fn linkBinOrCreateShim(this: *Linker, abs_target: [:0]const u8, abs_dest: [:0]const u8, global: bool) void {
bun.assertWithLocation(std.fs.path.isAbsolute(abs_target), @src());
bun.assertWithLocation(std.fs.path.isAbsolute(abs_dest), @src());
bun.assertWithLocation(abs_target[abs_target.len - 1] != std.fs.path.sep, @src());
bun.assertWithLocation(abs_dest[abs_dest.len - 1] != std.fs.path.sep, @src());
if (this.seen) |seen| {
// Skip seen destinations for this tree
// https://github.com/npm/cli/blob/22731831e22011e32fa0ca12178e242c2ee2b33d/node_modules/bin-links/lib/link-gently.js#L30
const entry = bun.handleOom(seen.getOrPut(abs_dest));
if (entry.found_existing) {
return;
}
entry.key_ptr.* = bun.handleOom(seen.allocator.dupe(u8, abs_dest));
}
// Skip if the target does not exist. This is important because placing a dangling
// shim in path might break a postinstall
if (!bun.sys.exists(abs_target)) {
this.skipped_due_to_missing_bin = true;
return;
}
bun.analytics.Features.binlinks += 1;
if (comptime !Environment.isWindows)
this.createSymlink(abs_target, abs_dest, global)
else {
const target = bun.sys.openat(.cwd(), abs_target, bun.O.RDONLY, 0).unwrap() catch |err| {
if (err != error.EISDIR) {
// ignore directories, creating a shim for one won't do anything
this.err = err;
}
return;
};
defer target.close();
this.createWindowsShim(target, abs_target, abs_dest, global);
}
if (this.err != null) {
// cleanup on error just in case
unlinkBinOrShim(abs_dest);
return;
}
if (comptime !Environment.isWindows) {
tryNormalizeShebang(abs_target);
}
}
fn tryNormalizeShebang(abs_target: [:0]const u8) void {
var shebang_buf: [2048]u8 = undefined;
// any error here is ignored
const chunk = brk: {
const bin_for_reading = bun.sys.File.openat(.cwd(), abs_target, bun.O.RDONLY, 0).unwrap() catch return;
defer bin_for_reading.close();
const read = bin_for_reading.readAll(&shebang_buf).unwrap() catch return;
break :brk shebang_buf[0..read];
};
// 123 4 5
// #!a\r\n
if (chunk.len < 5 or chunk[0] != '#' or chunk[1] != '!') return;
const newline = strings.indexOfChar(chunk, '\n') orelse return;
const chunk_without_newline = chunk[0..newline];
if (!(chunk_without_newline.len > 0 and chunk_without_newline[chunk_without_newline.len - 1] == '\r')) {
// Nothing to do!
return;
}
log("Normalizing shebang for {s}", .{abs_target});
// We have to do an atomic replace here, use a randomly generated
// filename in the same folder, read the entire original file
// contents using bun.sys.File.readFrom, then write the temporary file, then
// overwite the old one with the new one via bun.sys.renameat. And
// always unlink the old one. If it fails for any reason then exit
// early.
var tmpname_buf: [1024]u8 = undefined;
const tmpname = bun.fs.FileSystem.tmpname(std.fs.path.basename(abs_target), &tmpname_buf, bun.hash(chunk_without_newline)) catch return;
const dir_path = std.fs.path.dirname(abs_target) orelse return;
const content: []const u8, const content_to_free: []const u8 = brk: {
if (chunk.len >= shebang_buf.len) {
// Partial read. Need to read the rest of the file.
const original_contents = switch (bun.sys.File.readFrom(bun.FD.cwd(), abs_target, bun.default_allocator)) {
.result => |contents| contents,
.err => return,
};
break :brk .{ original_contents, original_contents };
}
break :brk .{ chunk, "" };
};
defer bun.default_allocator.free(content_to_free);
// Get original file permissions to preserve them (including setuid/setgid/sticky bits)
const original_stat = bun.sys.fstatat(.cwd(), abs_target).unwrap() catch return;
const original_mode = @as(bun.Mode, @intCast(original_stat.mode));
// Create temporary file path
var tmppath_buf: [bun.MAX_PATH_BYTES]u8 = undefined;
const tmppath = bun.path.joinAbsStringBufZ(dir_path, &tmppath_buf, &.{tmpname}, .auto);
var needs_unlink = true;
defer {
if (needs_unlink) _ = bun.sys.unlinkat(.cwd(), tmppath);
}
// Write to temporary file with corrected content
{
const tmpfile = bun.sys.File.openat(.cwd(), tmppath, bun.O.WRONLY | bun.O.CREAT | bun.O.TRUNC, original_mode).unwrap() catch return;
defer tmpfile.close();
// Write the corrected shebang (without \r)
tmpfile.writeAll(chunk_without_newline[0 .. chunk_without_newline.len - 1]).unwrap() catch return;
tmpfile.writeAll("\n").unwrap() catch return;
// Write the rest of the file (after the newline)
if (content.len > newline + 1) {
tmpfile.writeAll(content[newline + 1 ..]).unwrap() catch return;
}
// Reapply original permissions (umask was applied during openat, so we need to restore)
_ = bun.sys.fchmodat(.cwd(), tmppath, @as(bun.Mode, @intCast(original_stat.mode & 0o777)), 0).unwrap() catch return;
}
// Atomic replace: rename temp file to original
switch (bun.sys.renameat(.cwd(), tmppath, .cwd(), abs_target)) {
.result => {
needs_unlink = false;
},
.err => {},
}
}
fn createWindowsShim(this: *Linker, target: bun.FileDescriptor, abs_target: [:0]const u8, abs_dest: [:0]const u8, global: bool) void {
const WinBinLinkingShim = @import("./windows-shim/BinLinkingShim.zig");
var shim_buf: [65536]u8 = undefined;
var read_in_buf: [WinBinLinkingShim.Shebang.max_shebang_input_length]u8 = undefined;
var dest_buf: bun.WPathBuffer = undefined;
var target_buf: bun.WPathBuffer = undefined;
const abs_dest_w = strings.convertUTF8toUTF16InBuffer(&dest_buf, abs_dest);
@memcpy(dest_buf[abs_dest_w.len..][0..".bunx\x00".len], comptime strings.literal(u16, ".bunx\x00"));
const abs_bunx_file: [:0]const u16 = dest_buf[0 .. abs_dest_w.len + ".bunx".len :0];
const bunx_file = bun.sys.File.openatOSPath(bun.invalid_fd, abs_bunx_file, bun.O.WRONLY | bun.O.CREAT | bun.O.TRUNC, 0o664).unwrap() catch |err| bunx_file: {
if (err != error.ENOENT or global) {
this.err = err;
return;
}
const node_modules_path_save = this.node_modules_path.save();
this.node_modules_path.append(".bin");
bun.makePath(std.fs.cwd(), this.node_modules_path.slice()) catch {};
node_modules_path_save.restore();
break :bunx_file bun.sys.File.openatOSPath(bun.invalid_fd, abs_bunx_file, bun.O.WRONLY | bun.O.CREAT | bun.O.TRUNC, 0o664).unwrap() catch |real_err| {
this.err = real_err;
return;
};
};
defer bunx_file.close();
const rel_target = path.relativeBufZ(this.rel_buf, path.dirname(abs_dest, .auto), abs_target);
bun.assertWithLocation(strings.hasPrefixComptime(rel_target, "..\\"), @src());
const rel_target_w = strings.toWPathNormalized(&target_buf, rel_target["..\\".len..]);
const shebang = shebang: {
const first_content_chunk = contents: {
const reader = target.stdFile().reader();
const read = reader.read(&read_in_buf) catch break :contents null;
if (read == 0) break :contents null;
break :contents read_in_buf[0..read];
};
if (first_content_chunk) |chunk| {
break :shebang WinBinLinkingShim.Shebang.parse(chunk, rel_target_w) catch {
this.err = error.InvalidBinCount;
return;
};
} else {
break :shebang WinBinLinkingShim.Shebang.parseFromBinPath(rel_target_w);
}
};
const shim = WinBinLinkingShim{
.bin_path = rel_target_w,
.shebang = shebang,
};
const len = shim.encodedLength();
if (len > shim_buf.len) {
this.err = error.InvalidBinContent;
return;
}
const metadata = shim_buf[0..len];
shim.encodeInto(metadata) catch {
this.err = error.InvalidBinContent;
return;
};
bunx_file.writer().writeAll(metadata) catch |err| {
this.err = err;
return;
};
@memcpy(dest_buf[abs_dest_w.len..][0..".exe\x00".len], comptime strings.literal(u16, ".exe\x00"));
const abs_exe_file: [:0]const u16 = dest_buf[0 .. abs_dest_w.len + ".exe".len :0];
bun.sys.File.writeFile(bun.invalid_fd, abs_exe_file, WinBinLinkingShim.embedded_executable_data).unwrap() catch |err| {
if (err == error.EBUSY) {
// exe is most likely running. bunx file has already been updated, ignore error
return;
}
this.err = err;
return;
};
}
fn createSymlink(this: *Linker, abs_target: [:0]const u8, abs_dest: [:0]const u8, global: bool) void {
defer {
if (this.err == null) {
_ = bun.sys.chmod(abs_target, umask | 0o777);
}
}
const abs_dest_dir = path.dirname(abs_dest, .auto);
const rel_target = path.relativeBufZ(this.rel_buf, abs_dest_dir, abs_target);
bun.assertWithLocation(strings.hasPrefixComptime(rel_target, ".."), @src());
switch (bun.sys.symlinkRunningExecutable(rel_target, abs_dest)) {
.err => |err| {
if (err.getErrno() != .EXIST and err.getErrno() != .NOENT) {
this.err = err.toZigErr();
return;
}
// ENOENT means `.bin` hasn't been created yet. Should only happen if this isn't global
if (err.getErrno() == .NOENT) {
if (global) {
this.err = err.toZigErr();
return;
}
const node_modules_path_save = this.node_modules_path.save();
this.node_modules_path.append(".bin");
bun.makePath(std.fs.cwd(), this.node_modules_path.slice()) catch {};
node_modules_path_save.restore();
switch (bun.sys.symlinkRunningExecutable(rel_target, abs_dest)) {
.err => |real_error| {
// It was just created, no need to delete destination and symlink again
this.err = real_error.toZigErr();
return;
},
.result => return,
}
bun.sys.symlinkRunningExecutable(rel_target, abs_dest).unwrap() catch |real_err| {
this.err = real_err;
};
return;
}
// beyond this error can only be `.EXIST`
bun.assertWithLocation(err.getErrno() == .EXIST, @src());
},
.result => return,
}
// delete and try again
std.fs.deleteTreeAbsolute(abs_dest) catch {};
bun.sys.symlinkRunningExecutable(rel_target, abs_dest).unwrap() catch |err| {
this.err = err;
};
}
/// uses `this.abs_target_buf`
pub fn buildTargetPackageDir(this: *const Linker) []const u8 {
const dest_dir_without_trailing_slash = strings.withoutTrailingSlash(this.target_node_modules_path.slice());
var remain = this.abs_target_buf;
@memcpy(remain[0..dest_dir_without_trailing_slash.len], dest_dir_without_trailing_slash);
remain = remain[dest_dir_without_trailing_slash.len..];
remain[0] = std.fs.path.sep;
remain = remain[1..];
const package_name = this.target_package_name.slice();
@memcpy(remain[0..package_name.len], package_name);
remain = remain[package_name.len..];
remain[0] = std.fs.path.sep;
remain = remain[1..];
return this.abs_target_buf[0 .. @intFromPtr(remain.ptr) - @intFromPtr(this.abs_target_buf.ptr)];
}
pub fn buildDestinationDir(this: *const Linker, global: bool) []u8 {
const dest_dir_without_trailing_slash = strings.withoutTrailingSlash(this.node_modules_path.slice());
var remain = this.abs_dest_buf;
if (global) {
const global_bin_path_without_trailing_slash = strings.withoutTrailingSlash(this.global_bin_path);
@memcpy(remain[0..global_bin_path_without_trailing_slash.len], global_bin_path_without_trailing_slash);
remain = remain[global_bin_path_without_trailing_slash.len..];
remain[0] = std.fs.path.sep;
remain = remain[1..];
} else {
@memcpy(remain[0..dest_dir_without_trailing_slash.len], dest_dir_without_trailing_slash);
remain = remain[dest_dir_without_trailing_slash.len..];
@memcpy(remain[0.."/.bin/".len], std.fs.path.sep_str ++ ".bin" ++ std.fs.path.sep_str);
remain = remain["/.bin/".len..];
}
return remain;
}
// target: what the symlink points to
// destination: where the symlink exists on disk
pub fn link(this: *Linker, global: bool) void {
const package_dir = this.buildTargetPackageDir();
var abs_dest_buf_remain = this.buildDestinationDir(global);
bun.assertWithLocation(this.bin.tag != .none, @src());
switch (this.bin.tag) {
.none => {},
.file => {
const target = this.bin.value.file.slice(this.string_buf);
if (target.len == 0) return;
// for normalizing `target`
const abs_target = path.joinAbsStringZ(package_dir, &.{target}, .auto);
const unscoped_package_name = Dependency.unscopedPackageName(this.package_name.slice());
@memcpy(abs_dest_buf_remain[0..unscoped_package_name.len], unscoped_package_name);
abs_dest_buf_remain = abs_dest_buf_remain[unscoped_package_name.len..];
abs_dest_buf_remain[0] = 0;
const abs_dest_len = @intFromPtr(abs_dest_buf_remain.ptr) - @intFromPtr(this.abs_dest_buf.ptr);
const abs_dest: [:0]const u8 = this.abs_dest_buf[0..abs_dest_len :0];
this.linkBinOrCreateShim(abs_target, abs_dest, global);
},
.named_file => {
const name = this.bin.value.named_file[0].slice(this.string_buf);
const normalized_name = normalizedBinName(name);
const target = this.bin.value.named_file[1].slice(this.string_buf);
if (normalized_name.len == 0 or target.len == 0) return;
// for normalizing `target`
const abs_target = path.joinAbsStringZ(package_dir, &.{target}, .auto);
@memcpy(abs_dest_buf_remain[0..normalized_name.len], normalized_name);
abs_dest_buf_remain = abs_dest_buf_remain[normalized_name.len..];
abs_dest_buf_remain[0] = 0;
const abs_dest_len = @intFromPtr(abs_dest_buf_remain.ptr) - @intFromPtr(this.abs_dest_buf.ptr);
const abs_dest: [:0]const u8 = this.abs_dest_buf[0..abs_dest_len :0];
this.linkBinOrCreateShim(abs_target, abs_dest, global);
},
.map => {
var i = this.bin.value.map.begin();
const end = this.bin.value.map.end();
const abs_dest_dir_end = abs_dest_buf_remain;
while (i < end) : (i += 2) {
const bin_dest = this.extern_string_buf[i].slice(this.string_buf);
const normalized_bin_dest = normalizedBinName(bin_dest);
const bin_target = this.extern_string_buf[i + 1].slice(this.string_buf);
if (bin_target.len == 0 or normalized_bin_dest.len == 0) continue;
const abs_target = path.joinAbsStringZ(package_dir, &.{bin_target}, .auto);
abs_dest_buf_remain = abs_dest_dir_end;
@memcpy(abs_dest_buf_remain[0..normalized_bin_dest.len], normalized_bin_dest);
abs_dest_buf_remain = abs_dest_buf_remain[normalized_bin_dest.len..];
abs_dest_buf_remain[0] = 0;
const abs_dest_len = @intFromPtr(abs_dest_buf_remain.ptr) - @intFromPtr(this.abs_dest_buf.ptr);
const abs_dest: [:0]const u8 = this.abs_dest_buf[0..abs_dest_len :0];
this.linkBinOrCreateShim(abs_target, abs_dest, global);
}
},
.dir => {
const target = this.bin.value.dir.slice(this.string_buf);
if (target.len == 0) return;
// for normalizing `target`
const abs_target_dir = path.joinAbsStringZ(package_dir, &.{target}, .auto);
var target_dir = bun.openDirAbsolute(abs_target_dir) catch |err| {
if (err == error.ENOENT) {
// https://github.com/npm/cli/blob/366c07e2f3cb9d1c6ddbd03e624a4d73fbd2676e/node_modules/bin-links/lib/link-gently.js#L43
// avoid erroring when the directory does not exist
return;
}
this.err = err;
return;
};
defer target_dir.close();
const abs_dest_dir_end = abs_dest_buf_remain;
var iter = target_dir.iterate();
while (iter.next() catch null) |entry| {
switch (entry.kind) {
.sym_link, .file => {
// `this.abs_target_buf` is available now because `path.joinAbsStringZ` copied everything into `parse_join_input_buffer`
const abs_target = path.joinAbsStringBufZ(abs_target_dir, this.abs_target_buf, &.{entry.name}, .auto);
abs_dest_buf_remain = abs_dest_dir_end;
@memcpy(abs_dest_buf_remain[0..entry.name.len], entry.name);
abs_dest_buf_remain = abs_dest_buf_remain[entry.name.len..];
abs_dest_buf_remain[0] = 0;
const abs_dest_len = @intFromPtr(abs_dest_buf_remain.ptr) - @intFromPtr(this.abs_dest_buf.ptr);
const abs_dest: [:0]const u8 = this.abs_dest_buf[0..abs_dest_len :0];
this.linkBinOrCreateShim(abs_target, abs_dest, global);
},
else => {},
}
}
},
}
}
pub fn unlink(this: *Linker, global: bool) void {
const package_dir = this.buildTargetPackageDir();
var abs_dest_buf_remain = this.buildDestinationDir(global);
bun.assertWithLocation(this.bin.tag != .none, @src());
switch (this.bin.tag) {
.none => {},
.file => {
const unscoped_package_name = Dependency.unscopedPackageName(this.package_name.slice());
@memcpy(abs_dest_buf_remain[0..unscoped_package_name.len], unscoped_package_name);
abs_dest_buf_remain = abs_dest_buf_remain[unscoped_package_name.len..];
abs_dest_buf_remain[0] = 0;
const abs_dest_len = @intFromPtr(abs_dest_buf_remain.ptr) - @intFromPtr(this.abs_dest_buf.ptr);
const abs_dest: [:0]const u8 = this.abs_dest_buf[0..abs_dest_len :0];
unlinkBinOrShim(abs_dest);
},
.named_file => {
const name = this.bin.value.named_file[0].slice(this.string_buf);
const normalized_name = normalizedBinName(name);
if (normalized_name.len == 0) return;
@memcpy(abs_dest_buf_remain[0..normalized_name.len], normalized_name);
abs_dest_buf_remain = abs_dest_buf_remain[normalized_name.len..];
abs_dest_buf_remain[0] = 0;
const abs_dest_len = @intFromPtr(abs_dest_buf_remain.ptr) - @intFromPtr(this.abs_dest_buf.ptr);
const abs_dest: [:0]const u8 = this.abs_dest_buf[0..abs_dest_len :0];
unlinkBinOrShim(abs_dest);
},
.map => {
var i = this.bin.value.map.begin();
const end = this.bin.value.map.end();
const abs_dest_dir_end = abs_dest_buf_remain;
while (i < end) : (i += 2) {
const bin_dest = this.extern_string_buf[i].slice(this.string_buf);
const normalized_bin_dest = normalizedBinName(bin_dest);
if (normalized_bin_dest.len == 0) continue;
abs_dest_buf_remain = abs_dest_dir_end;
@memcpy(abs_dest_buf_remain[0..normalized_bin_dest.len], normalized_bin_dest);
abs_dest_buf_remain = abs_dest_buf_remain[normalized_bin_dest.len..];
abs_dest_buf_remain[0] = 0;
const abs_dest_len = @intFromPtr(abs_dest_buf_remain.ptr) - @intFromPtr(this.abs_dest_buf.ptr);
const abs_dest: [:0]const u8 = this.abs_dest_buf[0..abs_dest_len :0];
unlinkBinOrShim(abs_dest);
}
},
.dir => {
const target = this.bin.value.dir.slice(this.string_buf);
if (target.len == 0) return;
const abs_target_dir = path.joinAbsStringZ(package_dir, &.{target}, .auto);
var target_dir = bun.openDirAbsolute(abs_target_dir) catch |err| {
this.err = err;
return;
};
defer target_dir.close();
const abs_dest_dir_end = abs_dest_buf_remain;
var iter = target_dir.iterate();
while (iter.next() catch null) |entry| {
switch (entry.kind) {
.sym_link, .file => {
abs_dest_buf_remain = abs_dest_dir_end;
@memcpy(abs_dest_buf_remain[0..entry.name.len], entry.name);
abs_dest_buf_remain = abs_dest_buf_remain[entry.name.len..];
abs_dest_buf_remain[0] = 0;
const abs_dest_len = @intFromPtr(abs_dest_buf_remain.ptr) - @intFromPtr(this.abs_dest_buf.ptr);
const abs_dest: [:0]const u8 = this.abs_dest_buf[0..abs_dest_len :0];
unlinkBinOrShim(abs_dest);
},
else => {},
}
}
},
}
}
};
};
const log = bun.Output.scoped(.BinLinker, .hidden);
const string = []const u8;
const stringZ = [:0]const u8;
const Dependency = @import("./dependency.zig");
const Environment = @import("../env.zig");
const std = @import("std");
const Install = @import("./install.zig");
const ExternalStringList = @import("./install.zig").ExternalStringList;
const bun = @import("bun");
const JSON = bun.json;
const OOM = bun.OOM;
const path = bun.path;
const strings = bun.strings;
const Semver = bun.Semver;
const ExternalString = Semver.ExternalString;
const String = Semver.String;