Files
bun.sh/src/install/NetworkTask.zig
Michael H f7da0ac6fd bun install: support for minimumReleaseAge (#22801)
### What does this PR do?

fixes #22679

* includes a better error if a package cant be met because of the age
(but would normally)
* logs the resolved one in --verbose (which can be helpful in debugging
to show it does know latest but couldn't use)
* makes bun outdated show in the table when the package isn't true
latest
* includes a rudimentary "stability" check if a later version is in
blacked out time (but only up to 7 days as it goes back to latest with
min age)


For extended security we could also Last-Modified header of the tgz
download and then abort if too new (just like the hash)


| install error with no recent version | bun outdated respecting the
rule |
| --- | --- |
<img width="838" height="119" alt="image"
src="https://github.com/user-attachments/assets/b60916a8-27f6-4405-bfb6-57f9fa8bb0d6"
/> | <img width="609" height="314" alt="image"
src="https://github.com/user-attachments/assets/d8869ff4-8e16-492c-8e4c-9ac1dfa302ba"
/> |

For stable release we will make it use `3d` type syntax instead of magic
second numbers.


### How did you verify your code works?

tests & manual

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
2025-10-06 02:58:04 -07:00

345 lines
12 KiB
Zig

unsafe_http_client: AsyncHTTP = undefined,
response: bun.http.HTTPClientResult = .{},
task_id: Task.Id,
url_buf: []const u8 = &[_]u8{},
retried: u16 = 0,
allocator: std.mem.Allocator,
request_buffer: MutableString = undefined,
response_buffer: MutableString = undefined,
package_manager: *PackageManager,
callback: union(Task.Tag) {
package_manifest: struct {
loaded_manifest: ?Npm.PackageManifest = null,
name: strings.StringOrTinyString,
is_extended_manifest: bool = false,
},
extract: ExtractTarball,
git_clone: void,
git_checkout: void,
local_tarball: void,
},
/// Key in patchedDependencies in package.json
apply_patch_task: ?*PatchTask = null,
next: ?*NetworkTask = null,
pub const DedupeMapEntry = struct {
is_required: bool,
};
pub const DedupeMap = std.HashMap(Task.Id, DedupeMapEntry, IdentityContext(Task.Id), 80);
pub fn notify(this: *NetworkTask, async_http: *AsyncHTTP, result: bun.http.HTTPClientResult) void {
defer this.package_manager.wake();
async_http.real.?.* = async_http.*;
async_http.real.?.response_buffer = async_http.response_buffer;
this.response = result;
this.package_manager.async_network_task_queue.push(this);
}
pub const Authorization = enum {
no_authorization,
allow_authorization,
};
// We must use a less restrictive Accept header value
// https://github.com/oven-sh/bun/issues/341
// https://www.jfrog.com/jira/browse/RTFACT-18398
const accept_header_value = "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*";
const accept_header_value_extended = "application/json, */*";
const default_headers_buf: string = "Accept" ++ accept_header_value;
const extended_headers_buf: string = "Accept" ++ accept_header_value_extended;
fn appendAuth(header_builder: *HeaderBuilder, scope: *const Npm.Registry.Scope) void {
if (scope.token.len > 0) {
header_builder.appendFmt("Authorization", "Bearer {s}", .{scope.token});
} else if (scope.auth.len > 0) {
header_builder.appendFmt("Authorization", "Basic {s}", .{scope.auth});
} else {
return;
}
header_builder.append("npm-auth-type", "legacy");
}
fn countAuth(header_builder: *HeaderBuilder, scope: *const Npm.Registry.Scope) void {
if (scope.token.len > 0) {
header_builder.count("Authorization", "");
header_builder.content.cap += "Bearer ".len + scope.token.len;
} else if (scope.auth.len > 0) {
header_builder.count("Authorization", "");
header_builder.content.cap += "Basic ".len + scope.auth.len;
} else {
return;
}
header_builder.count("npm-auth-type", "legacy");
}
const ForManifestError = OOM || error{
InvalidURL,
};
pub fn forManifest(
this: *NetworkTask,
name: string,
allocator: std.mem.Allocator,
scope: *const Npm.Registry.Scope,
loaded_manifest: ?*const Npm.PackageManifest,
is_optional: bool,
needs_extended: bool,
) ForManifestError!void {
this.url_buf = blk: {
// Not all registries support scoped package names when fetching the manifest.
// registry.npmjs.org supports both "@storybook%2Faddons" and "@storybook/addons"
// Other registries like AWS codeartifact only support the former.
// "npm" CLI requests the manifest with the encoded name.
var arena = std.heap.ArenaAllocator.init(bun.default_allocator);
defer arena.deinit();
var stack_fallback_allocator = std.heap.stackFallback(512, arena.allocator());
var encoded_name = name;
if (strings.containsChar(name, '/')) {
encoded_name = try std.mem.replaceOwned(u8, stack_fallback_allocator.get(), name, "/", "%2f");
}
const tmp = bun.jsc.URL.join(
bun.String.borrowUTF8(scope.url.href),
bun.String.borrowUTF8(encoded_name),
);
defer tmp.deref();
if (tmp.tag == .Dead) {
if (!is_optional) {
this.package_manager.log.addErrorFmt(
null,
logger.Loc.Empty,
allocator,
"Failed to join registry {} and package {} URLs",
.{ bun.fmt.QuotedFormatter{ .text = scope.url.href }, bun.fmt.QuotedFormatter{ .text = name } },
) catch |err| bun.handleOom(err);
} else {
this.package_manager.log.addWarningFmt(
null,
logger.Loc.Empty,
allocator,
"Failed to join registry {} and package {} URLs",
.{ bun.fmt.QuotedFormatter{ .text = scope.url.href }, bun.fmt.QuotedFormatter{ .text = name } },
) catch |err| bun.handleOom(err);
}
return error.InvalidURL;
}
if (!(tmp.hasPrefixComptime("https://") or tmp.hasPrefixComptime("http://"))) {
if (!is_optional) {
this.package_manager.log.addErrorFmt(
null,
logger.Loc.Empty,
allocator,
"Registry URL must be http:// or https://\nReceived: \"{}\"",
.{tmp},
) catch |err| bun.handleOom(err);
} else {
this.package_manager.log.addWarningFmt(
null,
logger.Loc.Empty,
allocator,
"Registry URL must be http:// or https://\nReceived: \"{}\"",
.{tmp},
) catch |err| bun.handleOom(err);
}
return error.InvalidURL;
}
// This actually duplicates the string! So we defer deref the WTF managed one above.
break :blk try tmp.toOwnedSlice(allocator);
};
var last_modified: string = "";
var etag: string = "";
if (loaded_manifest) |manifest| {
if ((needs_extended and manifest.pkg.has_extended_manifest) or !needs_extended) {
last_modified = manifest.pkg.last_modified.slice(manifest.string_buf);
etag = manifest.pkg.etag.slice(manifest.string_buf);
}
}
var header_builder = HeaderBuilder{};
countAuth(&header_builder, scope);
if (etag.len != 0) {
header_builder.count("If-None-Match", etag);
}
if (last_modified.len != 0) {
header_builder.count("If-Modified-Since", last_modified);
}
if (header_builder.header_count > 0) {
const accept_header = if (needs_extended) accept_header_value_extended else accept_header_value;
header_builder.count("Accept", accept_header);
if (last_modified.len > 0 and etag.len > 0) {
header_builder.content.count(last_modified);
}
try header_builder.allocate(allocator);
appendAuth(&header_builder, scope);
if (etag.len != 0) {
header_builder.append("If-None-Match", etag);
} else if (last_modified.len != 0) {
header_builder.append("If-Modified-Since", last_modified);
}
header_builder.append("Accept", accept_header);
if (last_modified.len > 0 and etag.len > 0) {
last_modified = header_builder.content.append(last_modified);
}
} else {
const header_buf = if (needs_extended) &extended_headers_buf else &default_headers_buf;
try header_builder.entries.append(
allocator,
.{
.name = .{ .offset = 0, .length = @as(u32, @truncate("Accept".len)) },
.value = .{ .offset = "Accept".len, .length = @as(u32, @truncate(header_buf.len - "Accept".len)) },
},
);
header_builder.header_count = 1;
header_builder.content = GlobalStringBuilder{ .ptr = @as([*]u8, @constCast(@ptrCast(header_buf.ptr))), .len = header_buf.len, .cap = header_buf.len };
}
this.response_buffer = try MutableString.init(allocator, 0);
this.allocator = allocator;
const url = URL.parse(this.url_buf);
this.unsafe_http_client = AsyncHTTP.init(allocator, .GET, url, header_builder.entries, header_builder.content.ptr.?[0..header_builder.content.len], &this.response_buffer, "", this.getCompletionCallback(), HTTP.FetchRedirect.follow, .{
.http_proxy = this.package_manager.httpProxy(url),
});
this.unsafe_http_client.client.flags.reject_unauthorized = this.package_manager.tlsRejectUnauthorized();
if (PackageManager.verbose_install) {
this.unsafe_http_client.client.verbose = .headers;
}
this.callback = .{
.package_manifest = .{
.name = try strings.StringOrTinyString.initAppendIfNeeded(name, *FileSystem.FilenameStore, FileSystem.FilenameStore.instance),
.loaded_manifest = if (loaded_manifest) |manifest| manifest.* else null,
.is_extended_manifest = needs_extended,
},
};
if (PackageManager.verbose_install) {
this.unsafe_http_client.verbose = .headers;
this.unsafe_http_client.client.verbose = .headers;
}
// Incase the ETag causes invalidation, we fallback to the last modified date.
if (last_modified.len != 0 and bun.getRuntimeFeatureFlag(.BUN_FEATURE_FLAG_LAST_MODIFIED_PRETEND_304)) {
this.unsafe_http_client.client.flags.force_last_modified = true;
this.unsafe_http_client.client.if_modified_since = last_modified;
}
}
pub fn getCompletionCallback(this: *NetworkTask) HTTP.HTTPClientResult.Callback {
return HTTP.HTTPClientResult.Callback.New(*NetworkTask, notify).init(this);
}
pub fn schedule(this: *NetworkTask, batch: *ThreadPool.Batch) void {
this.unsafe_http_client.schedule(this.allocator, batch);
}
pub const ForTarballError = OOM || error{
InvalidURL,
};
pub fn forTarball(
this: *NetworkTask,
allocator: std.mem.Allocator,
tarball_: *const ExtractTarball,
scope: *const Npm.Registry.Scope,
authorization: NetworkTask.Authorization,
) ForTarballError!void {
this.callback = .{ .extract = tarball_.* };
const tarball = &this.callback.extract;
const tarball_url = tarball.url.slice();
if (tarball_url.len == 0) {
this.url_buf = try ExtractTarball.buildURL(
scope.url.href,
tarball.name,
tarball.resolution.value.npm.version,
this.package_manager.lockfile.buffers.string_bytes.items,
);
} else {
this.url_buf = tarball_url;
}
if (!(strings.hasPrefixComptime(this.url_buf, "https://") or strings.hasPrefixComptime(this.url_buf, "http://"))) {
const msg = .{
.fmt = "Expected tarball URL to start with https:// or http://, got {} while fetching package {}",
.args = .{ bun.fmt.QuotedFormatter{ .text = this.url_buf }, bun.fmt.QuotedFormatter{ .text = tarball.name.slice() } },
};
try this.package_manager.log.addErrorFmt(null, .{}, allocator, msg.fmt, msg.args);
return error.InvalidURL;
}
this.response_buffer = MutableString.initEmpty(allocator);
this.allocator = allocator;
var header_builder = HeaderBuilder{};
var header_buf: string = "";
if (authorization == .allow_authorization) {
countAuth(&header_builder, scope);
}
if (header_builder.header_count > 0) {
try header_builder.allocate(allocator);
if (authorization == .allow_authorization) {
appendAuth(&header_builder, scope);
}
header_buf = header_builder.content.ptr.?[0..header_builder.content.len];
}
const url = URL.parse(this.url_buf);
this.unsafe_http_client = AsyncHTTP.init(allocator, .GET, url, header_builder.entries, header_buf, &this.response_buffer, "", this.getCompletionCallback(), HTTP.FetchRedirect.follow, .{
.http_proxy = this.package_manager.httpProxy(url),
});
this.unsafe_http_client.client.flags.reject_unauthorized = this.package_manager.tlsRejectUnauthorized();
if (PackageManager.verbose_install) {
this.unsafe_http_client.client.verbose = .headers;
}
}
const string = []const u8;
const std = @import("std");
const install = @import("./install.zig");
const ExtractTarball = install.ExtractTarball;
const NetworkTask = install.NetworkTask;
const Npm = install.Npm;
const PackageManager = install.PackageManager;
const PatchTask = install.PatchTask;
const Task = install.Task;
const bun = @import("bun");
const GlobalStringBuilder = bun.StringBuilder;
const IdentityContext = bun.IdentityContext;
const MutableString = bun.MutableString;
const OOM = bun.OOM;
const ThreadPool = bun.ThreadPool;
const URL = bun.URL;
const logger = bun.logger;
const strings = bun.strings;
const Fs = bun.fs;
const FileSystem = Fs.FileSystem;
const HTTP = bun.http;
const AsyncHTTP = HTTP.AsyncHTTP;
const HeaderBuilder = HTTP.HeaderBuilder;