diff --git a/docs/pm/cli/install.mdx b/docs/pm/cli/install.mdx index 0e2a092d549..9df6e647a91 100644 --- a/docs/pm/cli/install.mdx +++ b/docs/pm/cli/install.mdx @@ -255,11 +255,15 @@ The default is controlled by a `configVersion` field in your lockfile. For a det ## Minimum release age -To protect against supply chain attacks where malicious packages are quickly published, you can configure a minimum age requirement for npm packages. Package versions published more recently than the specified threshold (in seconds) will be filtered out during installation. +To protect against supply chain attacks where malicious packages are quickly published, you can configure a minimum age requirement for npm packages. Package versions published more recently than the specified threshold will be filtered out during installation. The filter is disabled by default; pass `0` to disable it explicitly. + +The value accepts either an [`ms`](https://npmjs.com/package/ms)-style duration string (`3d`, `1 week`, `48h`) or a bare number of seconds: ```bash terminal icon="terminal" # Only install package versions published at least 3 days ago -bun add @types/bun --minimum-release-age 259200 # seconds +bun add @types/bun --minimum-release-age 3d +# Or as a number of seconds: +bun add @types/bun --minimum-release-age 259200 ``` You can also configure this in `bunfig.toml`: @@ -267,7 +271,7 @@ You can also configure this in `bunfig.toml`: ```toml bunfig.toml icon="settings" [install] # Only install package versions published at least 3 days ago -minimumReleaseAge = 259200 # seconds +minimumReleaseAge = "3d" # or 259200 (seconds) # Exclude trusted packages from the age gate minimumReleaseAgeExcludes = ["@types/node", "typescript"] @@ -334,8 +338,10 @@ concurrentScripts = 16 # (cpu count or GOMAXPROCS) x2 linker = "hoisted" -# minimum age config -minimumReleaseAge = 259200 # seconds +# skip package versions published within this period (security feature) +# accepts ms-style duration strings ("2d", "48h", "1 week") or a number of seconds +# (disabled by default; 0 also disables) +minimumReleaseAge = 259200 # or "3d" minimumReleaseAgeExcludes = ["@types/node", "typescript"] ``` diff --git a/docs/runtime/bunfig.mdx b/docs/runtime/bunfig.mdx index 9bdc496a4f7..7576d8704d9 100644 --- a/docs/runtime/bunfig.mdx +++ b/docs/runtime/bunfig.mdx @@ -735,12 +735,14 @@ Learn more about [using and writing security scanners](/pm/security-scanner-api) ### `install.minimumReleaseAge` -Configure a minimum age (in seconds) for npm package versions. Package versions published more recently than this threshold will be filtered out during installation. Default is `null` (disabled). +Configure a minimum age for npm package versions. Package versions published more recently than this threshold will be filtered out during installation. Accepts either a number of seconds or an [`ms`](https://npmjs.com/package/ms)-style duration string like `"2d"`, `"1 week"`, or `"48h"`. Set to `0` to disable. Disabled by default. ```toml title="bunfig.toml" icon="settings" [install] # Only install package versions published at least 3 days ago -minimumReleaseAge = 259200 +minimumReleaseAge = "3d" +# Or as a number of seconds: +# minimumReleaseAge = 259200 ``` For more details see [Minimum release age](/pm/cli/install#minimum-release-age) in the install documentation. diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index 51b8768c799..432c9dbd2b1 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -1092,6 +1092,92 @@ pub fn parse_num(s: &[u8]) -> Option { parse_ascii(s) } +/// Parse a duration string and return the value in milliseconds. +/// +/// Matches the behaviour of the npm `ms` package: accepts an optional +/// leading `+`/`-`, a number (integer or decimal), optional whitespace, +/// and an optional unit. When no unit is provided the number is +/// interpreted as milliseconds. Returns `None` for invalid input. +/// +/// Supported units (case-insensitive): +/// - years / year / yrs / yr / y +/// - weeks / week / w +/// - days / day / d +/// - hours / hour / hrs / hr / h +/// - minutes / minute / mins / min / m +/// - seconds / second / secs / sec / s +/// - milliseconds / millisecond / msecs / msec / ms +pub fn parse_ms(input: &[u8]) -> Option { + const MS_PER_S: f64 = crate::time::MS_PER_S as f64; + const MS_PER_MIN: f64 = 60.0 * MS_PER_S; + const MS_PER_HOUR: f64 = 60.0 * MS_PER_MIN; + const MS_PER_DAY: f64 = crate::time::MS_PER_DAY as f64; + const MS_PER_WEEK: f64 = 7.0 * MS_PER_DAY; + const MS_PER_YEAR: f64 = 365.25 * MS_PER_DAY; + + let remaining = input.trim_ascii(); + if remaining.is_empty() { + return None; + } + // The npm `ms` package caps input at 100 characters. + if remaining.len() > 100 { + return None; + } + + // Split the leading number from the unit. + let mut i: usize = 0; + if matches!(remaining[0], b'+' | b'-') { + i += 1; + } + let mut saw_digit = false; + while i < remaining.len() && remaining[i].is_ascii_digit() { + saw_digit = true; + i += 1; + } + if i < remaining.len() && remaining[i] == b'.' { + i += 1; + while i < remaining.len() && remaining[i].is_ascii_digit() { + saw_digit = true; + i += 1; + } + } + if !saw_digit { + return None; + } + + let value = parse_f64(&remaining[..i])?; + + let rest = remaining[i..].trim_ascii(); + + // Lowercase the unit into a small stack buffer for comparison. + let mut unit_buf = [0u8; 16]; + if rest.len() > unit_buf.len() { + return None; + } + for (j, &c) in rest.iter().enumerate() { + unit_buf[j] = match c { + b'A'..=b'Z' => c | 0x20, + b'a'..=b'z' => c, + _ => return None, + }; + } + let unit = &unit_buf[..rest.len()]; + + let multiplier: f64 = match unit { + b"" => 1.0, + b"ms" | b"msec" | b"msecs" | b"millisecond" | b"milliseconds" => 1.0, + b"s" | b"sec" | b"secs" | b"second" | b"seconds" => MS_PER_S, + b"m" | b"min" | b"mins" | b"minute" | b"minutes" => MS_PER_MIN, + b"h" | b"hr" | b"hrs" | b"hour" | b"hours" => MS_PER_HOUR, + b"d" | b"day" | b"days" => MS_PER_DAY, + b"w" | b"week" | b"weeks" => MS_PER_WEEK, + b"y" | b"yr" | b"yrs" | b"year" | b"years" => MS_PER_YEAR, + _ => return None, + }; + + Some(value * multiplier) +} + // ─────────────────────────────────────────────────────────────────────────── // Latin-1 formatting // ─────────────────────────────────────────────────────────────────────────── diff --git a/src/bun_core/lib.rs b/src/bun_core/lib.rs index 7cc3588d4ad..a8a9177d19f 100644 --- a/src/bun_core/lib.rs +++ b/src/bun_core/lib.rs @@ -1262,8 +1262,8 @@ pub use crate::fmt::{ hex_char_lower, hex_char_upper, hex_digit_value, hex_lower, hex_pair_value, hex_u8, hex_u16, hex_upper, hex2_lower, hex2_upper, hex4_lower, hex4_upper, int_as_bytes, parse_ascii, parse_f32, parse_f64, parse_hex_prefix, parse_hex_to_int, parse_hex4, - parse_int as parse_int_radix, parse_num, print_int, quote, raw, s, size, size_f64, size_i64, - truncated_hash32, truncated_hash32_bytes, utf16, + parse_int as parse_int_radix, parse_ms, parse_num, print_int, quote, raw, s, size, size_f64, + size_i64, truncated_hash32, truncated_hash32_bytes, utf16, }; /// Surrogate/transcode primitives + scalar-fallback string helpers that diff --git a/src/bunfig/bunfig.rs b/src/bunfig/bunfig.rs index ec5ebda3cb3..32469ccf341 100644 --- a/src/bunfig/bunfig.rs +++ b/src/bunfig/bunfig.rs @@ -1482,22 +1482,48 @@ impl<'a> Parser<'a> { } if let Some(min_age) = install_obj.get(b"minimumReleaseAge") { + const MS_PER_S: f64 = bun_core::time::MS_PER_S as f64; match &min_age.data { ExprData::ENumber(seconds) => { if seconds.value() < 0.0 { self.add_error( min_age.loc, - b"Expected positive number of seconds for minimumReleaseAge", + b"Expected a non-negative number of seconds for minimumReleaseAge (0 disables)", )?; return Ok(()); } - const MS_PER_S: f64 = bun_core::time::MS_PER_S as f64; install.minimum_release_age_ms = Some(seconds.value() * MS_PER_S); } + ExprData::EString(s) => { + // A bare number with no unit is interpreted as seconds for + // consistency with the unquoted form and the CLI flag. + // Trim first so `"259200 "` doesn't fall through to + // `parse_ms` (which would treat it as milliseconds). + let text = s.string(self.bump)?.trim_ascii(); + let ms = if let Some(secs) = bun_core::parse_f64(text) { + secs * MS_PER_S + } else if let Some(ms) = bun_core::parse_ms(text) { + ms + } else { + self.add_error( + min_age.loc, + b"Expected a duration like \"2d\" or \"1 week\" for minimumReleaseAge", + )?; + return Ok(()); + }; + if !(ms.is_finite() && ms >= 0.0) { + self.add_error( + min_age.loc, + b"Expected a non-negative duration for minimumReleaseAge (0 disables)", + )?; + return Ok(()); + } + install.minimum_release_age_ms = Some(ms); + } _ => { self.add_error( min_age.loc, - b"Expected number of seconds for minimumReleaseAge", + b"Expected a number of seconds or a duration string for minimumReleaseAge", )?; } } diff --git a/src/install/PackageManager/CommandLineArguments.rs b/src/install/PackageManager/CommandLineArguments.rs index 72e84e80fa9..2d6b086de8f 100644 --- a/src/install/PackageManager/CommandLineArguments.rs +++ b/src/install/PackageManager/CommandLineArguments.rs @@ -114,7 +114,7 @@ const SHARED_PARAMS: &[ParamType] = &[ "--linker Linker strategy (one of \"isolated\" or \"hoisted\")" ), clap::param!( - "--minimum-release-age Only install packages published at least N seconds ago (security feature)" + "--minimum-release-age Skip package versions published within this period (e.g. \"2d\", \"1 week\", or a number of seconds)" ), clap::param!( "--cpu ... Override CPU architecture for optional dependencies (e.g., x64, arm64, * for all)" @@ -1089,26 +1089,32 @@ Full documentation is available at https://bun.com/docs/cli/pm#scan. cli.save_text_lockfile = Some(true); } - if let Some(min_age_secs) = args.option(b"--minimum-release-age") { - let secs: f64 = match bun_core::parse_double(min_age_secs) { - Ok(s) => s, - Err(_) => { - Output::err_generic( - "Expected --minimum-release-age to be a positive number: {}", - (bstr::BStr::new(min_age_secs),), - ); - Global::crash(); - } + if let Some(min_age) = args.option(b"--minimum-release-age") { + const MS_PER_S: f64 = bun_core::time::MS_PER_S as f64; + // A bare number with no unit is interpreted as seconds for + // backwards compatibility with earlier releases. Trim first so + // `"259200 "` doesn't fall through to `parse_ms` (which would + // treat it as milliseconds). + let trimmed = min_age.trim_ascii(); + let ms: f64 = if let Some(secs) = bun_core::parse_f64(trimmed) { + secs * MS_PER_S + } else if let Some(ms) = bun_core::parse_ms(trimmed) { + ms + } else { + Output::err_generic( + "Expected --minimum-release-age to be a non-negative number of seconds or a duration like \"2d\": {}", + (bstr::BStr::new(min_age),), + ); + Global::crash(); }; - if secs < 0.0 { + if !(ms.is_finite() && ms >= 0.0) { Output::err_generic( - "Expected --minimum-release-age to be a positive number: {}", - (bstr::BStr::new(min_age_secs),), + "Expected --minimum-release-age to be a non-negative number of seconds or a duration like \"2d\": {}", + (bstr::BStr::new(min_age),), ); Global::crash(); } - const MS_PER_S: f64 = bun_core::time::MS_PER_S as f64; - cli.minimum_release_age_ms = Some(secs * MS_PER_S); + cli.minimum_release_age_ms = Some(ms); } let omit_values = args.options(b"--omit"); diff --git a/src/install/PackageManager/PackageManagerEnqueue.rs b/src/install/PackageManager/PackageManagerEnqueue.rs index b83bd8cc1b2..cd82f8ed52f 100644 --- a/src/install/PackageManager/PackageManagerEnqueue.rs +++ b/src/install/PackageManager/PackageManagerEnqueue.rs @@ -1026,8 +1026,15 @@ pub fn enqueue_dependency_with_main_and_success_fn( task_id, dependency.behavior.is_required(), ) { - let needs_extended_manifest = - this.options.minimum_release_age_ms.is_some(); + // `Some(0.0)` means the age filter is explicitly + // disabled, so it needs the abbreviated manifest + // just like the unset (`None`) case — only a + // positive age requires the `time`-bearing + // extended manifest. + let needs_extended_manifest = this + .options + .minimum_release_age_ms + .is_some_and(|ms| ms > 0.0); if this.options.enable.manifest_cache() { let mut expired = false; // SAFETY: `this_ptr` is the live exclusive @@ -1069,8 +1076,16 @@ pub fn enqueue_dependency_with_main_and_success_fn( .version, ) { - if let Some(min_age_ms) = - this.options.minimum_release_age_ms + // `Some(0.0)` means the filter is + // explicitly disabled — skip the + // check so this exact-version path + // agrees with the range/dist-tag + // filters (which also treat `0` as + // "no gate"). + if let Some(min_age_ms) = this + .options + .minimum_release_age_ms + .filter(|&ms| ms > 0.0) { if !loaded_manifest .as_ref() @@ -2374,7 +2389,12 @@ fn get_or_put_resolved_package( // materializing `&mut *this_ptr` after `name_str`/`scope` are // derived from it would pop their borrow-stack tags under SB. let cache_ctx = this.manifest_disk_cache_ctx(); - let needs_ext = this.options.minimum_release_age_ms.is_some(); + // Only a positive age needs the extended (`time`-bearing) manifest; + // `Some(0.0)` (filter explicitly disabled) behaves like unset. + let needs_ext = this + .options + .minimum_release_age_ms + .is_some_and(|ms| ms > 0.0); let this_ptr: *mut PackageManager = this; // SAFETY: `string_bytes` is not resized between here and the // `find_result` lookup; `manifest` lives in `this.manifests` and diff --git a/src/install/PackageManager/PopulateManifestCache.rs b/src/install/PackageManager/PopulateManifestCache.rs index 78df4289355..e40d6df88bc 100644 --- a/src/install/PackageManager/PopulateManifestCache.rs +++ b/src/install/PackageManager/PopulateManifestCache.rs @@ -175,7 +175,12 @@ pub fn populate_manifest_cache( let pkg_name_slice = pkg_name.slice(string_buf); // `options` is not mutated between here and the // `start_manifest_task` call — read via the BACKREF `mgr_ref`. - let needs_extended_manifest = mgr_ref.options.minimum_release_age_ms.is_some(); + // `Some(0.0)` (filter explicitly disabled) behaves like unset; + // only a positive age needs the extended manifest. + let needs_extended_manifest = mgr_ref + .options + .minimum_release_age_ms + .is_some_and(|ms| ms > 0.0); // `scope_for_package_name` borrows only `options` (via the // BACKREF `mgr_ref`); `manifests` is a disjoint field projected @@ -233,8 +238,13 @@ pub fn populate_manifest_cache( } // `options` read via BACKREF `mgr_ref` — see provenance-root - // note above. - let needs_extended_manifest = mgr_ref.options.minimum_release_age_ms.is_some(); + // note above. `Some(0.0)` (filter explicitly disabled) + // behaves like unset; only a positive age needs the + // extended manifest. + let needs_extended_manifest = mgr_ref + .options + .minimum_release_age_ms + .is_some_and(|ms| ms > 0.0); let package_name = pkg_names[pkg_id as usize].slice(string_buf); // See disjoint-field note on the `.All` arm above. let scope = diff --git a/src/install/npm.rs b/src/install/npm.rs index 6cc734a061d..0aedee0fa3b 100644 --- a/src/install/npm.rs +++ b/src/install/npm.rs @@ -1658,7 +1658,12 @@ impl PackageManifest { return FindVersionResult::Err(FindVersionError::NotFound); }; let min_age_gate_ms = match minimum_release_age_ms { - Some(min_age_ms) if !self.should_exclude_from_age_filter(exclusions) => { + // `Some(0.0)` means the filter is explicitly disabled — treat it + // like unset so no age gate is applied (and no `time` data, which + // only the extended manifest carries, is required). + Some(min_age_ms) + if min_age_ms > 0.0 && !self.should_exclude_from_age_filter(exclusions) => + { Some(min_age_ms) } _ => None, @@ -1782,7 +1787,12 @@ impl PackageManifest { exclusions: Option<&[&[u8]]>, ) -> FindVersionResult<'_> { let min_age_gate_ms = match minimum_release_age_ms { - Some(min_age_ms) if !self.should_exclude_from_age_filter(exclusions) => { + // `Some(0.0)` means the filter is explicitly disabled — treat it + // like unset so no age gate is applied (and no `time` data, which + // only the extended manifest carries, is required). + Some(min_age_ms) + if min_age_ms > 0.0 && !self.should_exclude_from_age_filter(exclusions) => + { Some(min_age_ms) } _ => None, diff --git a/test/cli/install/minimum-release-age.test.ts b/test/cli/install/minimum-release-age.test.ts index d90cf6fb835..6675cdb8cc9 100644 --- a/test/cli/install/minimum-release-age.test.ts +++ b/test/cli/install/minimum-release-age.test.ts @@ -1322,6 +1322,233 @@ registry = "${mockRegistryUrl}"`, expect(lockfile).toContain("regular-package@3.0.0"); }); + test("0 is equivalent to unset: requests the abbreviated manifest, not the extended one", async () => { + // `minimumReleaseAge = 0` disables the filter, so it must behave exactly + // like leaving the option unset — including not forcing the full + // (extended, `time`-bearing) registry manifest for every package. Use a + // dedicated registry that records the Accept header for the manifest + // request so we can assert the abbreviated form was requested. + const acceptHeaders: string[] = []; + await using registry = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + if (url.pathname === "/regular-package") { + const accept = req.headers.get("accept") ?? ""; + acceptHeaders.push(accept); + const base = `http://localhost:${registry.port}`; + const versions = { + "1.0.0": { + name: "regular-package", + version: "1.0.0", + dist: { tarball: `${base}/regular-package/-/regular-package-1.0.0.tgz`, integrity: "sha512-fake1==" }, + }, + "2.0.0": { + name: "regular-package", + version: "2.0.0", + dist: { tarball: `${base}/regular-package/-/regular-package-2.0.0.tgz`, integrity: "sha512-fake2==" }, + }, + }; + const full = { + name: "regular-package", + "dist-tags": { latest: "2.0.0" }, + versions, + time: { "1.0.0": daysAgo(30), "2.0.0": daysAgo(1) }, + }; + if (accept.includes("application/vnd.npm.install-v1+json")) { + return Response.json({ name: full.name, "dist-tags": full["dist-tags"], versions }); + } + return Response.json(full); + } + if (url.pathname.includes(".tgz")) { + const m = url.pathname.match(/\/([^\/]+)\/-\/\1-([\d.]+).tgz/)!; + return new Response(createTarball(m[1], m[2]), { + headers: { "Content-Type": "application/octet-stream" }, + }); + } + return new Response("not found", { status: 404 }); + }, + }); + + using dir = tempDir("zero-abbreviated-manifest", { + "package.json": JSON.stringify({ dependencies: { "regular-package": "*" } }), + "bunfig.toml": `[install]\nminimumReleaseAge = 0\nregistry = "http://localhost:${registry.port}"`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--no-verify"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + + // With 0 (disabled), no version is filtered -> latest wins. + const lockfile = await Bun.file(`${dir}/bun.lock`).text(); + expect(lockfile).toContain("regular-package@2.0.0"); + + // The manifest must have been fetched, and every request for it must use + // the abbreviated Accept header — disabling via 0 must not pull the + // extended (`application/json`) manifest that the age filter requires. + expect(acceptHeaders.length).toBeGreaterThan(0); + expect(acceptHeaders.every(a => a.includes("application/vnd.npm.install-v1+json"))).toBe(true); + }); + + test("CLI flag accepts ms-style duration string", async () => { + using dir = tempDir("cli-ms-string", { + "package.json": JSON.stringify({ + dependencies: { "regular-package": "*" }, + }), + ".npmrc": `registry=${mockRegistryUrl}`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + + const lockfile = await Bun.file(`${dir}/bun.lock`).text(); + // 5 days -> 2.1.0 (6 days old) passes, 3.0.0 (1 day old) blocked + expect(lockfile).toContain("regular-package@2.1.0"); + expect(lockfile).not.toContain("regular-package@3.0.0"); + }); + + test("CLI flag accepts ms-style duration string with space", async () => { + using dir = tempDir("cli-ms-string-space", { + "package.json": JSON.stringify({ + dependencies: { "regular-package": "*" }, + }), + ".npmrc": `registry=${mockRegistryUrl}`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--minimum-release-age", "1 week", "--no-verify"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + + const lockfile = await Bun.file(`${dir}/bun.lock`).text(); + // 7 days -> 2.0.0 (10 days old) passes, 2.1.0 (6 days) and 3.0.0 (1 day) blocked + expect(lockfile).toContain("regular-package@2.0.0"); + expect(lockfile).not.toContain("regular-package@2.1.0"); + expect(lockfile).not.toContain("regular-package@3.0.0"); + }); + + test("CLI flag rejects invalid duration string", async () => { + using dir = tempDir("cli-ms-invalid", { + "package.json": JSON.stringify({ + dependencies: { "regular-package": "*" }, + }), + ".npmrc": `registry=${mockRegistryUrl}`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--minimum-release-age", "banana"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain("--minimum-release-age"); + expect(exitCode).not.toBe(0); + }); + + test("bunfig accepts ms-style duration string", async () => { + using dir = tempDir("bunfig-ms-string", { + "package.json": JSON.stringify({ + dependencies: { "regular-package": "*" }, + }), + "bunfig.toml": `[install] +minimumReleaseAge = "5 days" +registry = "${mockRegistryUrl}"`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + + const lockfile = await Bun.file(`${dir}/bun.lock`).text(); + expect(lockfile).toContain("regular-package@2.1.0"); + expect(lockfile).not.toContain("regular-package@3.0.0"); + }); + + test("bunfig bare-number string is seconds (matches unquoted form)", async () => { + using dir = tempDir("bunfig-ms-bare", { + "package.json": JSON.stringify({ + dependencies: { "regular-package": "*" }, + }), + "bunfig.toml": `[install] +minimumReleaseAge = "${5 * SECONDS_PER_DAY}" +registry = "${mockRegistryUrl}"`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + + const lockfile = await Bun.file(`${dir}/bun.lock`).text(); + // "432000" as a string should be 5 days (seconds), same as unquoted 432000 + expect(lockfile).toContain("regular-package@2.1.0"); + expect(lockfile).not.toContain("regular-package@3.0.0"); + }); + + test("bunfig rejects invalid duration string", async () => { + using dir = tempDir("bunfig-ms-invalid", { + "package.json": JSON.stringify({ + dependencies: { "regular-package": "*" }, + }), + "bunfig.toml": `[install] +minimumReleaseAge = "nope" +registry = "${mockRegistryUrl}"`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain("minimumReleaseAge"); + expect(exitCode).not.toBe(0); + }); + test("global bunfig.toml configuration works", async () => { // Create a fake home directory with global bunfig using globalConfigDir = tempDir("global-config", {