diff --git a/crates/turborepo-devtools/src/watcher.rs b/crates/turborepo-devtools/src/watcher.rs index 4c1ecb5c06a27..153123d7402d6 100644 --- a/crates/turborepo-devtools/src/watcher.rs +++ b/crates/turborepo-devtools/src/watcher.rs @@ -44,6 +44,7 @@ const RELEVANT_FILES: &[&str] = &[ "pnpm-lock.yaml", "nub.lock", "lock.yaml", + "bun.lock", "bun.lockb", "Cargo.toml", "Cargo.lock", @@ -225,6 +226,8 @@ mod tests { assert!(is_relevant_file(Path::new("pnpm-workspace.yaml"))); assert!(is_relevant_file(Path::new("nub.lock"))); assert!(is_relevant_file(Path::new("lock.yaml"))); + assert!(is_relevant_file(Path::new("bun.lock"))); + assert!(is_relevant_file(Path::new("bun.lockb"))); assert!(is_relevant_file(Path::new("crates/app/Cargo.toml"))); assert!(is_relevant_file(Path::new("Cargo.lock"))); assert!(!is_relevant_file(Path::new("index.ts"))); diff --git a/crates/turborepo-lockfiles/src/bun/data.rs b/crates/turborepo-lockfiles/src/bun/data.rs index 9f7606be027c1..9f486ee17c0ee 100644 --- a/crates/turborepo-lockfiles/src/bun/data.rs +++ b/crates/turborepo-lockfiles/src/bun/data.rs @@ -142,20 +142,25 @@ pub(crate) enum LockfileVersion { // `resolve_package`'s workspace-direct optimization checks // `lockfile_version >= 1`, which V2 satisfies. V2 = 2, + // V3 is stamped only when the `overrides` section contains nested rules, + // i.e. object values (`"parent@range": { "child": "range", ".": "range" }`). + // Everything else is identical to V2. + V3 = 3, } impl LockfileVersion { - #[allow(dead_code)] + pub(super) const LATEST: Self = Self::V3; + pub(super) fn from_i32(value: i32) -> Option { match value { 0 => Some(Self::V0), 1 => Some(Self::V1), 2 => Some(Self::V2), + 3 => Some(Self::V3), _ => None, } } - #[allow(dead_code)] pub(super) fn as_i32(self) -> i32 { self as i32 } @@ -178,7 +183,7 @@ pub struct BunLockfileData { #[serde(default)] pub(super) trusted_dependencies: Vec, #[serde(default)] - pub(super) overrides: Map, + pub(super) overrides: Map, #[serde(default)] pub(super) catalog: Map, #[serde(default)] @@ -188,12 +193,48 @@ pub struct BunLockfileData { pub(super) patched_dependencies: Map, } +/// A value in the top-level `overrides` section. +/// +/// Flat rules are strings (`"lodash": "4.17.21"`). A rule can also be an +/// object scoping overrides to the dependencies of one parent package +/// (`"webpack@^4": { "terser": "4.8.1" }`); inside such an object the special +/// `"."` key is a flat rule for the parent itself. Bun stamps a lockfile +/// containing object rules as version 3 but accepts them at any version. +/// Nested rules are materialized by Bun as nested lockfile keys +/// (`webpack/terser`), which the regular resolution path already follows, so +/// turbo only needs to carry them through unchanged and apply the `"."` entry. +#[derive(Debug, Deserialize, PartialEq, Clone, Serialize)] +#[serde(untagged)] +pub(crate) enum OverrideValue { + Version(String), + Nested(Map), +} + +impl OverrideValue { + /// The version to use for a dependency on the package named by this + /// rule's key, if the rule specifies one. + pub(super) fn version(&self) -> Option<&str> { + match self { + Self::Version(version) => Some(version), + Self::Nested(rules) => rules.get(".").and_then(Value::as_str), + } + } +} + #[derive(Debug, Deserialize, PartialEq, Default, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct WorkspaceEntry { + // Bun omits the root workspace's name when the root package.json has none. + #[serde(default, skip_serializing_if = "String::is_empty")] pub(super) name: String, #[serde(skip_serializing_if = "Option::is_none")] pub(super) version: Option, + // `bin` is a string or an object; the installer links workspace bins from + // the lockfile, so these must survive a prune. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) bin: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) bin_dir: Option, #[serde(skip_serializing_if = "Option::is_none")] pub(super) dependencies: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -212,8 +253,10 @@ pub(crate) struct PackageEntry { pub(super) registry: Option, // Present for all package types except root deps pub(super) info: Option, - // Present on registry + // Registry/tarball: integrity. Git/github: the `.bun-tag` string. pub(super) checksum: Option, + // Git/github only: the optional 4th element pinning the packed checkout. + pub(super) integrity: Option, pub(super) root: Option, } @@ -244,7 +287,10 @@ pub(crate) struct PackageInfo { #[derive(Debug, Deserialize, PartialEq, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct RootInfo { - pub(super) bin: Option, + // A string or an object, like `bin` in package.json. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) bin: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) bin_dir: Option, } impl PackageEntry { diff --git a/crates/turborepo-lockfiles/src/bun/de.rs b/crates/turborepo-lockfiles/src/bun/de.rs index bcbf777879ccd..5f09a187cc3e0 100644 --- a/crates/turborepo-lockfiles/src/bun/de.rs +++ b/crates/turborepo-lockfiles/src/bun/de.rs @@ -15,11 +15,11 @@ use crate::bun::RootInfo; // symlink -> [ "name@link:path", INFO ] // folder -> [ "name@file:path", INFO ] // workspace -> [ "name@workspace:path", INFO ] -// tarball -> [ "name@tarball", INFO ] +// local tarball -> [ "name@./path.tgz", INFO, integrity? ] +// remote tarball -> [ "name@https://host/path.tgz", INFO, integrity? ] // root -> [ "name@root:", { bin, binDir } ] -// git -> [ "name@git+repo", INFO, .bun-tag string (TODO: remove this) ] -// github -> [ "name@github:user/repo", INFO, .bun-tag string (TODO: remove -// this) ] +// git -> [ "name@git+repo", INFO, .bun-tag string, integrity? ] +// github -> [ "name@github:user/repo", INFO, .bun-tag string, integrity? ] impl<'de> Deserialize<'de> for PackageEntry { fn deserialize(deserializer: D) -> Result where @@ -65,6 +65,7 @@ impl<'de> Deserialize<'de> for PackageEntry { info, registry, checksum: None, + integrity: None, root, }); } @@ -88,17 +89,26 @@ impl<'de> Deserialize<'de> for PackageEntry { info = vals.pop_front().and_then(val_to_info); } - // Checksum is last - let checksum = vals.pop_front().and_then(|val| match val { - Vals::Str(sha) => Some(sha), - Vals::Info(_) => None, - }); + let mut next_string = || { + vals.pop_front().and_then(|val| match val { + Vals::Str(s) => Some(s), + Vals::Info(_) => None, + }) + }; + let checksum = next_string(); + // Only git/github entries carry a further element after the checksum. + let integrity = if is_git_or_github_package(&key) { + next_string() + } else { + None + }; Ok(Self { ident: key, info, registry, checksum, + integrity, root: None, }) } @@ -170,6 +180,7 @@ mod test { ..Default::default() }), checksum: Some("sha".into()), + integrity: None, root: None, } ); @@ -187,6 +198,7 @@ mod test { }), registry: None, checksum: None, + integrity: None, root: None, } ); @@ -203,6 +215,7 @@ mod test { info: None, registry: None, checksum: None, + integrity: None, } ); @@ -220,6 +233,7 @@ mod test { ..Default::default() }), checksum: Some("24a971c".into()), + integrity: None, root: None, } ); @@ -241,6 +255,7 @@ mod test { ..Default::default() }), checksum: Some("24a971c".into()), + integrity: None, root: None, } ); @@ -259,6 +274,7 @@ mod test { ..Default::default() }), checksum: Some("abc123".into()), + integrity: None, root: None, } ); @@ -277,6 +293,7 @@ mod test { ..Default::default() }), checksum: None, + integrity: None, root: None, } ); @@ -290,6 +307,98 @@ mod test { registry: None, info: Some(PackageInfo::default()), checksum: None, + integrity: None, + root: None, + } + ); + + fixture!( + workspace_with_bin, + WorkspaceEntry, + WorkspaceEntry { + name: "cli".into(), + bin: Some(json!({"cli": "bin/cli.js"})), + ..Default::default() + } + ); + + fixture!( + workspace_with_bin_dir, + WorkspaceEntry, + WorkspaceEntry { + name: "scripts".into(), + bin_dir: Some("bin".into()), + ..Default::default() + } + ); + + fixture!( + root_workspace_without_name, + WorkspaceEntry, + WorkspaceEntry { + dependencies: Some( + Some(("is-odd".to_string(), "3.0.1".to_string())) + .into_iter() + .collect() + ), + ..Default::default() + } + ); + + fixture!( + root_pkg_with_object_bin, + PackageEntry, + PackageEntry { + ident: "some-package@root:".into(), + root: Some(RootInfo { + bin: Some(json!({"some-package": "cli.js"})), + bin_dir: None, + }), + info: None, + registry: None, + checksum: None, + integrity: None, + } + ); + + fixture!( + root_pkg_without_bins, + PackageEntry, + PackageEntry { + ident: "some-package@root:".into(), + root: Some(RootInfo { + bin: None, + bin_dir: None, + }), + info: None, + registry: None, + checksum: None, + integrity: None, + } + ); + + fixture!( + git_pkg_with_integrity, + PackageEntry, + PackageEntry { + ident: "my-package@git+https://github.com/user/repo#abc123".into(), + registry: None, + info: Some(PackageInfo::default()), + checksum: Some("abc123".into()), + integrity: Some("sha512-integrity".into()), + root: None, + } + ); + + fixture!( + local_tarball_pkg, + PackageEntry, + PackageEntry { + ident: "bar@./vendor/bar-0.0.2.tgz".into(), + registry: None, + info: Some(PackageInfo::default()), + checksum: Some("sha512-bar".into()), + integrity: None, root: None, } ); @@ -304,6 +413,13 @@ mod test { #[test_case(json!(["@tanstack/react-store@github:TanStack/store#24a971c", "", {"dependencies": {"@tanstack/store": "0.7.0"}}, "24a971c"]), github_pkg_corrupted_input() ; "github package with corrupted 4-element input")] #[test_case(json!(["@api/sdk@file:apps/api/.api/apis/sdk", {"dependencies": {"is-odd": "^3.0.1"}}]), file_pkg() ; "file package")] #[test_case(json!(["my-pkg@link:../../local-pkg", {}]), link_pkg() ; "link package")] + #[test_case(json!({"name": "cli", "bin": {"cli": "bin/cli.js"}}), workspace_with_bin() ; "workspace entry with bin")] + #[test_case(json!({"name": "scripts", "binDir": "bin"}), workspace_with_bin_dir() ; "workspace entry with binDir")] + #[test_case(json!({"dependencies": {"is-odd": "3.0.1"}}), root_workspace_without_name() ; "root workspace entry without name")] + #[test_case(json!(["some-package@root:", {"bin": {"some-package": "cli.js"}}]), root_pkg_with_object_bin() ; "root package with object bin")] + #[test_case(json!(["some-package@root:", {}]), root_pkg_without_bins() ; "root package without bins")] + #[test_case(json!(["my-package@git+https://github.com/user/repo#abc123", {}, "abc123", "sha512-integrity"]), git_pkg_with_integrity() ; "git package with integrity")] + #[test_case(json!(["bar@./vendor/bar-0.0.2.tgz", {}, "sha512-bar"]), local_tarball_pkg() ; "local tarball package")] fn test_deserialization Deserialize<'a> + PartialEq + std::fmt::Debug>( input: serde_json::Value, expected: &T, diff --git a/crates/turborepo-lockfiles/src/bun/emit.rs b/crates/turborepo-lockfiles/src/bun/emit.rs index 0ccbd3939c0a8..2296189a4b63b 100644 --- a/crates/turborepo-lockfiles/src/bun/emit.rs +++ b/crates/turborepo-lockfiles/src/bun/emit.rs @@ -158,6 +158,18 @@ impl BunLockfile { } else { output.push_str(&format!(" \"{key}\": [{ident_json}],")); } + } else if ident.is_root() { + // Root entries: [ident, { bin, binDir }]. Bun expects an object + // as the second element even when there are no bins. + let ident_json = serde_json::to_string(&entry.ident)?; + let root_json = match &entry.root { + Some(root) => serde_json::to_string(root)?, + None => "{}".to_string(), + }; + let root_json_spaced = self.format_info_json(&root_json); + output.push_str(&format!( + " \"{key}\": [{ident_json}, {root_json_spaced}]," + )); } else if ident.is_local_package() || is_tarball_or_url_package(&entry.ident) { let ident_json = serde_json::to_string(&entry.ident)?; let info_json = @@ -192,10 +204,19 @@ impl BunLockfile { // GitHub and git packages have 3 elements (no registry) // npm packages have 4 elements (with registry) if is_git_or_github_package(&entry.ident) { - // GitHub/git packages: [ident, info, checksum] - 3 elements - output.push_str(&format!( - " \"{key}\": [{ident_json}, {info_json_spaced}, {checksum_json}],", - )); + // GitHub/git packages: [ident, info, bun-tag, integrity?] + match &entry.integrity { + Some(integrity) => { + let integrity_json = serde_json::to_string(integrity)?; + output.push_str(&format!( + " \"{key}\": [{ident_json}, {info_json_spaced}, \ + {checksum_json}, {integrity_json}],", + )); + } + None => output.push_str(&format!( + " \"{key}\": [{ident_json}, {info_json_spaced}, {checksum_json}],", + )), + } } else { // npm packages: [ident, registry, info, checksum] - 4 elements let registry_json = diff --git a/crates/turborepo-lockfiles/src/bun/index.rs b/crates/turborepo-lockfiles/src/bun/index.rs index 74894450f430d..4f9f3e5a9d27e 100644 --- a/crates/turborepo-lockfiles/src/bun/index.rs +++ b/crates/turborepo-lockfiles/src/bun/index.rs @@ -235,6 +235,7 @@ mod tests { registry: Some("".to_string()), info: Some(PackageInfo::default()), checksum: Some("sha512".to_string()), + integrity: None, root: None, } } @@ -247,6 +248,7 @@ mod tests { registry: Some("".to_string()), info: Some(info), checksum: Some("sha512".to_string()), + integrity: None, root: None, } } diff --git a/crates/turborepo-lockfiles/src/bun/mod.rs b/crates/turborepo-lockfiles/src/bun/mod.rs index dddf091227a5a..4475fae2a5de0 100644 --- a/crates/turborepo-lockfiles/src/bun/mod.rs +++ b/crates/turborepo-lockfiles/src/bun/mod.rs @@ -1,8 +1,9 @@ //! # Bun Lockfile Support //! //! This module provides comprehensive support for Bun lockfiles (`bun.lockb`), -//! handling both binary and JSON formats with support for lockfile versions 0, -//! 1, and 2. +//! handling both binary and JSON formats with support for lockfile versions 0 +//! through 3. Newer versions are parsed as version 3 with a warning, since +//! every Bun lockfile revision so far has only added to the schema. //! //! ## Lockfile Version Support //! @@ -24,6 +25,14 @@ //! only the version tag needs to be accepted; no downstream branches fork on //! V2. //! +//! ### Version 3 +//! - Values in the top-level `overrides` section may be objects scoping +//! overrides to one parent package (`"webpack@^4": { "terser": "4.8.1" }`, +//! with `"."` standing for the parent itself). Bun stamps version 3 only when +//! such a rule exists. `turbo prune` copies the section verbatim, exactly as +//! for flat overrides, because Bun's `--frozen-lockfile` compares the whole +//! set against the (also copied verbatim) root package.json. +//! //! ## Key Features //! //! ### Catalog Resolution @@ -119,16 +128,17 @@ pub use types::{PackageIdent, PackageKey, VersionSpec}; type Map = std::collections::BTreeMap; type BTreeSet = std::collections::BTreeSet; -#[cfg(test)] -pub(super) use data::WorkspaceEntry; pub(super) use data::{BunLockfileData, LockfileVersion, PackageEntry, PackageInfo, RootInfo}; +#[cfg(test)] +pub(super) use data::{OverrideValue, WorkspaceEntry}; /// Check if a package identifier refers to a git or GitHub package. /// /// Git and GitHub packages have different serialization formats than npm /// packages: /// - npm packages: `[ident, registry, info, checksum]` (4 elements) -/// - git/github packages: `[ident, info, checksum]` (3 elements, no registry) +/// - git/github packages: `[ident, info, bun-tag, integrity?]` (3 or 4 +/// elements, no registry) /// /// This function is used in deserialization, serialization, and encoding to /// ensure consistent handling of these package types. diff --git a/crates/turborepo-lockfiles/src/bun/parse.rs b/crates/turborepo-lockfiles/src/bun/parse.rs index b188a6ef66e21..40b85ef88fd7d 100644 --- a/crates/turborepo-lockfiles/src/bun/parse.rs +++ b/crates/turborepo-lockfiles/src/bun/parse.rs @@ -40,9 +40,21 @@ impl FromStr for BunLockfile { let strict_json = format.print().map_err(Error::from)?; let data: BunLockfileData = serde_json::from_str(strict_json.as_code())?; - // Validate that we support this lockfile version - let _version = LockfileVersion::from_i32(data.lockfile_version) - .ok_or(crate::Error::UnsupportedBunVersion(data.lockfile_version))?; + if LockfileVersion::from_i32(data.lockfile_version).is_none() { + if data.lockfile_version < LockfileVersion::LATEST.as_i32() { + return Err(crate::Error::UnsupportedBunVersion(data.lockfile_version)); + } + // Bun lockfile revisions have only ever added to the schema, and the + // deserialization above already succeeded, so treat a newer version + // like the latest one we know instead of discarding the lockfile. + tracing::warn!( + "bun.lock has lockfileVersion {}, newer than the latest supported version {}; \ + treating it as version {}", + data.lockfile_version, + LockfileVersion::LATEST.as_i32(), + LockfileVersion::LATEST.as_i32() + ); + } // Build key_to_entry map // When there are multiple lockfile keys with the same ident (e.g., nested diff --git a/crates/turborepo-lockfiles/src/bun/resolve.rs b/crates/turborepo-lockfiles/src/bun/resolve.rs index aca5a6772d09d..3ab7742c516fc 100644 --- a/crates/turborepo-lockfiles/src/bun/resolve.rs +++ b/crates/turborepo-lockfiles/src/bun/resolve.rs @@ -168,10 +168,12 @@ impl BunLockfile { } pub(super) fn apply_overrides<'a>(&'a self, name: &str, version: &'a str) -> &'a str { + // Keys carrying a parent range (`"webpack@^4"`) never match a bare + // name, so only unscoped rules (and their `"."` entry) apply here. self.data .overrides .get(name) - .map(|s| s.as_str()) + .and_then(|rule| rule.version()) .unwrap_or(version) } diff --git a/crates/turborepo-lockfiles/src/bun/ser.rs b/crates/turborepo-lockfiles/src/bun/ser.rs index d3adf3889dce5..9eff5c30dd073 100644 --- a/crates/turborepo-lockfiles/src/bun/ser.rs +++ b/crates/turborepo-lockfiles/src/bun/ser.rs @@ -13,11 +13,11 @@ use super::{PackageEntry, is_git_or_github_package, is_tarball_or_url_package}; // symlink -> [ "name@link:path", INFO ] // folder -> [ "name@file:path", INFO ] // workspace -> [ "name@workspace:path", INFO ] -// tarball -> [ "name@tarball", INFO ] +// local tarball -> [ "name@./path.tgz", INFO, integrity? ] +// remote tarball -> [ "name@https://host/path.tgz", INFO, integrity? ] // root -> [ "name@root:", { bin, binDir } ] -// git -> [ "name@git+repo", INFO, .bun-tag string (TODO: remove this) ] -// github -> [ "name@github:user/repo", INFO, .bun-tag string (TODO: remove -// this) ] +// git -> [ "name@git+repo", INFO, .bun-tag string, integrity? ] +// github -> [ "name@github:user/repo", INFO, .bun-tag string, integrity? ] impl Serialize for PackageEntry { fn serialize(&self, serializer: S) -> Result { let is_git = is_git_or_github_package(&self.ident); @@ -37,6 +37,9 @@ impl Serialize for PackageEntry { && (!is_url || !checksum.is_empty()) { len += 1; + if is_git && self.integrity.is_some() { + len += 1; + } } } @@ -63,6 +66,9 @@ impl Serialize for PackageEntry { && (!is_url || !checksum.is_empty()) { tuple.serialize_element(checksum)?; + if is_git && let Some(integrity) = &self.integrity { + tuple.serialize_element(integrity)?; + } } tuple.end() @@ -135,6 +141,7 @@ mod test { ..Default::default() }), checksum: Some("sha".into()), + integrity: None, root: None, } ); @@ -152,6 +159,7 @@ mod test { }), registry: None, checksum: None, + integrity: None, root: None, } ); @@ -168,6 +176,7 @@ mod test { info: None, registry: None, checksum: None, + integrity: None, } ); @@ -185,6 +194,7 @@ mod test { ..Default::default() }), checksum: Some("24a971c".into()), + integrity: None, root: None, } ); @@ -203,6 +213,7 @@ mod test { ..Default::default() }), checksum: Some("abc123".into()), + integrity: None, root: None, } ); @@ -221,6 +232,7 @@ mod test { ..Default::default() }), checksum: None, + integrity: None, root: None, } ); @@ -234,6 +246,7 @@ mod test { registry: None, info: Some(PackageInfo::default()), checksum: None, + integrity: None, root: None, } ); @@ -254,6 +267,80 @@ mod test { ..Default::default() }), checksum: Some("24a971c".into()), + integrity: None, + root: None, + } + ); + + fixture!( + workspace_with_bin, + WorkspaceEntry, + WorkspaceEntry { + name: "cli".into(), + bin: Some(json!({"cli": "bin/cli.js"})), + ..Default::default() + } + ); + + fixture!( + root_workspace_without_name, + WorkspaceEntry, + WorkspaceEntry::default() + ); + + fixture!( + root_pkg_with_object_bin, + PackageEntry, + PackageEntry { + ident: "some-package@root:".into(), + root: Some(RootInfo { + bin: Some(json!({"some-package": "cli.js"})), + bin_dir: None, + }), + info: None, + registry: None, + checksum: None, + integrity: None, + } + ); + + fixture!( + git_pkg_with_integrity, + PackageEntry, + PackageEntry { + ident: "my-package@git+https://github.com/user/repo#abc123".into(), + registry: None, + info: Some(PackageInfo::default()), + checksum: Some("abc123".into()), + integrity: Some("sha512-integrity".into()), + root: None, + } + ); + + // The integrity element only exists for git/github entries; a stray value + // on a registry entry must not produce a 5-element tuple. + fixture!( + registry_pkg_ignores_integrity, + PackageEntry, + PackageEntry { + ident: "is-odd@3.0.1".into(), + registry: Some("".into()), + info: Some(PackageInfo::default()), + checksum: Some("sha".into()), + integrity: Some("ignored".into()), + root: None, + } + ); + + fixture!( + local_tarball_pkg, + PackageEntry, + PackageEntry { + ident: "bar@./vendor/bar-0.0.2.tgz".into(), + registry: None, + info: Some(PackageInfo::default()), + checksum: Some("sha512-bar".into()), + integrity: None, root: None, } ); @@ -270,6 +357,12 @@ mod test { // Defense-in-depth test: corrupted registry should be stripped from github packages during // serialization #[test_case(json!(["@tanstack/react-store@github:TanStack/store#24a971c", {"dependencies": {"@tanstack/store": "0.7.0"}}, "24a971c"]), github_pkg_with_corrupted_registry() ; "github package with corrupted registry stripped")] + #[test_case(json!({"name": "cli", "bin": {"cli": "bin/cli.js"}}), workspace_with_bin() ; "workspace entry with bin")] + #[test_case(json!({}), root_workspace_without_name() ; "root workspace entry without name")] + #[test_case(json!(["some-package@root:", {"bin": {"some-package": "cli.js"}}]), root_pkg_with_object_bin() ; "root package with object bin")] + #[test_case(json!(["my-package@git+https://github.com/user/repo#abc123", {}, "abc123", "sha512-integrity"]), git_pkg_with_integrity() ; "git package with integrity")] + #[test_case(json!(["is-odd@3.0.1", "", {}, "sha"]), registry_pkg_ignores_integrity() ; "registry package ignores integrity")] + #[test_case(json!(["bar@./vendor/bar-0.0.2.tgz", {}, "sha512-bar"]), local_tarball_pkg() ; "local tarball package")] fn test_serialization( expected: serde_json::Value, input: &T, diff --git a/crates/turborepo-lockfiles/src/bun/subgraph.rs b/crates/turborepo-lockfiles/src/bun/subgraph.rs index 9f1e0026a086a..972530578b0ae 100644 --- a/crates/turborepo-lockfiles/src/bun/subgraph.rs +++ b/crates/turborepo-lockfiles/src/bun/subgraph.rs @@ -83,10 +83,11 @@ impl BunLockfile { lockfile_version: self.data.lockfile_version, config_version: self.data.config_version, workspaces: Map::new(), - // trustedDependencies are intentionally left empty. turbo prune - // copies the root package.json which is the source of truth for - // trusted scripts; bun re-derives the set at install time. - trusted_dependencies: Vec::new(), + // Copied verbatim for the same reason as overrides below: turbo + // prune copies the root package.json unchanged, and bun diffs the + // lockfile's trustedDependencies against package.json's on install. + // Dropping the section makes every entry register as newly added. + trusted_dependencies: self.data.trusted_dependencies.clone(), overrides: Map::new(), catalog: self.data.catalog.clone(), catalogs: self.data.catalogs.clone(), @@ -481,6 +482,7 @@ impl BunLockfile { registry: None, info: Some(info), checksum: None, + integrity: None, root: None, }; pruned_data.packages.insert(key.clone(), entry); diff --git a/crates/turborepo-lockfiles/src/bun/test.rs b/crates/turborepo-lockfiles/src/bun/test.rs index 4c21a0aae5586..8a9c0b42dc41a 100644 --- a/crates/turborepo-lockfiles/src/bun/test.rs +++ b/crates/turborepo-lockfiles/src/bun/test.rs @@ -107,7 +107,7 @@ fn test_new_fields_parsing() { assert_eq!(lockfile.data.overrides.len(), 1); assert_eq!( lockfile.data.overrides.get("foo"), - Some(&"1.0.0".to_string()) + Some(&OverrideValue::Version("1.0.0".to_string())) ); assert_eq!(lockfile.data.catalog.len(), 1); @@ -2484,3 +2484,505 @@ fn test_subgraph_relocates_ancestor_scoped_dep_for_renamed_dependent() { } } } + +/// Prunes `lockfile` down to `workspace` and the closure of `deps`, then +/// encodes the result the way `turbo prune` writes bun.lock. +fn prune_to_string(lockfile: &BunLockfile, workspace: &str, deps: &[(&str, &str)]) -> String { + let deps: std::collections::BTreeMap = deps + .iter() + .map(|(name, version)| (name.to_string(), version.to_string())) + .collect(); + let closure = crate::transitive_closure(lockfile, workspace, deps, false).unwrap(); + let package_idents: Vec = closure.iter().map(|pkg| pkg.key.clone()).collect(); + let subgraph = + ::subgraph(lockfile, &[workspace.into()], &package_idents) + .unwrap(); + String::from_utf8(subgraph.encode().unwrap()).unwrap() +} + +// Bun writes lockfileVersion 3 when `overrides` contains nested rules, which +// are stored as object values. Bun's `--frozen-lockfile` compares the whole +// overrides set against the root package.json (which prune copies verbatim), +// so the section has to survive prune byte-for-byte, nested rules included. +#[test] +fn test_parses_v3_lockfile_with_nested_overrides() { + let overrides = json!({ + "lodash": "4.17.21", + "micromatch": { + ".": "4.0.5", + "picomatch": "2.3.2", + }, + "webpack@^4": { + "terser": "4.8.1", + }, + }); + let contents = serde_json::to_string(&json!({ + "lockfileVersion": 3, + "configVersion": 1, + "workspaces": { + "": { + "name": "root", + "dependencies": { + "picomatch": "2.2.0", + }, + }, + "packages/app": { + "name": "app", + "dependencies": { + "lodash": "^4.0.0", + "micromatch": "^4.0.0", + "webpack": "^4.0.0", + }, + }, + "packages/other": { + "name": "other", + "dependencies": { + "is-odd": "3.0.1", + }, + }, + }, + "overrides": overrides, + "packages": { + "app": ["app@workspace:packages/app"], + "is-odd": ["is-odd@3.0.1", "", {}, "sha512-is-odd"], + "lodash": ["lodash@4.17.21", "", {}, "sha512-lodash"], + "micromatch": ["micromatch@4.0.5", "", { "dependencies": { "picomatch": "^2.2.0" } }, "sha512-micromatch"], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-picomatch-232"], + "other": ["other@workspace:packages/other"], + "picomatch": ["picomatch@2.2.0", "", {}, "sha512-picomatch-220"], + "terser": ["terser@4.8.1", "", {}, "sha512-terser"], + "webpack": ["webpack@4.47.0", "", { "dependencies": { "terser": "^4.0.0" } }, "sha512-webpack"], + }, + })) + .unwrap(); + + let lockfile = BunLockfile::from_str(&contents).expect("lockfileVersion 3 should parse"); + assert_eq!(lockfile.data.lockfile_version, 3); + let expected_overrides: Map = serde_json::from_value(overrides).unwrap(); + assert_eq!(lockfile.data.overrides, expected_overrides); + + // Flat rules and the parent's own "." rule apply; a nested child rule and a + // rule keyed by a parent range do not (Bun materializes those as nested + // lockfile keys, which resolution follows on its own). + assert_eq!(lockfile.apply_overrides("lodash", "^4.0.0"), "4.17.21"); + assert_eq!(lockfile.apply_overrides("micromatch", "^4.0.0"), "4.0.5"); + assert_eq!(lockfile.apply_overrides("picomatch", "^2.2.0"), "^2.2.0"); + assert_eq!(lockfile.apply_overrides("webpack", "^4.0.0"), "^4.0.0"); + assert_eq!(lockfile.apply_overrides("terser", "^4.0.0"), "^4.0.0"); + + let micromatch = lockfile + .resolve_package("packages/app", "micromatch", "^4.0.0") + .unwrap() + .unwrap(); + assert_eq!(micromatch.key, "micromatch@4.0.5"); + let deps = lockfile + .all_dependencies("micromatch@4.0.5") + .unwrap() + .unwrap(); + assert_eq!(deps.get("picomatch"), Some(&"=2.3.2".to_string())); + + let pruned = prune_to_string( + &lockfile, + "packages/app", + &[ + ("lodash", "^4.0.0"), + ("micromatch", "^4.0.0"), + ("webpack", "^4.0.0"), + ], + ); + assert!( + pruned.starts_with("{\n \"lockfileVersion\": 3,\n"), + "pruned lockfile should keep version 3:\n{pruned}" + ); + assert!( + pruned.contains( + " \"overrides\": {\n \"lodash\": \"4.17.21\",\n \"micromatch\": {\n \".\": \ + \"4.0.5\",\n \"picomatch\": \"2.3.2\",\n },\n \"webpack@^4\": {\n \ + \"terser\": \"4.8.1\",\n },\n },\n" + ), + "nested overrides should be written in bun's format:\n{pruned}" + ); + + let reparsed = BunLockfile::from_str(&pruned).expect("pruned v3 lockfile should reparse"); + assert_eq!(reparsed.data.lockfile_version, 3); + assert_eq!(reparsed.data.overrides, expected_overrides); + assert_eq!( + reparsed.data.packages["micromatch/picomatch"].ident, + "picomatch@2.3.2" + ); + assert_eq!(reparsed.data.packages["picomatch"].ident, "picomatch@2.2.0"); + assert!(!reparsed.data.packages.contains_key("is-odd")); +} + +// Bun accepts object overrides at every lockfile version on read; a lockfile +// that fails the version 2 invariants but has nested rules is written as +// version 1 with objects in it. +#[test] +fn test_object_overrides_parse_at_older_lockfile_versions() { + let contents = serde_json::to_string(&json!({ + "lockfileVersion": 1, + "workspaces": { "": { "name": "root" } }, + "overrides": { "webpack": { "terser": "4.8.1" } }, + "packages": {}, + })) + .unwrap(); + + let lockfile = BunLockfile::from_str(&contents).unwrap(); + assert_eq!(lockfile.apply_overrides("webpack", "^4"), "^4"); + assert_eq!(lockfile.apply_overrides("terser", "^4"), "^4"); + let encoded = String::from_utf8(lockfile.encode().unwrap()).unwrap(); + assert!(encoded.contains("\"webpack\": {\n \"terser\": \"4.8.1\",\n },")); +} + +#[test] +fn test_newer_lockfile_versions_are_accepted_and_preserved() { + let contents = serde_json::to_string(&json!({ + "lockfileVersion": 4, + "workspaces": { "": { "name": "root" } }, + "packages": {}, + })) + .unwrap(); + + let lockfile = BunLockfile::from_str(&contents).expect("newer versions should parse"); + assert_eq!(lockfile.data.lockfile_version, 4); + let encoded = String::from_utf8(lockfile.encode().unwrap()).unwrap(); + assert!(encoded.starts_with("{\n \"lockfileVersion\": 4,\n")); + + let v3 = + BunLockfile::from_str(&contents.replace("\"lockfileVersion\":4", "\"lockfileVersion\":3")) + .unwrap(); + assert!( + crate::Lockfile::global_change(&lockfile, &v3), + "a version change is still a global change" + ); +} + +#[test] +fn test_negative_lockfile_version_is_rejected() { + let contents = serde_json::to_string(&json!({ + "lockfileVersion": -1, + "workspaces": { "": { "name": "root" } }, + "packages": {}, + })) + .unwrap(); + + assert!(matches!( + BunLockfile::from_str(&contents), + Err(crate::Error::UnsupportedBunVersion(-1)) + )); +} + +// Bun diffs the lockfile's trustedDependencies against the ones declared in +// package.json on every install; prune copies the root package.json verbatim, +// so the lockfile section has to be copied verbatim too. +#[test] +fn test_prune_preserves_trusted_dependencies() { + let contents = serde_json::to_string(&json!({ + "lockfileVersion": 1, + "workspaces": { + "": { "name": "root" }, + "packages/app": { + "name": "app", + "dependencies": { "esbuild": "0.25.0" }, + }, + "packages/other": { + "name": "other", + "dependencies": { "sharp": "0.34.0" }, + }, + }, + "trustedDependencies": ["esbuild", "sharp"], + "packages": { + "app": ["app@workspace:packages/app"], + "esbuild": ["esbuild@0.25.0", "", {}, "sha512-esbuild"], + "other": ["other@workspace:packages/other"], + "sharp": ["sharp@0.34.0", "", {}, "sha512-sharp"], + }, + })) + .unwrap(); + + let lockfile = BunLockfile::from_str(&contents).unwrap(); + let pruned = prune_to_string(&lockfile, "packages/app", &[("esbuild", "0.25.0")]); + + assert!( + pruned.contains(" \"trustedDependencies\": [\n \"esbuild\",\n \"sharp\",\n ],\n"), + "trustedDependencies should be copied verbatim:\n{pruned}" + ); + let reparsed = BunLockfile::from_str(&pruned).unwrap(); + assert_eq!(reparsed.data.trusted_dependencies, ["esbuild", "sharp"]); + assert!(!reparsed.data.packages.contains_key("sharp")); +} + +// Bun links workspace bins from the `bin`/`binDir` fields of the workspace +// entries, so a pruned lockfile without them installs the workspaces with no +// bins. +#[test] +fn test_prune_preserves_workspace_bins() { + let contents = serde_json::to_string(&json!({ + "lockfileVersion": 1, + "workspaces": { + "": {}, + "packages/cli": { + "name": "cli", + "version": "1.0.0", + "bin": { "cli": "bin/cli.js", "cli-dev": "bin/dev.js" }, + "dependencies": { "scripts": "workspace:*", "tool": "workspace:*" }, + }, + "packages/scripts": { + "name": "scripts", + "version": "1.0.0", + "binDir": "scripts", + }, + "packages/tool": { + "name": "tool", + "version": "1.0.0", + "bin": "tool.js", + }, + "packages/unrelated": { + "name": "unrelated", + "bin": "unrelated.js", + }, + }, + "packages": { + "cli": ["cli@workspace:packages/cli"], + "scripts": ["scripts@workspace:packages/scripts"], + "tool": ["tool@workspace:packages/tool"], + "unrelated": ["unrelated@workspace:packages/unrelated"], + }, + })) + .unwrap(); + + let lockfile = BunLockfile::from_str(&contents).unwrap(); + assert_eq!(lockfile.data.workspaces[""].name, ""); + assert_eq!( + lockfile.data.workspaces["packages/tool"].bin, + Some(json!("tool.js")) + ); + + let subgraph = ::subgraph( + &lockfile, + &[ + "packages/cli".into(), + "packages/scripts".into(), + "packages/tool".into(), + ], + &[], + ) + .unwrap(); + let pruned = String::from_utf8(subgraph.encode().unwrap()).unwrap(); + + assert!( + pruned.contains( + " \"packages/cli\": {\n \"name\": \"cli\",\n \"version\": \"1.0.0\",\n \ + \"bin\": {\n \"cli\": \"bin/cli.js\",\n \"cli-dev\": \"bin/dev.js\",\n \ + },\n \"dependencies\": {\n" + ), + "object bin should be preserved:\n{pruned}" + ); + assert!( + pruned.contains(" \"binDir\": \"scripts\",\n"), + "binDir should be preserved:\n{pruned}" + ); + assert!( + pruned.contains(" \"bin\": \"tool.js\",\n"), + "string bin should be preserved:\n{pruned}" + ); + assert!( + !pruned.contains("unrelated"), + "unrelated workspace should be pruned:\n{pruned}" + ); + + let reparsed = BunLockfile::from_str(&pruned).unwrap(); + for path in ["", "packages/cli", "packages/scripts", "packages/tool"] { + assert_eq!( + reparsed.data.workspaces[path], lockfile.data.workspaces[path], + "workspace entry for {path:?} should round-trip" + ); + } +} + +// Bun omits the root workspace's `name` when the root package.json has none. +#[test] +fn test_root_workspace_without_name_round_trips() { + let contents = serde_json::to_string(&json!({ + "lockfileVersion": 1, + "workspaces": { + "": { + "dependencies": { "is-odd": "3.0.1" }, + }, + }, + "packages": { + "is-odd": ["is-odd@3.0.1", "", {}, "sha512-is-odd"], + }, + })) + .unwrap(); + + let lockfile = BunLockfile::from_str(&contents).expect("root workspace name is optional"); + let resolved = lockfile + .resolve_package("", "is-odd", "3.0.1") + .unwrap() + .unwrap(); + assert_eq!(resolved.key, "is-odd@3.0.1"); + + let pruned = prune_to_string(&lockfile, "", &[("is-odd", "3.0.1")]); + assert!( + pruned.contains( + " \"workspaces\": {\n \"\": {\n \"dependencies\": {\n \"is-odd\": \ + \"3.0.1\",\n },\n },\n },\n" + ), + "root entry should be written without a name:\n{pruned}" + ); + assert!(BunLockfile::from_str(&pruned).is_ok()); +} + +// git and github entries carry an optional 4th element pinning the packed +// checkout: [ident, INFO, bun-tag, integrity]. Dropping it silently removes +// the content pin from the pruned lockfile. +#[test] +fn test_prune_preserves_git_integrity() { + let contents = serde_json::to_string(&json!({ + "lockfileVersion": 1, + "workspaces": { + "": { "name": "root" }, + "packages/app": { + "name": "app", + "dependencies": { + "from-git": "git+https://github.com/user/from-git.git#v1", + "from-github": "github:user/from-github#abc123", + "legacy": "github:user/legacy#def456", + }, + }, + }, + "packages": { + "app": ["app@workspace:packages/app"], + "from-git": [ + "from-git@git+https://github.com/user/from-git.git#0123456789abcdef", + { "dependencies": { "is-odd": "3.0.1" } }, + "0123456789abcdef", + "sha512-from-git", + ], + "from-github": ["from-github@github:user/from-github#abc123", {}, "abc123", "sha512-from-github"], + "is-odd": ["is-odd@3.0.1", "", {}, "sha512-is-odd"], + "legacy": ["legacy@github:user/legacy#def456", {}, "def456"], + }, + })) + .unwrap(); + + let lockfile = BunLockfile::from_str(&contents).unwrap(); + let from_git = &lockfile.data.packages["from-git"]; + assert_eq!(from_git.checksum.as_deref(), Some("0123456789abcdef")); + assert_eq!(from_git.integrity.as_deref(), Some("sha512-from-git")); + assert_eq!(lockfile.data.packages["legacy"].integrity, None); + + let pruned = prune_to_string( + &lockfile, + "packages/app", + &[ + ("from-git", "git+https://github.com/user/from-git.git#v1"), + ("from-github", "github:user/from-github#abc123"), + ("legacy", "github:user/legacy#def456"), + ], + ); + assert!( + pruned.contains( + " \"from-git\": [\"from-git@git+https://github.com/user/from-git.git#0123456789abcdef\", \ + { \"dependencies\": { \"is-odd\": \"3.0.1\" } }, \"0123456789abcdef\", \ + \"sha512-from-git\"]," + ), + "git integrity should be preserved:\n{pruned}" + ); + assert!( + pruned.contains( + " \"from-github\": [\"from-github@github:user/from-github#abc123\", {}, \ + \"abc123\", \"sha512-from-github\"]," + ), + "github integrity should be preserved:\n{pruned}" + ); + assert!( + pruned.contains(" \"legacy\": [\"legacy@github:user/legacy#def456\", {}, \"def456\"],"), + "entries without integrity should stay 3 elements:\n{pruned}" + ); + + let reparsed = BunLockfile::from_str(&pruned).unwrap(); + assert_eq!(reparsed.data.packages, lockfile.data.packages); +} + +// Local tarballs are written as [ident, INFO, integrity?] and root +// dependencies as [ident, { bin, binDir }]; Bun rejects either of them in the +// registry 4-tuple shape with "Expected an object". +#[test] +fn test_prune_preserves_local_tarball_and_root_entry_shapes() { + let contents = serde_json::to_string(&json!({ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "mono", + "dependencies": { + "bar": "./vendor/bar-0.0.2.tgz", + "baz": "file:./vendor/baz.tar.gz", + "mono-self": "npm:mono@root:", + "remote": "https://example.com/remote-1.0.0.tgz", + "@scope/self-dir": "*", + "self-plain": "*", + }, + }, + }, + "packages": { + "@scope/self-dir": ["@scope/self-dir@root:", { "binDir": "bin" }], + "bar": ["bar@./vendor/bar-0.0.2.tgz", { "dependencies": { "is-odd": "3.0.1" } }, "sha512-bar"], + "baz": ["baz@./vendor/baz.tar.gz", {}], + "is-odd": ["is-odd@3.0.1", "", {}, "sha512-is-odd"], + "mono-self": ["mono@root:", { "bin": { "mono": "cli.js" } }], + "remote": ["remote@https://example.com/remote-1.0.0.tgz", {}, "sha512-remote"], + "self-plain": ["self-plain@root:", {}], + }, + })) + .unwrap(); + + let lockfile = BunLockfile::from_str(&contents).unwrap(); + assert_eq!( + lockfile.data.packages["mono-self"].root, + Some(RootInfo { + bin: Some(json!({ "mono": "cli.js" })), + bin_dir: None, + }) + ); + assert_eq!( + lockfile.data.packages["self-plain"].root, + Some(RootInfo { + bin: None, + bin_dir: None, + }) + ); + + let pruned = prune_to_string( + &lockfile, + "", + &[ + ("bar", "./vendor/bar-0.0.2.tgz"), + ("baz", "file:./vendor/baz.tar.gz"), + ("mono-self", "npm:mono@root:"), + ("remote", "https://example.com/remote-1.0.0.tgz"), + ("@scope/self-dir", "*"), + ("self-plain", "*"), + ], + ); + + for expected in [ + " \"@scope/self-dir\": [\"@scope/self-dir@root:\", { \"binDir\": \"bin\" }],", + " \"bar\": [\"bar@./vendor/bar-0.0.2.tgz\", { \"dependencies\": { \"is-odd\": \ + \"3.0.1\" } }, \"sha512-bar\"],", + " \"baz\": [\"baz@./vendor/baz.tar.gz\", {}],", + " \"is-odd\": [\"is-odd@3.0.1\", \"\", {}, \"sha512-is-odd\"],", + " \"mono-self\": [\"mono@root:\", { \"bin\": { \"mono\": \"cli.js\" } }],", + " \"remote\": [\"remote@https://example.com/remote-1.0.0.tgz\", {}, \"sha512-remote\"],", + " \"self-plain\": [\"self-plain@root:\", {}],", + ] { + assert!( + pruned.contains(expected), + "expected pruned lockfile to contain:\n{expected}\n\ngot:\n{pruned}" + ); + } + + let reparsed = BunLockfile::from_str(&pruned).unwrap(); + assert_eq!(reparsed.data.packages, lockfile.data.packages); +} diff --git a/crates/turborepo-lockfiles/src/bun/types.rs b/crates/turborepo-lockfiles/src/bun/types.rs index b746bf30117df..5f55e958785a0 100644 --- a/crates/turborepo-lockfiles/src/bun/types.rs +++ b/crates/turborepo-lockfiles/src/bun/types.rs @@ -178,8 +178,8 @@ pub enum PackageIdent { Link { name: String, path: String }, /// File package: name@file:path File { name: String, path: String }, - /// Tarball package: name@tarball - Tarball { name: String }, + /// Tarball package: name@./local.tgz or name@https://host/remote.tgz + Tarball { name: String, url: String }, /// Root package: name@root: Root { name: String }, } @@ -221,10 +221,13 @@ impl PackageIdent { }; } - // Handle tarball - if rest == "tarball" { + // Handle tarballs. Bun classifies a resolution as a tarball purely + // by extension; local ones are stored as the relative path given in + // package.json (`./vendor/foo.tgz`), remote ones as the URL. + if rest.ends_with(".tgz") || rest.ends_with(".tar.gz") { return Self::Tarball { name: name.to_string(), + url: rest.to_string(), }; } @@ -257,7 +260,7 @@ impl PackageIdent { | Self::Git { name, .. } | Self::Link { name, .. } | Self::File { name, .. } - | Self::Tarball { name } + | Self::Tarball { name, .. } | Self::Root { name } => name, } } @@ -285,13 +288,20 @@ impl PackageIdent { } /// Returns true if this is a file, link, or tarball ident. - /// These package types use 2-element arrays: [ident, INFO] + /// These package types are written without a registry element: + /// `[ident, INFO]`, plus a trailing integrity for tarballs. pub fn is_local_package(&self) -> bool { matches!( self, Self::File { .. } | Self::Link { .. } | Self::Tarball { .. } ) } + + /// Returns true if this is a `name@root:` ident, i.e. a dependency on the + /// root package itself. Written as `[ident, { bin, binDir }]`. + pub fn is_root(&self) -> bool { + matches!(self, Self::Root { .. }) + } } impl fmt::Display for PackageIdent { @@ -302,7 +312,7 @@ impl fmt::Display for PackageIdent { Self::Git { name, url, .. } => write!(f, "{name}@{url}"), Self::Link { name, path } => write!(f, "{name}@link:{path}"), Self::File { name, path } => write!(f, "{name}@file:{path}"), - Self::Tarball { name } => write!(f, "{name}@tarball"), + Self::Tarball { name, url } => write!(f, "{name}@{url}"), Self::Root { name } => write!(f, "{name}@root:"), } } @@ -601,6 +611,40 @@ mod tests { } ); assert_eq!(ident.name(), "some-package"); + assert!(ident.is_root()); + assert!(!ident.is_local_package()); + } + + #[test] + fn test_package_ident_local_tarball() { + let ident = PackageIdent::parse("bar@./vendor/bar-0.0.2.tgz"); + assert_eq!( + ident, + PackageIdent::Tarball { + name: "bar".to_string(), + url: "./vendor/bar-0.0.2.tgz".to_string() + } + ); + assert_eq!(ident.name(), "bar"); + assert!(ident.is_local_package()); + assert_eq!(ident.to_string(), "bar@./vendor/bar-0.0.2.tgz"); + + let ident = PackageIdent::parse("@scope/baz@../baz.tar.gz"); + assert_eq!(ident.name(), "@scope/baz"); + assert!(ident.is_local_package()); + } + + #[test] + fn test_package_ident_remote_tarball() { + let ident = PackageIdent::parse("foo@https://example.com/foo-1.0.0.tgz"); + assert_eq!( + ident, + PackageIdent::Tarball { + name: "foo".to_string(), + url: "https://example.com/foo-1.0.0.tgz".to_string() + } + ); + assert!(ident.is_local_package()); } #[test]