From f240c84f74af2105708913151202b6750d3b92b3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 11 May 2026 23:49:57 +0000 Subject: [PATCH 1/8] install: enforce minimumReleaseAge against lockfile-pinned versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolution-time cooldown filter only runs when Bun is choosing a new version. If a version was already pinned in bun.lock before the cooldown was configured (or pinned by a developer whose local bunfig was less strict), subsequent `bun install` and `bun install --frozen-lockfile` runs installed it unconditionally — defeating the supply-chain use case the setting is meant to cover. Add a post-resolution gate in installWithManager that walks every npm-tagged package in the lockfile, loads its manifest (honoring extended-manifest fetching when minimumReleaseAge is set), looks up the pinned version's publish timestamp, and errors out with every violation when any version falls inside the cooldown window. Excludes from `install.minimumReleaseAgeExcludes` are honored. Fixes #30525 --- src/install/PackageManager.zig | 1 + .../PackageManager/PopulateManifestCache.zig | 66 ++++++++ .../PackageManager/install_with_manager.zig | 15 ++ test/cli/install/minimum-release-age.test.ts | 149 ++++++++++++++---- 4 files changed, 198 insertions(+), 33 deletions(-) diff --git a/src/install/PackageManager.zig b/src/install/PackageManager.zig index d78c32759c9..4f83be2c5cb 100644 --- a/src/install/PackageManager.zig +++ b/src/install/PackageManager.zig @@ -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; diff --git a/src/install/PackageManager/PopulateManifestCache.zig b/src/install/PackageManager/PopulateManifestCache.zig index 3974b9cf11c..99994a97b46 100644 --- a/src/install/PackageManager/PopulateManifestCache.zig +++ b/src/install/PackageManager/PopulateManifestCache.zig @@ -149,13 +149,79 @@ pub fn populateManifestCache(manager: *PackageManager, packages: Packages) !void } } +/// 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); + const manifest = manager.manifests.byNameHash( + manager, + manager.scopeForPackageName(name_str), + name_hash, + .load_from_memory_fallback_to_disk, + true, + ) orelse continue; + + if (manifest.shouldExcludeFromAgeFilter(manager.options.minimum_release_age_excludes)) continue; + + const find_result = manifest.findByVersion(resolution.value.npm.version) orelse continue; + if (!Npm.PackageManifest.isPackageVersionTooRecent(find_result.package, min_age_ms)) continue; + + 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(); + } +} + 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; diff --git a/src/install/PackageManager/install_with_manager.zig b/src/install/PackageManager/install_with_manager.zig index 2debc3d08c7..9db694e889e 100644 --- a/src/install/PackageManager/install_with_manager.zig +++ b/src/install/PackageManager/install_with_manager.zig @@ -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 install.minimumReleaseAgeExcludes", .{}); + Global.crash(); + } + } + if (manager.options.lockfile_only) { // save the lockfile and exit. make sure metahash is generated for binary lockfile diff --git a/test/cli/install/minimum-release-age.test.ts b/test/cli/install/minimum-release-age.test.ts index 55aadbbec19..4eee0938290 100644 --- a/test/cli/install/minimum-release-age.test.ts +++ b/test/cli/install/minimum-release-age.test.ts @@ -1795,7 +1795,14 @@ registry = "${mockRegistryUrl}"`, }); describe("frozen lockfile", () => { - test("frozen lockfile preserves existing versions regardless of minimum-release-age", async () => { + // Regression test for https://github.com/oven-sh/bun/issues/30525 + // + // Before the fix, resolution-time filtering was the only gate, so a version + // already pinned in bun.lock would install unconditionally — even under + // --frozen-lockfile. That defeats the supply-chain use case: a team adding + // the cooldown after an incident was no safer than one that didn't, because + // already-resolved bad versions remained installable. + test("rejects locked version that violates minimum-release-age", async () => { using dir = tempDir("frozen-lockfile", { "package.json": JSON.stringify({ dependencies: { @@ -1806,23 +1813,24 @@ registry = "${mockRegistryUrl}"`, }); // First install without minimum-release-age to get latest - let proc = Bun.spawn({ - cmd: [bunExe(), "install"], - cwd: String(dir), - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - - let exitCode = await proc.exited; - expect(exitCode).toBe(0); + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + expect(exitCode).toBe(0); + } const lockfile = await Bun.file(`${dir}/bun.lock`).text(); - expect(lockfile).toContain("regular-package@3.0.0"); // Latest version + expect(lockfile).toContain("regular-package@3.0.0"); // Latest version (1 day old) - // Now try with frozen lockfile and minimum-release-age - // Frozen lockfile means no changes to lockfile - versions stay as-is - proc = Bun.spawn({ + // --frozen-lockfile with a cooldown the locked version violates must fail. + await using proc = Bun.spawn({ cmd: [ bunExe(), "install", @@ -1837,40 +1845,116 @@ registry = "${mockRegistryUrl}"`, stderr: "pipe", }); - const exitCode2 = await proc.exited; + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(1); + expect(stderr).toContain("regular-package"); + expect(stderr).toContain("3.0.0"); + expect(stderr.toLowerCase()).toContain("minimum release age"); + }); - // Should succeed - frozen lockfile means no changes, even if version is "too recent" - expect(exitCode2).toBe(0); + test("bun install (non-frozen) also rejects locked version that violates minimum-release-age", async () => { + using dir = tempDir("non-frozen-lockfile", { + "package.json": JSON.stringify({ + dependencies: { + "regular-package": "*", + }, + }), + ".npmrc": `registry=${mockRegistryUrl}`, + }); + + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(await proc.exited).toBe(0); + } + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); - // Lockfile should remain unchanged - const lockfileAfter = await Bun.file(`${dir}/bun.lock`).text(); - expect(lockfileAfter).toContain("regular-package@3.0.0"); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(1); + expect(stderr).toContain("regular-package"); + expect(stderr).toContain("3.0.0"); }); - test("works with frozen lockfile when versions are old enough", async () => { - using dir = tempDir("frozen-old-versions", { + test("excludes list lets a lockfile-pinned violator through", async () => { + using dir = tempDir("frozen-lockfile-excluded", { "package.json": JSON.stringify({ dependencies: { - "regular-package": "2.1.0", // Old enough version + "regular-package": "*", }, }), ".npmrc": `registry=${mockRegistryUrl}`, }); - // First install to create lockfile - let proc = Bun.spawn({ - cmd: [bunExe(), "install"], + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(await proc.exited).toBe(0); + } + + // Now add a bunfig that configures the cooldown + an exclude for regular-package. + await Bun.write( + `${dir}/bunfig.toml`, + `[install]\nminimumReleaseAge = ${5 * SECONDS_PER_DAY}\nminimumReleaseAgeExcludes = ["regular-package"]\nregistry = "${mockRegistryUrl}"\n`, + ); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--frozen-lockfile", "--no-verify"], cwd: String(dir), env: bunEnv, stdout: "pipe", stderr: "pipe", }); - let exitCode = await proc.exited; - expect(exitCode).toBe(0); + expect(await proc.exited).toBe(0); - // Install with frozen lockfile and minimum-release-age - proc = Bun.spawn({ + const after = await Bun.file(`${dir}/bun.lock`).text(); + expect(after).toContain("regular-package@3.0.0"); + }); + + test("works with frozen lockfile when versions are old enough", async () => { + using dir = tempDir("frozen-old-versions", { + "package.json": JSON.stringify({ + dependencies: { + "regular-package": "2.1.0", // Old enough version (6 days) + }, + }), + ".npmrc": `registry=${mockRegistryUrl}`, + }); + + // First install to create lockfile + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(await proc.exited).toBe(0); + } + + // Install with frozen lockfile and minimum-release-age — locked version is + // older than the cooldown, so it should install. + await using proc = Bun.spawn({ cmd: [ bunExe(), "install", @@ -1885,8 +1969,7 @@ registry = "${mockRegistryUrl}"`, stderr: "pipe", }); - exitCode = await proc.exited; - expect(exitCode).toBe(0); + expect(await proc.exited).toBe(0); const lockfile = await Bun.file(`${dir}/bun.lock`).text(); expect(lockfile).toContain("regular-package@2.1.0"); From 7772a1f30fe7d0a63e474e779e5a88ecc4500ca9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 12 May 2026 00:03:39 +0000 Subject: [PATCH 2/8] install: fail closed when locked version's metadata is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Silently continuing when `manifests.byNameHash` or `findByVersion` returns null would reopen the lockfile bypass the surrounding gate exists to close — a supply-chain actor who unpublished (or fabricated) a too-young version could slip past the cooldown because we had nothing to check its publish timestamp against. Surface both cases as errors with their own remediation context, and honor the bare-name entries of `minimumReleaseAgeExcludes` for the manifest-unavailable branch (the `name@version` form — if/when that PR lands — still requires a manifest lookup to confirm the pin). Add a regression test that tampers with bun.lock to point at a version the registry does not list and asserts the install aborts. --- .../PackageManager/PopulateManifestCache.zig | 40 +++++++++++++- test/cli/install/minimum-release-age.test.ts | 55 +++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/install/PackageManager/PopulateManifestCache.zig b/src/install/PackageManager/PopulateManifestCache.zig index 99994a97b46..f2253949f38 100644 --- a/src/install/PackageManager/PopulateManifestCache.zig +++ b/src/install/PackageManager/PopulateManifestCache.zig @@ -186,17 +186,42 @@ pub fn enforceLockfileAgeFilter(manager: *PackageManager) !void { 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 continue; + ) 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; + }; if (manifest.shouldExcludeFromAgeFilter(manager.options.minimum_release_age_excludes)) continue; - const find_result = manifest.findByVersion(resolution.value.npm.version) orelse 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; manager.log.addErrorFmt( @@ -213,6 +238,17 @@ pub fn enforceLockfileAgeFilter(manager: *PackageManager) !void { } } +/// 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; + } + return false; +} + const std = @import("std"); const bun = @import("bun"); diff --git a/test/cli/install/minimum-release-age.test.ts b/test/cli/install/minimum-release-age.test.ts index 4eee0938290..010d73f27da 100644 --- a/test/cli/install/minimum-release-age.test.ts +++ b/test/cli/install/minimum-release-age.test.ts @@ -1974,6 +1974,61 @@ registry = "${mockRegistryUrl}"`, const lockfile = await Bun.file(`${dir}/bun.lock`).text(); expect(lockfile).toContain("regular-package@2.1.0"); }); + + // Fail-closed: when we cannot verify a locked version's publish date + // against the manifest (e.g. the exact version was unpublished from + // the registry after it was resolved into bun.lock), the install must + // block rather than silently skip. Otherwise a supply-chain actor + // could slip a too-young version past the gate by unpublishing it + // and re-publishing under the same number — or by planting a + // fabricated entry in a lockfile. + test("blocks install when locked version is absent from the manifest", async () => { + using dir = tempDir("frozen-lockfile-missing-version", { + "package.json": JSON.stringify({ + dependencies: { "regular-package": "2.1.0" }, + }), + ".npmrc": `registry=${mockRegistryUrl}`, + }); + + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(await proc.exited).toBe(0); + } + + // Point bun.lock at a version that the registry's manifest does not + // list — the next `bun install` has nothing to check the age against. + const lockfilePath = `${dir}/bun.lock`; + const original = await Bun.file(lockfilePath).text(); + await Bun.write(lockfilePath, original.replaceAll("regular-package@2.1.0", "regular-package@99.99.99")); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "install", + "--frozen-lockfile", + "--minimum-release-age", + `${5 * SECONDS_PER_DAY}`, + "--no-verify", + ], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(1); + expect(stderr).toContain("regular-package"); + expect(stderr).toContain("99.99.99"); + expect(stderr.toLowerCase()).toContain("minimum release age"); + }); }); describe("monorepo with linker modes", () => { From 65a86a0a25ea2a26b69500e1e93fd8582d3f2c2b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 12 May 2026 00:11:05 +0000 Subject: [PATCH 3/8] ci: retrigger From d99d97cc73e0ee162e57f4ef82065ae67e892996 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 12 May 2026 00:32:06 +0000 Subject: [PATCH 4/8] install: use bun.handleOom over catch bun.outOfMemory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ban-words check forbids bare `catch bun.outOfMemory()` — it can accidentally swallow non-OOM errors. Use `bun.handleOom(expr)` instead, which crashes only on `error.OutOfMemory` and propagates any other error intact. --- src/install/PackageManager/PopulateManifestCache.zig | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/install/PackageManager/PopulateManifestCache.zig b/src/install/PackageManager/PopulateManifestCache.zig index f2253949f38..7d4f611dfbd 100644 --- a/src/install/PackageManager/PopulateManifestCache.zig +++ b/src/install/PackageManager/PopulateManifestCache.zig @@ -200,31 +200,31 @@ pub fn enforceLockfileAgeFilter(manager: *PackageManager) !void { true, ) orelse { if (isExcludedByName(name_str, manager.options.minimum_release_age_excludes)) continue; - manager.log.addErrorFmt( + bun.handleOom(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; }; if (manifest.shouldExcludeFromAgeFilter(manager.options.minimum_release_age_excludes)) continue; const find_result = manifest.findByVersion(resolution.value.npm.version) orelse { - manager.log.addErrorFmt( + bun.handleOom(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; - manager.log.addErrorFmt( + bun.handleOom(manager.log.addErrorFmt( null, logger.Loc.Empty, manager.allocator, @@ -234,7 +234,7 @@ pub fn enforceLockfileAgeFilter(manager: *PackageManager) !void { resolution.value.npm.version.fmt(string_buf), min_age_seconds, }, - ) catch bun.outOfMemory(); + )); } } From fb8c2c5e200fc1f8cb01da98b1279b6ce0244050 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 12 May 2026 00:45:25 +0000 Subject: [PATCH 5/8] install: only emit minimum-release-age note for actual violations, honor --silent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit + claude[bot] review feedback: 1. The gate was firing on *any* error in `manager.log`, so a transient manifest-fetch failure (populateManifestCache funnels registry 5xx / parse errors into the same log) would crash with the misleading `remove the offending version from bun.lock...` note even though nothing violated the cooldown. Snapshot `log.errors` before the call and gate the note on the delta. Unrelated errors still crash the install (we can't safely proceed with missing manifest data) but get the generic error output instead of the cooldown-specific remediation. 2. The note + log print ran regardless of `log_level`. Wrap them in `if (log_level != .silent)` to match the frozen-lockfile crash path and other output sites in this function. 3. Reorder the three frozen-lockfile tests to assert stderr content before exit code — when a test fails, the stderr expectations surface the actual install output instead of a bare `expected 1 got 0`. --- .../PackageManager/install_with_manager.zig | 14 ++++++++++++-- test/cli/install/minimum-release-age.test.ts | 6 +++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/install/PackageManager/install_with_manager.zig b/src/install/PackageManager/install_with_manager.zig index 9db694e889e..04b690abd16 100644 --- a/src/install/PackageManager/install_with_manager.zig +++ b/src/install/PackageManager/install_with_manager.zig @@ -797,11 +797,21 @@ pub fn installWithManager( // 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) { + const errors_before = manager.log.errors; try manager.enforceLockfileAgeFilter(); + // Distinguish cooldown violations (added by enforceLockfileAgeFilter) + // from unrelated errors that populateManifestCache may have funneled + // into `manager.log` (e.g. a transient registry 5xx). The remediation + // note is only meaningful for the former. + const violations = manager.log.errors -| errors_before; if (manager.log.hasErrors()) { - try manager.log.print(Output.errorWriter()); + if (log_level != .silent) { + try manager.log.print(Output.errorWriter()); + if (violations > 0) { + Output.note("remove the offending version from bun.lock, raise the bound, or add it to install.minimumReleaseAgeExcludes", .{}); + } + } manager.log.reset(); - Output.note("remove the offending version from bun.lock, raise the bound, or add it to install.minimumReleaseAgeExcludes", .{}); Global.crash(); } } diff --git a/test/cli/install/minimum-release-age.test.ts b/test/cli/install/minimum-release-age.test.ts index 010d73f27da..42c476736c6 100644 --- a/test/cli/install/minimum-release-age.test.ts +++ b/test/cli/install/minimum-release-age.test.ts @@ -1847,10 +1847,10 @@ registry = "${mockRegistryUrl}"`, const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - expect(exitCode).toBe(1); expect(stderr).toContain("regular-package"); expect(stderr).toContain("3.0.0"); expect(stderr.toLowerCase()).toContain("minimum release age"); + expect(exitCode).toBe(1); }); test("bun install (non-frozen) also rejects locked version that violates minimum-release-age", async () => { @@ -1884,9 +1884,9 @@ registry = "${mockRegistryUrl}"`, const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - expect(exitCode).toBe(1); expect(stderr).toContain("regular-package"); expect(stderr).toContain("3.0.0"); + expect(exitCode).toBe(1); }); test("excludes list lets a lockfile-pinned violator through", async () => { @@ -2024,10 +2024,10 @@ registry = "${mockRegistryUrl}"`, const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - expect(exitCode).toBe(1); expect(stderr).toContain("regular-package"); expect(stderr).toContain("99.99.99"); expect(stderr.toLowerCase()).toContain("minimum release age"); + expect(exitCode).toBe(1); }); }); From 2ad015c7fea5b7dd4fa966f505af01c9b27b03f0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 12 May 2026 01:18:53 +0000 Subject: [PATCH 6/8] install: run minimumReleaseAge gate before security scanner, tighten non-frozen test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review feedback: 1. The gate was running after `performSecurityScanAfterResolution`, which meant a poisoned lockfile could still trigger scanner installation and network work before we rejected the install. Move the gate to directly after `verifyResolutions`, before the security scanner, so lockfile-age enforcement happens first. 2. The non-frozen `bun install` test was asserting only `regular-package` + `3.0.0` in stderr — a different failure mode could satisfy that. Add a case-insensitive `minimum release age` assertion so the test pins this specific gate, matching the frozen and absent-manifest variants. --- .../PackageManager/install_with_manager.zig | 52 ++++++++++--------- test/cli/install/minimum-release-age.test.ts | 1 + 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/src/install/PackageManager/install_with_manager.zig b/src/install/PackageManager/install_with_manager.zig index 04b690abd16..00a38789456 100644 --- a/src/install/PackageManager/install_with_manager.zig +++ b/src/install/PackageManager/install_with_manager.zig @@ -645,6 +645,33 @@ pub fn installWithManager( manager.verifyResolutions(log_level); + // 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. Runs before + // the security scanner so a poisoned lockfile can't trigger scanner + // install / network work before we reject it. + if (manager.options.minimum_release_age_ms != null) { + const errors_before = manager.log.errors; + try manager.enforceLockfileAgeFilter(); + // Distinguish cooldown violations (added by enforceLockfileAgeFilter) + // from unrelated errors that populateManifestCache may have funneled + // into `manager.log` (e.g. a transient registry 5xx). The remediation + // note is only meaningful for the former. + const violations = manager.log.errors -| errors_before; + if (manager.log.hasErrors()) { + if (log_level != .silent) { + try manager.log.print(Output.errorWriter()); + if (violations > 0) { + Output.note("remove the offending version from bun.lock, raise the bound, or add it to install.minimumReleaseAgeExcludes", .{}); + } + } + manager.log.reset(); + Global.crash(); + } + } + if (manager.options.security_scanner != null) { const is_subcommand_to_run_scanner = manager.subcommand == .add or manager.subcommand == .update or manager.subcommand == .install or manager.subcommand == .remove; @@ -791,31 +818,6 @@ 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) { - const errors_before = manager.log.errors; - try manager.enforceLockfileAgeFilter(); - // Distinguish cooldown violations (added by enforceLockfileAgeFilter) - // from unrelated errors that populateManifestCache may have funneled - // into `manager.log` (e.g. a transient registry 5xx). The remediation - // note is only meaningful for the former. - const violations = manager.log.errors -| errors_before; - if (manager.log.hasErrors()) { - if (log_level != .silent) { - try manager.log.print(Output.errorWriter()); - if (violations > 0) { - Output.note("remove the offending version from bun.lock, raise the bound, or add it to install.minimumReleaseAgeExcludes", .{}); - } - } - manager.log.reset(); - Global.crash(); - } - } - if (manager.options.lockfile_only) { // save the lockfile and exit. make sure metahash is generated for binary lockfile diff --git a/test/cli/install/minimum-release-age.test.ts b/test/cli/install/minimum-release-age.test.ts index 42c476736c6..4f8a2be7875 100644 --- a/test/cli/install/minimum-release-age.test.ts +++ b/test/cli/install/minimum-release-age.test.ts @@ -1886,6 +1886,7 @@ registry = "${mockRegistryUrl}"`, expect(stderr).toContain("regular-package"); expect(stderr).toContain("3.0.0"); + expect(stderr.toLowerCase()).toContain("minimum release age"); expect(exitCode).toBe(1); }); From 09ec15fa8a550f2bcbcad7c6b5b083706744871f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 12 May 2026 01:39:52 +0000 Subject: [PATCH 7/8] install: count lockfile-age violations inside enforceLockfileAgeFilter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix snapshotted `manager.log.errors` at the call site, before calling `enforceLockfileAgeFilter`. But that function's first step is `populateManifestCache(manager, .all)`, which funnels registry errors (5xx, parse failures) through `runTasks` into the same log. Those errors landed *after* the snapshot and got counted as cooldown violations — exactly the misattribution the snapshot comment claimed to prevent. Return the violation count from `enforceLockfileAgeFilter` itself, so only errors its own loop adds are counted. The caller still crashes on any logged error (manifest-fetch failures can't be silently ignored, we'd be installing with missing data), but the cooldown-specific remediation note fires only when we actually found cooldown violations. --- .../PackageManager/PopulateManifestCache.zig | 17 +++++++++++++++-- .../PackageManager/install_with_manager.zig | 14 +++++++------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/install/PackageManager/PopulateManifestCache.zig b/src/install/PackageManager/PopulateManifestCache.zig index 7d4f611dfbd..5972f827d1d 100644 --- a/src/install/PackageManager/PopulateManifestCache.zig +++ b/src/install/PackageManager/PopulateManifestCache.zig @@ -166,8 +166,14 @@ pub fn populateManifestCache(manager: *PackageManager, packages: Packages) !void /// 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; +/// +/// Returns the number of cooldown / fail-closed errors *this function* +/// added to `manager.log`. Errors that `populateManifestCache` funnels +/// in (registry 5xx, parse failures) are not counted — the caller uses +/// this to gate the cooldown-specific remediation note so unrelated +/// errors don't get misattributed to a lockfile age violation. +pub fn enforceLockfileAgeFilter(manager: *PackageManager) !u32 { + const min_age_ms = manager.options.minimum_release_age_ms orelse return 0; // Make sure manifests are loaded from disk / network before we // inspect publish timestamps. `populateManifestCache` already @@ -182,6 +188,8 @@ pub fn enforceLockfileAgeFilter(manager: *PackageManager) !void { const string_buf = lockfile.buffers.string_bytes.items; const min_age_seconds = min_age_ms / std.time.ms_per_s; + var violations: u32 = 0; + for (pkg_resolutions, pkg_names, pkg_name_hashes) |resolution, name, name_hash| { if (resolution.tag != .npm) continue; @@ -207,6 +215,7 @@ pub fn enforceLockfileAgeFilter(manager: *PackageManager) !void { "Package \"{s}@{f}\" in lockfile could not be checked against minimum release age (manifest unavailable)", .{ name_str, resolution.value.npm.version.fmt(string_buf) }, )); + violations += 1; continue; }; @@ -220,6 +229,7 @@ pub fn enforceLockfileAgeFilter(manager: *PackageManager) !void { "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) }, )); + violations += 1; continue; }; if (!Npm.PackageManifest.isPackageVersionTooRecent(find_result.package, min_age_ms)) continue; @@ -235,7 +245,10 @@ pub fn enforceLockfileAgeFilter(manager: *PackageManager) !void { min_age_seconds, }, )); + violations += 1; } + + return violations; } /// Mirrors `PackageManifest.shouldExcludeFromAgeFilter` for the code path diff --git a/src/install/PackageManager/install_with_manager.zig b/src/install/PackageManager/install_with_manager.zig index 00a38789456..650dd9eaafc 100644 --- a/src/install/PackageManager/install_with_manager.zig +++ b/src/install/PackageManager/install_with_manager.zig @@ -653,13 +653,13 @@ pub fn installWithManager( // the security scanner so a poisoned lockfile can't trigger scanner // install / network work before we reject it. if (manager.options.minimum_release_age_ms != null) { - const errors_before = manager.log.errors; - try manager.enforceLockfileAgeFilter(); - // Distinguish cooldown violations (added by enforceLockfileAgeFilter) - // from unrelated errors that populateManifestCache may have funneled - // into `manager.log` (e.g. a transient registry 5xx). The remediation - // note is only meaningful for the former. - const violations = manager.log.errors -| errors_before; + // enforceLockfileAgeFilter returns only the errors its own loop + // added — errors that `populateManifestCache` surfaces (registry + // 5xx, parse failures) are not counted. That lets us gate the + // cooldown-specific remediation note on actual lockfile-age + // errors while still crashing on any error so we never silently + // install with missing manifest data. + const violations = try manager.enforceLockfileAgeFilter(); if (manager.log.hasErrors()) { if (log_level != .silent) { try manager.log.print(Output.errorWriter()); From 0c9fe32e5a74e7f211a98a2ef7841a4a7922cd8f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 12 May 2026 03:07:58 +0000 Subject: [PATCH 8/8] install: fix reversed remediation hint, update docs for lockfile-age gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two claude[bot] review findings: 1. The remediation note said "raise the bound" — but the violation fires because a package is *too recent*, so raising `minimumReleaseAge` makes things worse. Change to "lower `install.minimumReleaseAge`". 2. `docs/pm/cli/install.mdx` still said "Only affects new package resolution — existing packages in bun.lock remain unchanged". That is the bypass this PR closes. Replace with a bullet describing the new lockfile-age gate and pointing at `minimumReleaseAgeExcludes`. --- docs/pm/cli/install.mdx | 2 +- src/install/PackageManager/install_with_manager.zig | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/pm/cli/install.mdx b/docs/pm/cli/install.mdx index 0e2a092d549..b352abc8df6 100644 --- a/docs/pm/cli/install.mdx +++ b/docs/pm/cli/install.mdx @@ -275,7 +275,7 @@ minimumReleaseAgeExcludes = ["@types/node", "typescript"] When the minimum age filter is active: -- Only affects new package resolution - existing packages in `bun.lock` remain unchanged +- Versions already pinned in `bun.lock` are also validated: if a locked version was published within the cooldown (or its publish timestamp can't be verified against the manifest), `bun install` fails with a per-package error and points at `install.minimumReleaseAgeExcludes` - All dependencies (direct and transitive) are filtered to meet the age requirement when being resolved - When versions are blocked by the age gate, a stability check detects rapid bugfix patterns - If multiple versions were published close together just outside your age gate, it extends the filter to skip those potentially unstable versions and selects an older, more mature version diff --git a/src/install/PackageManager/install_with_manager.zig b/src/install/PackageManager/install_with_manager.zig index 650dd9eaafc..137b3a7004c 100644 --- a/src/install/PackageManager/install_with_manager.zig +++ b/src/install/PackageManager/install_with_manager.zig @@ -664,7 +664,7 @@ pub fn installWithManager( if (log_level != .silent) { try manager.log.print(Output.errorWriter()); if (violations > 0) { - Output.note("remove the offending version from bun.lock, raise the bound, or add it to install.minimumReleaseAgeExcludes", .{}); + Output.note("remove the offending version from bun.lock, lower install.minimumReleaseAge, or add it to install.minimumReleaseAgeExcludes", .{}); } } manager.log.reset();