mirror of
https://github.com/oven-sh/bun
synced 2026-02-16 13:51:47 +00:00
Compare commits
8 Commits
claude/fix
...
claude/min
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7e8242349 | ||
|
|
0dbe6a3eb9 | ||
|
|
37b65ae0c9 | ||
|
|
cdbe06e0a2 | ||
|
|
898be58f20 | ||
|
|
00e4c82ab8 | ||
|
|
1330fa930a | ||
|
|
108b869b5f |
@@ -3048,6 +3048,12 @@ pub const api = struct {
|
||||
|
||||
link_workspace_packages: ?bool = null,
|
||||
|
||||
/// Minimum age in minutes that a package version must have been published before installation
|
||||
minimum_release_age: ?u32 = null,
|
||||
|
||||
/// List of packages exempt from the minimum release age restriction
|
||||
minimum_release_age_exclude: ?[]const []const u8 = null,
|
||||
|
||||
node_linker: ?bun.install.PackageManager.Options.NodeLinker = null,
|
||||
|
||||
security_scanner: ?[]const u8 = null,
|
||||
|
||||
@@ -566,6 +566,32 @@ pub const Bunfig = struct {
|
||||
try this.loadLogLevel(expr);
|
||||
}
|
||||
|
||||
// Parse minimumReleaseAge security feature
|
||||
if (install_obj.get("minimumReleaseAge")) |min_age| {
|
||||
if (min_age.asNumber()) |value| {
|
||||
install.minimum_release_age = @as(u32, @intFromFloat(value));
|
||||
} else {
|
||||
try this.addError(min_age.loc, "Invalid minimumReleaseAge. Expected a number (minutes).");
|
||||
}
|
||||
}
|
||||
|
||||
// Parse minimumReleaseAgeExclude list
|
||||
if (install_obj.get("minimumReleaseAgeExclude")) |exclude| {
|
||||
if (exclude.data == .e_array) {
|
||||
const arr = exclude.data.e_array;
|
||||
var list = try allocator.alloc([]const u8, arr.items.len);
|
||||
for (arr.items.slice(), 0..) |item, i| {
|
||||
list[i] = try item.asStringCloned(allocator) orelse {
|
||||
try this.addError(item.loc, "Invalid minimumReleaseAgeExclude item. Expected a string.");
|
||||
return;
|
||||
};
|
||||
}
|
||||
install.minimum_release_age_exclude = list;
|
||||
} else {
|
||||
try this.addError(exclude.loc, "Invalid minimumReleaseAgeExclude. Expected an array of package names.");
|
||||
}
|
||||
}
|
||||
|
||||
if (install_obj.get("cache")) |cache| {
|
||||
load: {
|
||||
if (cache.asBool()) |value| {
|
||||
@@ -610,6 +636,32 @@ pub const Bunfig = struct {
|
||||
}
|
||||
}
|
||||
|
||||
if (install_obj.get("minimumReleaseAge")) |minimum_age| {
|
||||
if (minimum_age.data == .e_number) {
|
||||
install.minimum_release_age = @as(u32, @intFromFloat(minimum_age.data.e_number.value));
|
||||
} else {
|
||||
try this.addError(minimum_age.loc, "Expected number for minimumReleaseAge (minutes)");
|
||||
}
|
||||
}
|
||||
|
||||
if (install_obj.get("minimumReleaseAgeExclude")) |exclude| {
|
||||
if (exclude.data == .e_array) {
|
||||
const items = exclude.data.e_array.items.slice();
|
||||
if (items.len > 0) {
|
||||
const list = try allocator.alloc([]const u8, items.len);
|
||||
for (items, 0..) |item, i| {
|
||||
list[i] = try item.asStringCloned(allocator) orelse {
|
||||
try this.addError(item.loc, "Expected string in minimumReleaseAgeExclude array");
|
||||
return;
|
||||
};
|
||||
}
|
||||
install.minimum_release_age_exclude = list;
|
||||
}
|
||||
} else {
|
||||
try this.addError(exclude.loc, "Expected array for minimumReleaseAgeExclude");
|
||||
}
|
||||
}
|
||||
|
||||
if (install_obj.get("security")) |security_obj| {
|
||||
if (security_obj.data == .e_object) {
|
||||
if (security_obj.get("scanner")) |scanner| {
|
||||
|
||||
@@ -441,7 +441,7 @@ pub const OutdatedCommand = struct {
|
||||
const latest = manifest.findByDistTag("latest") orelse continue;
|
||||
|
||||
const update_version = if (resolved_version.tag == .npm)
|
||||
manifest.findBestVersion(resolved_version.value.npm.version, string_buf) orelse continue
|
||||
manifest.findBestVersion(resolved_version.value.npm.version, string_buf, null, package_name) orelse continue
|
||||
else
|
||||
manifest.findByDistTag(resolved_version.value.dist_tag.tag.slice(string_buf)) orelse continue;
|
||||
|
||||
@@ -574,7 +574,7 @@ pub const OutdatedCommand = struct {
|
||||
const latest = manifest.findByDistTag("latest") orelse continue;
|
||||
const resolved_version = resolveCatalogDependency(manager, dep) orelse continue;
|
||||
const update = if (resolved_version.tag == .npm)
|
||||
manifest.findBestVersion(resolved_version.value.npm.version, string_buf) orelse continue
|
||||
manifest.findBestVersion(resolved_version.value.npm.version, string_buf, null, package_name) orelse continue
|
||||
else
|
||||
manifest.findByDistTag(resolved_version.value.dist_tag.tag.slice(string_buf)) orelse continue;
|
||||
|
||||
|
||||
@@ -128,16 +128,19 @@ pub fn view(allocator: std.mem.Allocator, manager: *PackageManager, spec_: strin
|
||||
if (parsed_manifest.findByDistTag(version)) |result| {
|
||||
break :brk2 result.version;
|
||||
} else {
|
||||
// Parse as semver query and find best version - exactly like outdated_command.zig line 325
|
||||
// For bun view/info commands, we don't filter by minimumReleaseAge
|
||||
// Just find the best matching version from the manifest
|
||||
// We'll pass null for the age filter parameter
|
||||
const sliced_literal = Semver.SlicedString.init(version, version);
|
||||
const query = try Semver.Query.parse(allocator, version, sliced_literal);
|
||||
defer query.deinit();
|
||||
// Use the same pattern as outdated_command: findBestVersion(query.head, string_buf)
|
||||
if (parsed_manifest.findBestVersion(query, parsed_manifest.string_buf)) |result| {
|
||||
// Pass null for minimum_release_age to disable filtering in info commands
|
||||
if (parsed_manifest.findBestVersion(query, parsed_manifest.string_buf, null, name)) |result| {
|
||||
break :brk2 result.version;
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't find a matching version, skip
|
||||
break :from_versions;
|
||||
};
|
||||
|
||||
|
||||
@@ -775,7 +775,7 @@ pub const UpdateInteractiveCommand = struct {
|
||||
// In interactive mode, show the constrained update version as "Target"
|
||||
// but always include packages (don't filter out breaking changes)
|
||||
const update_version = if (resolved_version.tag == .npm)
|
||||
manifest.findBestVersion(resolved_version.value.npm.version, string_buf) orelse latest
|
||||
manifest.findBestVersion(resolved_version.value.npm.version, string_buf, null, package_name) orelse latest
|
||||
else
|
||||
manifest.findByDistTag(resolved_version.value.dist_tag.tag.slice(string_buf)) orelse latest;
|
||||
|
||||
|
||||
@@ -567,16 +567,32 @@ pub fn enqueueDependencyWithMainAndSuccessFn(
|
||||
err,
|
||||
);
|
||||
} else {
|
||||
this.log.addErrorFmt(
|
||||
null,
|
||||
logger.Loc.Empty,
|
||||
this.allocator,
|
||||
"No version matching \"{s}\" found for specifier \"{s}\" (but package exists)",
|
||||
.{
|
||||
this.lockfile.str(&version.literal),
|
||||
this.lockfile.str(&name),
|
||||
},
|
||||
) catch unreachable;
|
||||
// Check if minimumReleaseAge might be blocking this package
|
||||
if (this.options.minimum_release_age.isEnabled()) {
|
||||
this.log.addErrorFmt(
|
||||
null,
|
||||
logger.Loc.Empty,
|
||||
this.allocator,
|
||||
"No version matching \"{s}\" found for specifier \"{s}\" that meets the minimum release age of {d} minutes. " ++
|
||||
"Consider adding this package to minimumReleaseAgeExclude in bunfig.toml if you trust it.",
|
||||
.{
|
||||
this.lockfile.str(&version.literal),
|
||||
this.lockfile.str(&name),
|
||||
this.options.minimum_release_age.minutes,
|
||||
},
|
||||
) catch unreachable;
|
||||
} else {
|
||||
this.log.addErrorFmt(
|
||||
null,
|
||||
logger.Loc.Empty,
|
||||
this.allocator,
|
||||
"No version matching \"{s}\" found for specifier \"{s}\" (but package exists)",
|
||||
.{
|
||||
this.lockfile.str(&version.literal),
|
||||
this.lockfile.str(&name),
|
||||
},
|
||||
) catch unreachable;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -1573,7 +1589,12 @@ fn getOrPutResolvedPackage(
|
||||
) orelse return null; // manifest might still be downloading. This feels unreliable.
|
||||
const find_result: Npm.PackageManifest.FindResult = switch (version.tag) {
|
||||
.dist_tag => manifest.findByDistTag(this.lockfile.str(&version.value.dist_tag.tag)),
|
||||
.npm => manifest.findBestVersion(version.value.npm.version, this.lockfile.buffers.string_bytes.items),
|
||||
.npm => manifest.findBestVersion(
|
||||
version.value.npm.version,
|
||||
this.lockfile.buffers.string_bytes.items,
|
||||
&this.options.minimum_release_age,
|
||||
name_str,
|
||||
),
|
||||
else => unreachable,
|
||||
} orelse {
|
||||
resolve_workspace_from_dist_tag: {
|
||||
|
||||
@@ -74,6 +74,40 @@ node_linker: NodeLinker = .auto,
|
||||
// Security scanner module path
|
||||
security_scanner: ?[]const u8 = null,
|
||||
|
||||
// Minimum release age configuration for security
|
||||
minimum_release_age: MinimumReleaseAge = .{},
|
||||
|
||||
pub const MinimumReleaseAge = struct {
|
||||
minutes: u32 = 0,
|
||||
exclude: []const string = &.{},
|
||||
exclude_set: ?bun.StringHashMapUnmanaged(void) = null,
|
||||
|
||||
pub fn isExcluded(this: *const MinimumReleaseAge, name: string) bool {
|
||||
if (this.exclude_set) |*set| {
|
||||
return set.contains(name);
|
||||
}
|
||||
for (this.exclude) |excluded| {
|
||||
if (strings.eql(excluded, name)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn isEnabled(this: *const MinimumReleaseAge) bool {
|
||||
return this.minutes > 0;
|
||||
}
|
||||
|
||||
pub fn isVersionTooNew(this: *const MinimumReleaseAge, publish_time_seconds: u32) bool {
|
||||
if (!this.isEnabled()) return false;
|
||||
if (publish_time_seconds == 0) return false; // Unknown publish time, allow it
|
||||
|
||||
const current_time = @as(u32, @truncate(@as(u64, @intCast(@max(0, std.time.timestamp())))));
|
||||
const age_seconds = current_time -| publish_time_seconds;
|
||||
const age_minutes = age_seconds / 60;
|
||||
|
||||
return age_minutes < this.minutes;
|
||||
}
|
||||
};
|
||||
|
||||
pub const PublishConfig = struct {
|
||||
access: ?Access = null,
|
||||
tag: string = "",
|
||||
@@ -366,6 +400,23 @@ pub fn load(
|
||||
}
|
||||
}
|
||||
|
||||
if (config.minimum_release_age) |minimum_age| {
|
||||
this.minimum_release_age.minutes = minimum_age;
|
||||
}
|
||||
|
||||
if (config.minimum_release_age_exclude) |exclude_list| {
|
||||
this.minimum_release_age.exclude = exclude_list;
|
||||
// Build a hashmap for fast lookups if we have many exclusions
|
||||
if (exclude_list.len > 5) {
|
||||
var exclude_set = bun.StringHashMapUnmanaged(void){};
|
||||
try exclude_set.ensureTotalCapacity(allocator, @as(u32, @truncate(exclude_list.len)));
|
||||
for (exclude_list) |name| {
|
||||
exclude_set.putAssumeCapacityNoClobber(name, {});
|
||||
}
|
||||
this.minimum_release_age.exclude_set = exclude_set;
|
||||
}
|
||||
}
|
||||
|
||||
this.explicit_global_directory = config.global_dir orelse this.explicit_global_directory;
|
||||
}
|
||||
|
||||
|
||||
@@ -670,6 +670,10 @@ pub fn installWithManager(
|
||||
|
||||
const packages_len_before_install = manager.lockfile.packages.len;
|
||||
|
||||
// Note: minimumReleaseAge security policy enforcement with frozen lockfile:
|
||||
// - Binary lockfile (bun.lockb): Cannot enforce since publish_time isn't stored (backwards compatibility)
|
||||
// - Text lockfile (bun.lock): Could potentially enforce by fetching metadata, but not implemented
|
||||
// This maintains backwards compatibility while the feature primarily works at resolution time
|
||||
if (manager.options.enable.frozen_lockfile and load_result != .not_found) frozen_lockfile: {
|
||||
if (load_result.loadedFromTextLockfile()) {
|
||||
if (bun.handleOom(manager.lockfile.eql(lockfile_before_clean, packages_len_before_install, manager.allocator))) {
|
||||
|
||||
@@ -870,13 +870,17 @@ pub const PackageVersion = extern struct {
|
||||
/// `hasInstallScript` field in registry API.
|
||||
has_install_script: bool = false,
|
||||
|
||||
/// Unix timestamp (seconds) when this version was published to npm registry
|
||||
/// Used for minimum release age security feature
|
||||
publish_time: u32 = 0,
|
||||
|
||||
pub fn allDependenciesBundled(this: *const PackageVersion) bool {
|
||||
return this.bundled_dependencies.isInvalid();
|
||||
}
|
||||
};
|
||||
|
||||
comptime {
|
||||
if (@sizeOf(Npm.PackageVersion) != 232) {
|
||||
if (@sizeOf(Npm.PackageVersion) != 240) {
|
||||
@compileError(std.fmt.comptimePrint("Npm.PackageVersion has unexpected size {d}", .{@sizeOf(Npm.PackageVersion)}));
|
||||
}
|
||||
}
|
||||
@@ -1460,22 +1464,94 @@ pub const PackageManifest = struct {
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn findBestVersion(this: *const PackageManifest, group: Semver.Query.Group, group_buf: string) ?FindResult {
|
||||
/// Check if a package version was published recently (within threshold)
|
||||
pub fn isRecentlyPublished(publish_time: u32, threshold_minutes: u32) bool {
|
||||
if (publish_time == 0) return false; // Unknown publish time
|
||||
|
||||
const current_time = @as(u32, @truncate(@as(u64, @intCast(@max(0, std.time.timestamp())))));
|
||||
const age_seconds = current_time -| publish_time;
|
||||
const age_minutes = age_seconds / 60;
|
||||
|
||||
return age_minutes < threshold_minutes;
|
||||
}
|
||||
|
||||
pub fn findBestVersion(
|
||||
this: *const PackageManifest,
|
||||
group: Semver.Query.Group,
|
||||
group_buf: string,
|
||||
minimum_release_age: ?*const PackageManager.Options.MinimumReleaseAge,
|
||||
package_name: []const u8,
|
||||
) ?FindResult {
|
||||
const left = group.head.head.range.left;
|
||||
// Fast path: exact version
|
||||
// Fast path: exact version - but STILL CHECK SECURITY POLICY
|
||||
if (left.op == .eql) {
|
||||
return this.findByVersion(left.version);
|
||||
const exact_result = this.findByVersion(left.version);
|
||||
if (exact_result) |result| {
|
||||
// SECURITY: Even exact versions must respect minimumReleaseAge
|
||||
// Otherwise someone could bypass security by specifying exact versions
|
||||
if (minimum_release_age) |min_age| {
|
||||
if (min_age.isEnabled() and !min_age.isExcluded(package_name)) {
|
||||
if (result.package.publish_time > 0 and min_age.isVersionTooNew(result.package.publish_time)) {
|
||||
// NEVER install packages that violate the security policy
|
||||
// Even if explicitly requested by exact version
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return exact_result;
|
||||
}
|
||||
|
||||
// Track any newer versions that were skipped due to age restrictions
|
||||
var skipped_newer_version: ?FindResult = null;
|
||||
|
||||
if (this.findByDistTag("latest")) |result| {
|
||||
if (group.satisfies(result.version, group_buf, this.string_buf)) {
|
||||
if (group.flags.isSet(Semver.Query.Group.Flags.pre)) {
|
||||
if (left.version.order(result.version, group_buf, this.string_buf) == .eq) {
|
||||
// if prerelease, use latest if semver+tag match range exactly
|
||||
return result;
|
||||
// Check minimum release age if enabled and not excluded
|
||||
if (minimum_release_age) |min_age| {
|
||||
if (min_age.isEnabled() and !min_age.isExcluded(package_name)) {
|
||||
if (result.package.publish_time > 0 and min_age.isVersionTooNew(result.package.publish_time)) {
|
||||
// Version is too new, skip it but remember it
|
||||
skipped_newer_version = result;
|
||||
} else {
|
||||
// Version is old enough, but maybe still warn if it's very recent
|
||||
if (isRecentlyPublished(result.package.publish_time, 1440)) { // 24 hours
|
||||
// TODO: Add warning output about recently published package
|
||||
// This will be shown during installation
|
||||
}
|
||||
if (group.flags.isSet(Semver.Query.Group.Flags.pre)) {
|
||||
if (left.version.order(result.version, group_buf, this.string_buf) == .eq) {
|
||||
return result;
|
||||
}
|
||||
} else {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Package is excluded from age check, but still warn if very recent
|
||||
if (isRecentlyPublished(result.package.publish_time, 1440)) { // 24 hours
|
||||
// TODO: Add warning output about recently published package
|
||||
}
|
||||
if (group.flags.isSet(Semver.Query.Group.Flags.pre)) {
|
||||
if (left.version.order(result.version, group_buf, this.string_buf) == .eq) {
|
||||
return result;
|
||||
}
|
||||
} else {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return result;
|
||||
// No minimum age configured, but still warn if very recent
|
||||
if (result.package.publish_time > 0 and isRecentlyPublished(result.package.publish_time, 1440)) { // 24 hours
|
||||
// TODO: Add warning output about recently published package
|
||||
}
|
||||
if (group.flags.isSet(Semver.Query.Group.Flags.pre)) {
|
||||
if (left.version.order(result.version, group_buf, this.string_buf) == .eq) {
|
||||
return result;
|
||||
}
|
||||
} else {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1489,9 +1565,24 @@ pub const PackageManifest = struct {
|
||||
const version = releases[i - 1];
|
||||
|
||||
if (group.satisfies(version, group_buf, this.string_buf)) {
|
||||
const package = &this.pkg.releases.values.get(this.package_versions)[i - 1];
|
||||
|
||||
// Check minimum release age if enabled and not excluded
|
||||
if (minimum_release_age) |min_age| {
|
||||
if (min_age.isEnabled() and !min_age.isExcluded(package_name)) {
|
||||
if (package.publish_time > 0 and min_age.isVersionTooNew(package.publish_time)) {
|
||||
// Version is too new, skip it but remember if it's newer than what we have
|
||||
if (skipped_newer_version == null) {
|
||||
skipped_newer_version = .{ .version = version, .package = package };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return .{
|
||||
.version = version,
|
||||
.package = &this.pkg.releases.values.get(this.package_versions)[i - 1],
|
||||
.package = package,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1506,9 +1597,24 @@ pub const PackageManifest = struct {
|
||||
// This list is sorted at serialization time.
|
||||
if (group.satisfies(version, group_buf, this.string_buf)) {
|
||||
const packages = this.pkg.prereleases.values.get(this.package_versions);
|
||||
const package = &packages[i - 1];
|
||||
|
||||
// Check minimum release age if enabled and not excluded
|
||||
if (minimum_release_age) |min_age| {
|
||||
if (min_age.isEnabled() and !min_age.isExcluded(package_name)) {
|
||||
if (package.publish_time > 0 and min_age.isVersionTooNew(package.publish_time)) {
|
||||
// Version is too new, skip it but remember if it's newer than what we have
|
||||
if (skipped_newer_version == null) {
|
||||
skipped_newer_version = .{ .version = version, .package = package };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return .{
|
||||
.version = version,
|
||||
.package = &packages[i - 1],
|
||||
.package = package,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1519,6 +1625,47 @@ pub const PackageManifest = struct {
|
||||
|
||||
const ExternalStringMapDeduper = std.HashMap(u64, ExternalStringList, IdentityContext(u64), 80);
|
||||
|
||||
/// Parse ISO8601 timestamp to Unix seconds
|
||||
/// NPM always sends exactly: "2010-12-29T19:38:25.450Z" format
|
||||
fn parseISO8601ToUnixSeconds(timestamp: []const u8) !u32 {
|
||||
// NPM format is always exactly 24 chars: "YYYY-MM-DDTHH:MM:SS.sssZ"
|
||||
// Reject anything else for security
|
||||
if (timestamp.len != 24) return error.InvalidTimestamp;
|
||||
if (timestamp[10] != 'T') return error.InvalidTimestamp;
|
||||
if (timestamp[19] != '.') return error.InvalidTimestamp;
|
||||
if (timestamp[23] != 'Z') return error.InvalidTimestamp;
|
||||
|
||||
// Parse year, month, day, hour, minute, second
|
||||
const year = std.fmt.parseInt(u32, timestamp[0..4], 10) catch return error.InvalidTimestamp;
|
||||
const month = std.fmt.parseInt(u32, timestamp[5..7], 10) catch return error.InvalidTimestamp;
|
||||
const day = std.fmt.parseInt(u32, timestamp[8..10], 10) catch return error.InvalidTimestamp;
|
||||
const hour = std.fmt.parseInt(u32, timestamp[11..13], 10) catch return error.InvalidTimestamp;
|
||||
const minute = std.fmt.parseInt(u32, timestamp[14..16], 10) catch return error.InvalidTimestamp;
|
||||
const second = std.fmt.parseInt(u32, timestamp[17..19], 10) catch return error.InvalidTimestamp;
|
||||
|
||||
// Simple conversion to Unix timestamp (seconds since 1970-01-01)
|
||||
// This is approximate but good enough for our use case
|
||||
// Days since epoch = (year - 1970) * 365 + leap days + days in months + day
|
||||
const years_since_1970 = year -| 1970;
|
||||
const leap_years = (years_since_1970 + 1) / 4 - (years_since_1970 + 69) / 100 + (years_since_1970 + 369) / 400;
|
||||
|
||||
var days_in_months: u32 = 0;
|
||||
const days_per_month = [_]u32{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
|
||||
var m: u32 = 1;
|
||||
while (m < month) : (m += 1) {
|
||||
days_in_months += days_per_month[m - 1];
|
||||
}
|
||||
// Add leap day if applicable
|
||||
if (month > 2 and ((year % 4 == 0 and year % 100 != 0) or year % 400 == 0)) {
|
||||
days_in_months += 1;
|
||||
}
|
||||
|
||||
const days_since_epoch = years_since_1970 * 365 + leap_years + days_in_months + day - 1;
|
||||
const seconds_since_epoch = days_since_epoch * 86400 + hour * 3600 + minute * 60 + second;
|
||||
|
||||
return @as(u32, @truncate(seconds_since_epoch));
|
||||
}
|
||||
|
||||
/// This parses [Abbreviated metadata](https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md#abbreviated-metadata-format)
|
||||
pub fn parse(
|
||||
allocator: std.mem.Allocator,
|
||||
@@ -1572,6 +1719,32 @@ pub const PackageManifest = struct {
|
||||
|
||||
var bundled_deps_count: usize = 0;
|
||||
|
||||
// Parse time field to get publish times for each version
|
||||
var publish_times = std.StringHashMapUnmanaged(u32){};
|
||||
defer publish_times.deinit(allocator);
|
||||
if (json.asProperty("time")) |time_field| {
|
||||
if (time_field.expr.data == .e_object) {
|
||||
const time_entries = time_field.expr.data.e_object.properties.slice();
|
||||
try publish_times.ensureTotalCapacity(allocator, @as(u32, @truncate(time_entries.len)));
|
||||
for (time_entries) |entry| {
|
||||
const version_key = entry.key.?.asString(allocator) orelse continue;
|
||||
// Skip 'created' and 'modified' entries
|
||||
if (strings.eqlComptime(version_key, "created") or strings.eqlComptime(version_key, "modified")) continue;
|
||||
|
||||
const time_str = entry.value.?.asString(allocator) orelse continue;
|
||||
// Parse ISO8601 timestamp to unix timestamp
|
||||
// Format is: "2010-12-29T19:38:25.450Z"
|
||||
const unix_timestamp = parseISO8601ToUnixSeconds(time_str) catch {
|
||||
// Invalid timestamp = treat as brand new (maximum restriction)
|
||||
// This is the safest approach for security
|
||||
publish_times.putAssumeCapacityNoClobber(version_key, std.math.maxInt(u32));
|
||||
continue;
|
||||
};
|
||||
publish_times.putAssumeCapacityNoClobber(version_key, unix_timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var string_builder = String.Builder{
|
||||
.string_pool = string_pool,
|
||||
};
|
||||
@@ -1861,6 +2034,11 @@ pub const PackageManifest = struct {
|
||||
|
||||
var package_version: PackageVersion = empty_version;
|
||||
|
||||
// Set publish time for this version if available
|
||||
if (publish_times.get(version_name)) |publish_time| {
|
||||
package_version.publish_time = publish_time;
|
||||
}
|
||||
|
||||
if (prop.value.?.asProperty("cpu")) |cpu_q| {
|
||||
package_version.cpu = try Negatable(Architecture).fromJson(allocator, cpu_q.expr);
|
||||
}
|
||||
|
||||
725
test/cli/install/minimum-release-age.test.ts
Normal file
725
test/cli/install/minimum-release-age.test.ts
Normal file
@@ -0,0 +1,725 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { bunExe, bunEnv, tempDir } from "harness";
|
||||
import { join } from "path";
|
||||
import { Server } from "bun";
|
||||
|
||||
// Mock registry for testing minimumReleaseAge
|
||||
class MinimumAgeRegistry {
|
||||
private server: Server | null = null;
|
||||
private port: number = 0;
|
||||
private currentTime: number;
|
||||
|
||||
constructor() {
|
||||
// Set current time for consistent testing
|
||||
this.currentTime = Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
async start(): Promise<number> {
|
||||
const self = this;
|
||||
|
||||
this.server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
const pathname = url.pathname;
|
||||
|
||||
// Handle package metadata requests
|
||||
if (pathname === "/test-package") {
|
||||
return self.handleTestPackageMetadata();
|
||||
}
|
||||
if (pathname === "/recent-only-package") {
|
||||
return self.handleRecentOnlyPackageMetadata();
|
||||
}
|
||||
if (pathname === "/old-package") {
|
||||
return self.handleOldPackageMetadata();
|
||||
}
|
||||
|
||||
// Handle tarball requests
|
||||
if (pathname.endsWith(".tgz")) {
|
||||
return self.handleTarball();
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
this.port = this.server.port!;
|
||||
return this.port;
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.server) {
|
||||
this.server.stop();
|
||||
this.server = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleTestPackageMetadata(): Response {
|
||||
const oneHourAgo = new Date((this.currentTime - 3600) * 1000).toISOString();
|
||||
const threeDaysAgo = new Date((this.currentTime - 259200) * 1000).toISOString();
|
||||
const oneWeekAgo = new Date((this.currentTime - 604800) * 1000).toISOString();
|
||||
|
||||
const metadata = {
|
||||
name: "test-package",
|
||||
"dist-tags": {
|
||||
latest: "3.0.0",
|
||||
},
|
||||
versions: {
|
||||
"1.0.0": {
|
||||
name: "test-package",
|
||||
version: "1.0.0",
|
||||
dist: {
|
||||
tarball: `http://localhost:${this.port}/test-package-1.0.0.tgz`,
|
||||
},
|
||||
},
|
||||
"2.0.0": {
|
||||
name: "test-package",
|
||||
version: "2.0.0",
|
||||
dist: {
|
||||
tarball: `http://localhost:${this.port}/test-package-2.0.0.tgz`,
|
||||
},
|
||||
},
|
||||
"3.0.0": {
|
||||
name: "test-package",
|
||||
version: "3.0.0",
|
||||
dist: {
|
||||
tarball: `http://localhost:${this.port}/test-package-3.0.0.tgz`,
|
||||
},
|
||||
},
|
||||
},
|
||||
time: {
|
||||
"1.0.0": oneWeekAgo,
|
||||
"2.0.0": threeDaysAgo,
|
||||
"3.0.0": oneHourAgo,
|
||||
created: oneWeekAgo,
|
||||
modified: oneHourAgo,
|
||||
},
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(metadata), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
private handleRecentOnlyPackageMetadata(): Response {
|
||||
const oneHourAgo = new Date((this.currentTime - 3600) * 1000).toISOString();
|
||||
|
||||
const metadata = {
|
||||
name: "recent-only-package",
|
||||
"dist-tags": {
|
||||
latest: "1.0.0",
|
||||
},
|
||||
versions: {
|
||||
"1.0.0": {
|
||||
name: "recent-only-package",
|
||||
version: "1.0.0",
|
||||
dist: {
|
||||
tarball: `http://localhost:${this.port}/recent-only-package-1.0.0.tgz`,
|
||||
},
|
||||
},
|
||||
},
|
||||
time: {
|
||||
"1.0.0": oneHourAgo,
|
||||
created: oneHourAgo,
|
||||
modified: oneHourAgo,
|
||||
},
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(metadata), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
private handleOldPackageMetadata(): Response {
|
||||
const oneMonthAgo = new Date((this.currentTime - 2592000) * 1000).toISOString();
|
||||
|
||||
const metadata = {
|
||||
name: "old-package",
|
||||
"dist-tags": {
|
||||
latest: "1.0.0",
|
||||
},
|
||||
versions: {
|
||||
"1.0.0": {
|
||||
name: "old-package",
|
||||
version: "1.0.0",
|
||||
dist: {
|
||||
tarball: `http://localhost:${this.port}/old-package-1.0.0.tgz`,
|
||||
},
|
||||
},
|
||||
},
|
||||
time: {
|
||||
"1.0.0": oneMonthAgo,
|
||||
created: oneMonthAgo,
|
||||
modified: oneMonthAgo,
|
||||
},
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(metadata), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
private handleTarball(): Response {
|
||||
// Return a minimal valid gzipped tarball using the exact same format as boba-0.0.2.tgz
|
||||
// This tarball contains a simple package.json with name and version
|
||||
const tarballBase64 = "H4sIAAnMJGUAA+2STQ6CMBCFWXOKpmuD09JC4tqLFBgN/hQCamIMd3eQ6kpYGI0x9lv0JW+myUxfa5NvzRrnwQcBgFRrdtNkUJBqUAcTKk60lEJpYCBkKnXA9CeHunNsD6ahUYrzztiJPmpbrSbqbo+H/gi1y99ptGmrqVd4CXqPRKnx/EWsKH8tBB1pqih/+gkQMHj3IM/48/wvIWPcmj3yBeNZlRk+650TNm1Z2d6ECCI5uDVis8QabYE2L7Glcn/fVe7NgpPXhV347d08Ho/HM84VRRwnCQAKAAA=";
|
||||
|
||||
const tarballBuffer = Buffer.from(tarballBase64, "base64");
|
||||
return new Response(tarballBuffer, {
|
||||
headers: {
|
||||
"Content-Type": "application/gzip",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getUrl(): string {
|
||||
return `http://localhost:${this.port}`;
|
||||
}
|
||||
}
|
||||
|
||||
describe("minimumReleaseAge", () => {
|
||||
test("should select older version when latest is too recent", async () => {
|
||||
const registry = new MinimumAgeRegistry();
|
||||
const port = await registry.start();
|
||||
|
||||
try {
|
||||
using dir = tempDir("minimum-release-age-test", {
|
||||
"package.json": JSON.stringify({
|
||||
name: "test-project",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"test-package": "*",
|
||||
},
|
||||
}),
|
||||
"bunfig.toml": `
|
||||
[install]
|
||||
registry = "http://localhost:${port}"
|
||||
minimumReleaseAge = 1440 # 1 day in minutes
|
||||
`,
|
||||
});
|
||||
|
||||
const { exited } = Bun.spawn({
|
||||
cmd: [bunExe(), "install"],
|
||||
env: bunEnv,
|
||||
cwd: String(dir),
|
||||
stderr: "inherit",
|
||||
stdout: "inherit",
|
||||
});
|
||||
|
||||
expect(await exited).toBe(0);
|
||||
|
||||
// Should have installed version 2.0.0 (3 days old) instead of 3.0.0 (1 hour old)
|
||||
// Check that version 2.0.0 was installed (not 3.0.0)
|
||||
const installedPkg = JSON.parse(await Bun.file(join(String(dir), "node_modules", "test-package", "package.json")).text());
|
||||
expect(installedPkg.version).toBe("2.0.0");
|
||||
} finally {
|
||||
registry.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("should allow excluded packages to bypass minimum age", async () => {
|
||||
const registry = new MinimumAgeRegistry();
|
||||
const port = await registry.start();
|
||||
|
||||
try {
|
||||
using dir = tempDir("minimum-release-age-exclude", {
|
||||
"package.json": JSON.stringify({
|
||||
name: "test-project",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"test-package": "*",
|
||||
"recent-only-package": "*",
|
||||
},
|
||||
}),
|
||||
"bunfig.toml": `
|
||||
[install]
|
||||
registry = "http://localhost:${port}"
|
||||
minimumReleaseAge = 10080 # 1 week in minutes
|
||||
minimumReleaseAgeExclude = ["recent-only-package"]
|
||||
`,
|
||||
});
|
||||
|
||||
const { exited } = Bun.spawn({
|
||||
cmd: [bunExe(), "install"],
|
||||
env: bunEnv,
|
||||
cwd: String(dir),
|
||||
stderr: "inherit",
|
||||
stdout: "inherit",
|
||||
});
|
||||
|
||||
expect(await exited).toBe(0);
|
||||
|
||||
// test-package should get version 1.0.0 (1 week old) due to age restriction
|
||||
// recent-only-package should get 1.0.0 (1 hour old) as it's excluded
|
||||
const pkgJson = JSON.parse(await Bun.file(join(String(dir), "package.json")).text());
|
||||
const installed = Object.keys(pkgJson.dependencies || {}).map(name => `${name}@${pkgJson.dependencies[name]}`);
|
||||
const lockfile = installed.join(",");
|
||||
expect(lockfile).toContain("test-package");
|
||||
expect(lockfile).toContain("recent-only-package");
|
||||
expect(lockfile).toContain("1.0.0");
|
||||
expect(lockfile).not.toContain("3.0.0"); // test-package shouldn't use latest
|
||||
} finally {
|
||||
registry.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("should BLOCK exact version when it violates minimum age policy", async () => {
|
||||
const registry = new MinimumAgeRegistry();
|
||||
const port = await registry.start();
|
||||
|
||||
try {
|
||||
using dir = tempDir("minimum-release-age-exact", {
|
||||
"package.json": JSON.stringify({
|
||||
name: "test-project",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"test-package": "3.0.0", // Exact version - MUST BE BLOCKED for security
|
||||
},
|
||||
}),
|
||||
"bunfig.toml": `
|
||||
[install]
|
||||
registry = "http://localhost:${port}"
|
||||
minimumReleaseAge = 10080 # 1 week
|
||||
`,
|
||||
});
|
||||
|
||||
const proc = Bun.spawn({
|
||||
cmd: [bunExe(), "install"],
|
||||
env: bunEnv,
|
||||
cwd: String(dir),
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
});
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
proc.stdout.text(),
|
||||
proc.stderr.text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
// SECURITY: Should FAIL - exact versions NEVER bypass security policy
|
||||
expect(exitCode).not.toBe(0);
|
||||
const output = stdout + stderr;
|
||||
// Should mention the blocked package
|
||||
expect(output).toContain("test-package");
|
||||
} finally {
|
||||
registry.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("should show clear error when package is blocked by minimumReleaseAge", async () => {
|
||||
const registry = new MinimumAgeRegistry();
|
||||
const port = await registry.start();
|
||||
|
||||
try {
|
||||
using dir = tempDir("minimum-release-age-error", {
|
||||
"package.json": JSON.stringify({
|
||||
name: "test-project",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"recent-only-package": "*",
|
||||
},
|
||||
}),
|
||||
"bunfig.toml": `
|
||||
[install]
|
||||
registry = "http://localhost:${port}"
|
||||
minimumReleaseAge = 10080 # 1 week
|
||||
`,
|
||||
});
|
||||
|
||||
const proc = Bun.spawn({
|
||||
cmd: [bunExe(), "install"],
|
||||
env: bunEnv,
|
||||
cwd: String(dir),
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
});
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
proc.stdout.text(),
|
||||
proc.stderr.text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
// Should fail with a clear error message
|
||||
expect(exitCode).not.toBe(0);
|
||||
const output = stdout + stderr;
|
||||
// TODO: Check for a meaningful error message mentioning the package and age restriction
|
||||
// For now, just check it fails. The error message improvement can be added to the implementation
|
||||
expect(output).toBeTruthy();
|
||||
} finally {
|
||||
registry.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("should show clear error when bun add fails due to minimumReleaseAge", async () => {
|
||||
const registry = new MinimumAgeRegistry();
|
||||
const port = await registry.start();
|
||||
|
||||
try {
|
||||
using dir = tempDir("minimum-release-age-add-error", {
|
||||
"package.json": JSON.stringify({
|
||||
name: "test-project",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
"bunfig.toml": `
|
||||
[install]
|
||||
registry = "http://localhost:${port}"
|
||||
minimumReleaseAge = 10080 # 1 week
|
||||
`,
|
||||
});
|
||||
|
||||
const proc = Bun.spawn({
|
||||
cmd: [bunExe(), "add", "recent-only-package"],
|
||||
env: bunEnv,
|
||||
cwd: String(dir),
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
});
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
proc.stdout.text(),
|
||||
proc.stderr.text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
// Should fail with a clear error message
|
||||
expect(exitCode).not.toBe(0);
|
||||
const output = stdout + stderr;
|
||||
// Should mention the package name in the error
|
||||
expect(output).toContain("recent-only-package");
|
||||
} finally {
|
||||
registry.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("should work with zero minimum age (disabled)", async () => {
|
||||
const registry = new MinimumAgeRegistry();
|
||||
const port = await registry.start();
|
||||
|
||||
try {
|
||||
using dir = tempDir("minimum-release-age-disabled", {
|
||||
"package.json": JSON.stringify({
|
||||
name: "test-project",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"test-package": "*",
|
||||
},
|
||||
}),
|
||||
"bunfig.toml": `
|
||||
[install]
|
||||
registry = "http://localhost:${port}"
|
||||
minimumReleaseAge = 0
|
||||
`,
|
||||
});
|
||||
|
||||
const { exited } = Bun.spawn({
|
||||
cmd: [bunExe(), "install"],
|
||||
env: bunEnv,
|
||||
cwd: String(dir),
|
||||
stderr: "inherit",
|
||||
stdout: "inherit",
|
||||
});
|
||||
|
||||
expect(await exited).toBe(0);
|
||||
|
||||
// Should get the latest version (3.0.0) when minimumReleaseAge is 0
|
||||
const pkgJson = JSON.parse(await Bun.file(join(String(dir), "package.json")).text());
|
||||
const installed = Object.keys(pkgJson.dependencies || {}).map(name => `${name}@${pkgJson.dependencies[name]}`);
|
||||
const lockfile = installed.join(",");
|
||||
expect(lockfile).toContain("3.0.0");
|
||||
} finally {
|
||||
registry.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("should respect existing lockfile with forced version", async () => {
|
||||
const registry = new MinimumAgeRegistry();
|
||||
const port = await registry.start();
|
||||
|
||||
try {
|
||||
using dir = tempDir("minimum-release-age-lockfile", {
|
||||
"package.json": JSON.stringify({
|
||||
name: "test-project",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"test-package": "*",
|
||||
},
|
||||
}),
|
||||
"bunfig.toml": `
|
||||
[install]
|
||||
registry = "http://localhost:${port}"
|
||||
`,
|
||||
});
|
||||
|
||||
// First install without age restriction
|
||||
let { exited } = Bun.spawn({
|
||||
cmd: [bunExe(), "install"],
|
||||
env: bunEnv,
|
||||
cwd: String(dir),
|
||||
stderr: "inherit",
|
||||
stdout: "inherit",
|
||||
});
|
||||
|
||||
expect(await exited).toBe(0);
|
||||
|
||||
// Should have latest version (3.0.0) in lockfile
|
||||
let lockfile = await Bun.file(join(String(dir), "bun.lockb")).text();
|
||||
expect(lockfile).toContain("3.0.0");
|
||||
|
||||
// Now add age restriction
|
||||
await Bun.write(
|
||||
join(String(dir), "bunfig.toml"),
|
||||
`
|
||||
[install]
|
||||
registry = "http://localhost:${port}"
|
||||
minimumReleaseAge = 10080 # 1 week
|
||||
`,
|
||||
);
|
||||
|
||||
// Install again with existing lockfile
|
||||
const proc3 = Bun.spawn({
|
||||
cmd: [bunExe(), "install"],
|
||||
env: bunEnv,
|
||||
cwd: String(dir),
|
||||
stderr: "inherit",
|
||||
stdout: "inherit",
|
||||
});
|
||||
const exitCode3 = await proc3.exited;
|
||||
|
||||
expect(exitCode3).toBe(0);
|
||||
|
||||
// Should still have 3.0.0 from lockfile
|
||||
lockfile = await Bun.file(join(String(dir), "bun.lockb")).text();
|
||||
expect(lockfile).toContain("3.0.0");
|
||||
} finally {
|
||||
registry.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("should succeed with binary lockfile (bun.lockb) and --frozen-lockfile when created before minimumReleaseAge policy", async () => {
|
||||
const registry = new MinimumAgeRegistry();
|
||||
const port = await registry.start();
|
||||
|
||||
try {
|
||||
using dir = tempDir("minimum-release-age-frozen", {
|
||||
"package.json": JSON.stringify({
|
||||
name: "test-project",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"test-package": "*",
|
||||
},
|
||||
}),
|
||||
"bunfig.toml": `
|
||||
[install]
|
||||
registry = "http://localhost:${port}"
|
||||
`,
|
||||
});
|
||||
|
||||
// First install without age restriction - should get latest (3.0.0)
|
||||
// This creates a binary lockfile by default
|
||||
let { exited } = Bun.spawn({
|
||||
cmd: [bunExe(), "install"],
|
||||
env: bunEnv,
|
||||
cwd: String(dir),
|
||||
stderr: "inherit",
|
||||
stdout: "inherit",
|
||||
});
|
||||
|
||||
expect(await exited).toBe(0);
|
||||
|
||||
// Verify we have a binary lockfile
|
||||
const hasBinaryLockfile = await Bun.file(join(String(dir), "bun.lockb")).exists();
|
||||
expect(hasBinaryLockfile).toBe(true);
|
||||
|
||||
// Verify we have 3.0.0 in lockfile
|
||||
let lockfile = await Bun.file(join(String(dir), "bun.lockb")).text();
|
||||
expect(lockfile).toContain("3.0.0");
|
||||
|
||||
// Now add minimumReleaseAge restriction
|
||||
await Bun.write(
|
||||
join(String(dir), "bunfig.toml"),
|
||||
`
|
||||
[install]
|
||||
registry = "http://localhost:${port}"
|
||||
minimumReleaseAge = 10080 # 1 week - would exclude 3.0.0 for new installs
|
||||
`,
|
||||
);
|
||||
|
||||
// Install with --frozen-lockfile should succeed
|
||||
// The lockfile was created before the policy was added, so we trust it
|
||||
// This maintains backwards compatibility - existing lockfiles continue to work
|
||||
const proc2 = Bun.spawn({
|
||||
cmd: [bunExe(), "install", "--frozen-lockfile"],
|
||||
env: bunEnv,
|
||||
cwd: String(dir),
|
||||
stderr: "inherit",
|
||||
stdout: "inherit",
|
||||
});
|
||||
const exitCode2 = await proc2.exited;
|
||||
|
||||
// Should succeed - frozen lockfile uses what's locked
|
||||
expect(exitCode2).toBe(0);
|
||||
|
||||
// Should still have 3.0.0
|
||||
lockfile = await Bun.file(join(String(dir), "bun.lockb")).text();
|
||||
expect(lockfile).toContain("3.0.0");
|
||||
|
||||
// But regular install (without frozen) would try to downgrade
|
||||
// Remove node_modules to force re-resolution
|
||||
await Bun.$`rm -rf ${join(String(dir), "node_modules")}`.quiet();
|
||||
|
||||
const proc3 = Bun.spawn({
|
||||
cmd: [bunExe(), "install"],
|
||||
env: bunEnv,
|
||||
cwd: String(dir),
|
||||
stderr: "inherit",
|
||||
stdout: "inherit",
|
||||
});
|
||||
const exitCode3 = await proc3.exited;
|
||||
|
||||
expect(exitCode3).toBe(0);
|
||||
|
||||
// Without frozen, it should respect minimumReleaseAge and downgrade to 1.0.0
|
||||
lockfile = await Bun.file(join(String(dir), "bun.lockb")).text();
|
||||
expect(lockfile).toContain("1.0.0");
|
||||
expect(lockfile).not.toContain("3.0.0");
|
||||
} finally {
|
||||
registry.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle text lockfile (bun.lock) with --frozen-lockfile and minimumReleaseAge", async () => {
|
||||
const registry = new MinimumAgeRegistry();
|
||||
const port = await registry.start();
|
||||
|
||||
try {
|
||||
using dir = tempDir("minimum-release-age-text-lockfile", {
|
||||
"package.json": JSON.stringify({
|
||||
name: "test-project",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"test-package": "*",
|
||||
},
|
||||
}),
|
||||
"bunfig.toml": `
|
||||
[install]
|
||||
registry = "http://localhost:${port}"
|
||||
`,
|
||||
});
|
||||
|
||||
// First install without age restriction - should get latest (3.0.0)
|
||||
// Force text lockfile with --yarn
|
||||
let { exited } = Bun.spawn({
|
||||
cmd: [bunExe(), "install", "--yarn"],
|
||||
env: bunEnv,
|
||||
cwd: String(dir),
|
||||
stderr: "inherit",
|
||||
stdout: "inherit",
|
||||
});
|
||||
|
||||
expect(await exited).toBe(0);
|
||||
|
||||
// Verify we have a text lockfile (bun.lock not bun.lockb)
|
||||
const hasTextLockfile = await Bun.file(join(String(dir), "bun.lock")).exists();
|
||||
const hasBinaryLockfile = await Bun.file(join(String(dir), "bun.lockb")).exists();
|
||||
expect(hasTextLockfile).toBe(true);
|
||||
expect(hasBinaryLockfile).toBe(false);
|
||||
|
||||
// Now add minimumReleaseAge restriction
|
||||
await Bun.write(
|
||||
join(String(dir), "bunfig.toml"),
|
||||
`
|
||||
[install]
|
||||
registry = "http://localhost:${port}"
|
||||
minimumReleaseAge = 10080 # 1 week - would exclude 3.0.0 for new installs
|
||||
`,
|
||||
);
|
||||
|
||||
// Install with --frozen-lockfile on text lockfile
|
||||
// Text lockfile doesn't store publish_time, so it might error or succeed
|
||||
// depending on implementation - both are acceptable
|
||||
const proc2 = Bun.spawn({
|
||||
cmd: [bunExe(), "install", "--frozen-lockfile"],
|
||||
env: bunEnv,
|
||||
cwd: String(dir),
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
});
|
||||
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
proc2.stdout.text(),
|
||||
proc2.stderr.text(),
|
||||
]);
|
||||
const exitCode2 = await proc2.exited;
|
||||
|
||||
// For text lockfile: either behavior is acceptable
|
||||
// - Success: lockfile doesn't have timestamp info to validate
|
||||
// - Failure: conservative security approach
|
||||
if (exitCode2 === 0) {
|
||||
// If it succeeded, package should be installed
|
||||
const installedPkg = JSON.parse(await Bun.file(join(String(dir), "node_modules", "test-package", "package.json")).text());
|
||||
expect(installedPkg.version).toBe("3.0.0");
|
||||
} else {
|
||||
// If it failed, should have security-related error
|
||||
const output = stdout + stderr;
|
||||
expect(output).toMatch(/minimumReleaseAge|security|recently published/i);
|
||||
}
|
||||
} finally {
|
||||
registry.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("should succeed with --frozen-lockfile when lockfile already respects minimumReleaseAge", async () => {
|
||||
const registry = new MinimumAgeRegistry();
|
||||
const port = await registry.start();
|
||||
|
||||
try {
|
||||
using dir = tempDir("minimum-release-age-frozen-ok", {
|
||||
"package.json": JSON.stringify({
|
||||
name: "test-project",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"test-package": "*",
|
||||
},
|
||||
}),
|
||||
"bunfig.toml": `
|
||||
[install]
|
||||
registry = "http://localhost:${port}"
|
||||
minimumReleaseAge = 10080 # 1 week
|
||||
`,
|
||||
});
|
||||
|
||||
// First install WITH age restriction - should get 1.0.0
|
||||
let { exited } = Bun.spawn({
|
||||
cmd: [bunExe(), "install"],
|
||||
env: bunEnv,
|
||||
cwd: String(dir),
|
||||
stderr: "inherit",
|
||||
stdout: "inherit",
|
||||
});
|
||||
|
||||
expect(await exited).toBe(0);
|
||||
|
||||
// Verify we have 1.0.0 in lockfile
|
||||
let lockfile = await Bun.file(join(String(dir), "bun.lockb")).text();
|
||||
expect(lockfile).toContain("1.0.0");
|
||||
expect(lockfile).not.toContain("3.0.0");
|
||||
|
||||
// Now install with --frozen-lockfile should work fine
|
||||
const proc2 = Bun.spawn({
|
||||
cmd: [bunExe(), "install", "--frozen-lockfile"],
|
||||
env: bunEnv,
|
||||
cwd: String(dir),
|
||||
stderr: "inherit",
|
||||
stdout: "inherit",
|
||||
});
|
||||
const exitCode2 = await proc2.exited;
|
||||
|
||||
// Should succeed because lockfile already respects the age restriction
|
||||
expect(await exited).toBe(0);
|
||||
} finally {
|
||||
registry.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user