Skip to content
1 change: 1 addition & 0 deletions src/install/PackageManager.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1260,6 +1260,7 @@ pub const updatePackageJSONAndInstallCatchError = @import("./PackageManager/upda
pub const updatePackageJSONAndInstallWithManager = @import("./PackageManager/updatePackageJSONAndInstall.zig").updatePackageJSONAndInstallWithManager;

pub const populateManifestCache = @import("./PackageManager/PopulateManifestCache.zig").populateManifestCache;
pub const enforceLockfileAgeFilter = @import("./PackageManager/PopulateManifestCache.zig").enforceLockfileAgeFilter;

const string = []const u8;
const stringZ = [:0]const u8;
Expand Down
102 changes: 102 additions & 0 deletions src/install/PackageManager/PopulateManifestCache.zig
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,115 @@
}
}

/// After resolution, verify every npm-tagged package in the lockfile
/// satisfies the configured `minimumReleaseAge` cooldown.
///
/// The resolution-time filter (`findBestVersionWithFilter`, etc.) only
/// runs when Bun is actually picking a new version. If the lockfile
/// already pins a version — e.g. it was resolved before the cooldown
/// was configured, or by a developer whose local bunfig was less strict
/// — that install path skips the filter entirely. Without this gate,
/// `bun install` (and `bun install --frozen-lockfile`) will happily
/// install a locked version that was published inside the cooldown
/// window, defeating the supply-chain protection the setting is meant
/// to provide.
///
/// This loads manifests for every locked npm package, looks up the
/// exact pinned version's publish timestamp, and aggregates every
/// violation into `manager.log` as an error. Excludes from
/// `minimumReleaseAgeExcludes` are honored.
pub fn enforceLockfileAgeFilter(manager: *PackageManager) !void {
const min_age_ms = manager.options.minimum_release_age_ms orelse return;

// Make sure manifests are loaded from disk / network before we
// inspect publish timestamps. `populateManifestCache` already
// honors `minimum_release_age_ms` by requesting extended manifests.
try populateManifestCache(manager, .all);

const lockfile = manager.lockfile;
const pkgs = lockfile.packages.slice();
const pkg_resolutions = pkgs.items(.resolution);
const pkg_names = pkgs.items(.name);
const pkg_name_hashes = pkgs.items(.name_hash);
const string_buf = lockfile.buffers.string_bytes.items;
const min_age_seconds = min_age_ms / std.time.ms_per_s;

for (pkg_resolutions, pkg_names, pkg_name_hashes) |resolution, name, name_hash| {
if (resolution.tag != .npm) continue;

const name_str = name.slice(string_buf);

// Fail closed: if we cannot reach the manifest or locate the exact
// pinned version, we cannot prove the version satisfies the cooldown.
// Silently skipping would re-open the lockfile bypass this gate is
// meant to close (e.g. a version that was unpublished from the
// registry, or a manifest fetch that couldn't be completed).
const manifest = manager.manifests.byNameHash(
manager,
manager.scopeForPackageName(name_str),
name_hash,
.load_from_memory_fallback_to_disk,
true,
) orelse {
if (isExcludedByName(name_str, manager.options.minimum_release_age_excludes)) continue;
manager.log.addErrorFmt(
null,
logger.Loc.Empty,
manager.allocator,
"Package \"{s}@{f}\" in lockfile could not be checked against minimum release age (manifest unavailable)",
.{ name_str, resolution.value.npm.version.fmt(string_buf) },
) catch bun.outOfMemory();
continue;

Check failure on line 210 in src/install/PackageManager/PopulateManifestCache.zig

View check run for this annotation

Claude / Claude Code Review

Banned 'catch bun.outOfMemory()' pattern fails ban-words.test.ts

These three new ` catch bun.outOfMemory()` calls (here and at lines ~222 and ~237) trip the repo's ban-words check — `test/internal/ban-limits.json` sets the limit for this pattern to 0, which is why `test/internal/ban-words.test.ts` is failing in CI on this PR. Replace each with `bun.handleOom(manager.log.addErrorFmt(...))` (or `... catch |e| bun.handleOom(e)`) per the convention in `src/CLAUDE.md`.
Comment thread
robobun marked this conversation as resolved.
Outdated
};

if (manifest.shouldExcludeFromAgeFilter(manager.options.minimum_release_age_excludes)) continue;

const find_result = manifest.findByVersion(resolution.value.npm.version) orelse {
manager.log.addErrorFmt(
null,
logger.Loc.Empty,
manager.allocator,
"Package \"{s}@{f}\" in lockfile could not be checked against minimum release age (version not in manifest)",
.{ name_str, resolution.value.npm.version.fmt(string_buf) },
) catch bun.outOfMemory();
continue;
};
if (!Npm.PackageManifest.isPackageVersionTooRecent(find_result.package, min_age_ms)) continue;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

manager.log.addErrorFmt(
null,
logger.Loc.Empty,
manager.allocator,
"Package \"{s}@{f}\" in lockfile was published within minimum release age of {d} seconds",
.{
name_str,
resolution.value.npm.version.fmt(string_buf),
min_age_seconds,
},
) catch bun.outOfMemory();
}
}

/// Mirrors `PackageManifest.shouldExcludeFromAgeFilter` for the code path
/// where no manifest is available (the manifest lookup above returned null).
/// Kept in sync with the real check in `src/install/npm.zig`.
fn isExcludedByName(name: []const u8, exclusions: ?[]const []const u8) bool {
const excl = exclusions orelse return false;
for (excl) |entry| {
if (bun.strings.eql(entry, name)) return true;
}
Comment thread
robobun marked this conversation as resolved.
return false;
}

const std = @import("std");

const bun = @import("bun");
const Output = bun.Output;
const logger = bun.logger;

const Dependency = bun.install.Dependency;
const DependencyID = bun.install.DependencyID;
const Npm = bun.install.Npm;
const PackageID = bun.install.PackageID;
const PackageManager = bun.install.PackageManager;
const Resolution = bun.install.Resolution;
Expand Down
15 changes: 15 additions & 0 deletions src/install/PackageManager/install_with_manager.zig
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,21 @@ pub fn installWithManager(

const save_format = load_result.saveFormat(&manager.options);

// Enforce `minimumReleaseAge` against versions already pinned in the
// lockfile. Resolution-time filtering only fires when Bun is choosing
// a new version; without this gate, a locked version that was
// published inside the cooldown window would be installed silently —
// exactly the scenario the setting is meant to prevent.
if (manager.options.minimum_release_age_ms != null) {
try manager.enforceLockfileAgeFilter();
if (manager.log.hasErrors()) {
try manager.log.print(Output.errorWriter());
manager.log.reset();
Output.note("remove the offending version from bun.lock, raise the bound, or add it to <d>install.minimumReleaseAgeExcludes<r>", .{});
Global.crash();
Comment thread
robobun marked this conversation as resolved.
Outdated
}
}
Comment thread
robobun marked this conversation as resolved.
Outdated

if (manager.options.lockfile_only) {
// save the lockfile and exit. make sure metahash is generated for binary lockfile

Expand Down
Loading
Loading