diff --git a/completions/bun-cli.json b/completions/bun-cli.json index 5bcf1c989fbd..ca450182e634 100644 --- a/completions/bun-cli.json +++ b/completions/bun-cli.json @@ -3372,6 +3372,30 @@ "flags": [], "positionalArgs": [] }, + "sbom": { + "name": "sbom", + "description": "generate a Software Bill of Materials (SBOM) from the lockfile", + "flags": [ + { + "name": "format", + "description": "Output format: cyclonedx (default) or spdx", + "hasValue": true, + "valueType": "val", + "required": false, + "multiple": false + }, + { + "name": "outfile", + "shortName": "o", + "description": "Write the SBOM to a file instead of stdout", + "hasValue": true, + "valueType": "val", + "required": false, + "multiple": false + } + ], + "positionalArgs": [] + }, "why": { "name": "why", "description": " show dependency tree explaining why a package is installed", diff --git a/docs/pm/cli/pm.mdx b/docs/pm/cli/pm.mdx index 1319ddd2e218..2117c8805f0c 100644 --- a/docs/pm/cli/pm.mdx +++ b/docs/pm/cli/pm.mdx @@ -218,6 +218,25 @@ From a workspace root, every workspace's dependencies are listed; from inside a Requires both `bun.lock` and `node_modules`. Packages in the lockfile but missing from `node_modules` (e.g. after `bun install --production`) are skipped with a warning. +## sbom + +Generate a Software Bill of Materials (SBOM) from the lockfile in [CycloneDX 1.7](https://cyclonedx.org/) or [SPDX 2.3](https://spdx.dev/) JSON format: + +```bash terminal icon="terminal" +# CycloneDX 1.7 (default) to stdout +bun pm sbom + +# SPDX 2.3 to a file +bun pm sbom --format spdx -o sbom.spdx.json +``` + +For every package in the lockfile the SBOM includes the name and resolved version, a [purl](https://github.com/package-url/purl-spec) identifier, the download location (registry tarball URL, or a `git+@` locator for git dependencies), the integrity hash, and the full dependency graph. Dev dependencies are marked `excluded` (CycloneDX) / `DEV_DEPENDENCY_OF` (SPDX) and optional dependencies are marked `optional` / `OPTIONAL_DEPENDENCY_OF`, so downstream scanners like Grype, Trivy, or Dependency-Track can distinguish production from development packages. + +| Flag | Description | +| ---------------------- | ------------------------------------------ | +| `--format ` | `cyclonedx` (default) or `spdx` | +| `-o, --outfile ` | Write the SBOM to a file instead of stdout | + ## whoami Print your npm username. Requires you to be logged in (`bunx npm login`) with credentials in either `bunfig.toml` or `.npmrc`: diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index e1524675ffb9..56cccdcbb12d 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -135,6 +135,9 @@ impl PackageManagerCommand { bun pm ls list the dependency tree according to the current lockfile --all list the entire dependency tree according to the current lockfile --trusted list only trusted dependencies + bun pm sbom generate a Software Bill of Materials (SBOM) + --format cyclonedx (default) or spdx + -o, --outfile write the SBOM to a file instead of stdout bun pm why \ show dependency tree explaining why a package is installed bun pm licenses list installed packages grouped by license --json output as JSON diff --git a/src/install/PackageManager/CommandLineArguments.rs b/src/install/PackageManager/CommandLineArguments.rs index be895af25bf1..36084f4235b3 100644 --- a/src/install/PackageManager/CommandLineArguments.rs +++ b/src/install/PackageManager/CommandLineArguments.rs @@ -224,6 +224,12 @@ pub(crate) static PM_PARAMS: &[ParamType] = concat_params![ clap::param!( "--depth Maximum depth of the dependency tree to display" ), + clap::param!( + "--format SBOM output format: cyclonedx (default) or spdx" + ), + clap::param!( + "-o, --outfile Write the SBOM to a file instead of stdout" + ), clap::param!(" ... "), ] ]; @@ -550,6 +556,10 @@ pub struct CommandLineArguments { pub dev_only: bool, pub long: bool, + // `bun pm sbom` options + pub sbom_format: Option<&'static [u8]>, + pub sbom_outfile: Option<&'static [u8]>, + // `bun audit` options pub audit_level: Option, pub audit_ignore_list: &'static [&'static [u8]], @@ -637,6 +647,9 @@ impl Default for CommandLineArguments { dev_only: false, long: false, + sbom_format: None, + sbom_outfile: None, + audit_level: None, audit_ignore_list: &[], @@ -1726,6 +1739,10 @@ Full documentation is available at https://bun.com/docs/pm/cli/prune } cli.dev_only = args.flag(b"--dev"); cli.long = args.flag(b"--long"); + + // `bun pm sbom` command options + cli.sbom_format = args.option(b"--format"); + cli.sbom_outfile = args.option(b"--outfile"); } // `bun pm why` and `bun why` options diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index da7ddb805c55..efdf39ababec 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -71,6 +71,10 @@ pub struct Options { pub top_only: bool, pub depth: Option, + // `bun pm sbom` command options + pub sbom_format: Option<&'static [u8]>, + pub sbom_outfile: Option<&'static [u8]>, + /// isolated installs (pnpm-like) or hoisted installs (yarn-like, original) pub node_linker: NodeLinker, @@ -152,6 +156,8 @@ impl Default for Options { force: false, top_only: false, depth: None, + sbom_format: None, + sbom_outfile: None, node_linker: NodeLinker::Auto, public_hoist_pattern: None, hoist_pattern: None, @@ -890,6 +896,10 @@ impl Options { // `bun pm why` command options self.top_only = cli.top_only; self.depth = cli.depth; + + // `bun pm sbom` command options + self.sbom_format = cli.sbom_format; + self.sbom_outfile = cli.sbom_outfile; } else { self.log_level = if default_disable_progress_bar { LogLevel::DefaultNoProgress diff --git a/src/install/integrity.rs b/src/install/integrity.rs index e5ae223d335e..eaa0939fbaa3 100644 --- a/src/install/integrity.rs +++ b/src/install/integrity.rs @@ -167,7 +167,7 @@ impl Integrity { Integrity { value: out, tag } } - pub(crate) fn slice(&self) -> &[u8] { + pub fn slice(&self) -> &[u8] { &self.value[0..self.tag.digest_len()] } @@ -280,13 +280,13 @@ unsafe impl bytemuck::NoUninit for Tag {} impl Tag { pub(crate) const UNKNOWN: Tag = Tag(0); /// "shasum" in the metadata - pub(crate) const SHA1: Tag = Tag(1); + pub const SHA1: Tag = Tag(1); /// The value is a [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) value pub const SHA256: Tag = Tag(2); /// The value is a [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) value - pub(crate) const SHA384: Tag = Tag(3); + pub const SHA384: Tag = Tag(3); /// The value is a [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) value - pub(crate) const SHA512: Tag = Tag(4); + pub const SHA512: Tag = Tag(4); #[inline] pub fn is_supported(self) -> bool { diff --git a/src/install/lockfile/Package/Meta.rs b/src/install/lockfile/Package/Meta.rs index 2ac6e612756f..dcea808f7910 100644 --- a/src/install/lockfile/Package/Meta.rs +++ b/src/install/lockfile/Package/Meta.rs @@ -23,7 +23,7 @@ pub struct Meta { pub(crate) id: PackageID, pub(crate) man_dir: String, - pub(crate) integrity: Integrity, + pub integrity: Integrity, /// Shouldn't be used directly. Use `Meta.has_install_script()` and /// `Meta.set_has_install_script()` instead. diff --git a/src/install/resolution.rs b/src/install/resolution.rs index 5998e39bc87b..9486d73ecb1e 100644 --- a/src/install/resolution.rs +++ b/src/install/resolution.rs @@ -145,7 +145,7 @@ impl ResolutionType { } /// `git` or `github` payload — they share the [`Repository`] shape. #[inline] - pub(crate) fn repository(&self) -> &Repository { + pub fn repository(&self) -> &Repository { debug_assert!(self.tag == Tag::Git || self.tag == Tag::Github); // SAFETY: `git` and `github` occupy the same union slot type // (`Repository`); tag asserted to be one of the two. @@ -923,22 +923,22 @@ impl Default for Tag { #[allow(non_upper_case_globals)] impl Tag { - pub(crate) const Uninitialized: Tag = Tag(0); + pub const Uninitialized: Tag = Tag(0); pub const Root: Tag = Tag(1); pub const Npm: Tag = Tag(2); pub const Folder: Tag = Tag(4); - pub(crate) const LocalTarball: Tag = Tag(8); + pub const LocalTarball: Tag = Tag(8); - pub(crate) const Github: Tag = Tag(16); + pub const Github: Tag = Tag(16); - pub(crate) const Git: Tag = Tag(32); + pub const Git: Tag = Tag(32); pub const Symlink: Tag = Tag(64); pub const Workspace: Tag = Tag(72); - pub(crate) const RemoteTarball: Tag = Tag(80); + pub const RemoteTarball: Tag = Tag(80); // This is a placeholder for now. // But the intent is to eventually support URL imports at the package manager level. @@ -957,7 +957,7 @@ impl Tag { // This is similar to how Go does it, except it wouldn't clone the whole repo. // There are more efficient ways to do this, e.g. generate a .bun file just for all URL imports. // There are questions of determinism, but perhaps that's what Integrity would do. - pub(crate) const SingleFileModule: Tag = Tag(100); + pub const SingleFileModule: Tag = Tag(100); } impl Tag { diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index f609dfdd4a0b..1644f395aee4 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -360,6 +360,8 @@ pub(crate) mod patch_commit_command; pub(crate) mod pm_licenses_command; #[path = "pm_pkg_command.rs"] pub mod pm_pkg_command; +#[path = "pm_sbom_command.rs"] +pub mod pm_sbom_command; #[path = "pm_trusted_command.rs"] pub mod pm_trusted_command; pub mod pm_update_package_json; diff --git a/src/runtime/cli/package_manager_command.rs b/src/runtime/cli/package_manager_command.rs index 62b21f210e1b..b6828d9422f0 100644 --- a/src/runtime/cli/package_manager_command.rs +++ b/src/runtime/cli/package_manager_command.rs @@ -20,6 +20,7 @@ use bun_sys::{self, Dir, Fd, File}; use crate::cli::Command; use crate::cli::pm_licenses_command::{LicensesFlags, PmLicensesCommand}; use crate::cli::pm_pkg_command::PmPkgCommand; +use crate::cli::pm_sbom_command::PmSbomCommand; use crate::cli::pm_trusted_command::{DefaultTrustedCommand, TrustCommand, UntrustedCommand}; use crate::cli::pm_version_command::PmVersionCommand; use crate::cli::pm_view_command as PmViewCommand; @@ -187,6 +188,9 @@ impl PackageManagerCommand { bun pm ls list the dependency tree according to the current lockfile\n\ --all list the entire dependency tree according to the current lockfile\n\ --trusted list only trusted dependencies\n\ + bun pm sbom generate a Software Bill of Materials (SBOM)\n\ + --format cyclonedx (default) or spdx\n\ + -o, --outfile write the SBOM to a file instead of stdout\n\ bun pm why \\ show dependency tree explaining why a package is installed\n\ bun pm licenses list installed packages grouped by license\n\ --json output as JSON\n\ @@ -750,6 +754,9 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; let positionals: &[&[u8]] = pm.options.positionals; PmLicensesCommand::exec(pm, positionals, &cwd, licenses_flags)?; Global::exit(0); + } else if strings::eql_comptime(subcommand, b"sbom") { + PmSbomCommand::exec(&&mut *ctx, pm, &cwd)?; + Global::exit(0); } else if strings::eql_comptime(subcommand, b"pkg") { let positionals: &[&[u8]] = pm.options.positionals; PmPkgCommand::exec(&&mut *ctx, pm, positionals, &cwd)?; diff --git a/src/runtime/cli/pm_sbom_command.rs b/src/runtime/cli/pm_sbom_command.rs new file mode 100644 index 000000000000..6ce4a497543b --- /dev/null +++ b/src/runtime/cli/pm_sbom_command.rs @@ -0,0 +1,993 @@ +//! `bun pm sbom` - generate a Software Bill of Materials (SBOM) from the lockfile. +//! +//! Supports two output formats: +//! - CycloneDX 1.7 (default): +//! - SPDX 2.3: + +// The serializers hand-format pretty-printed JSON line by line. +#![allow(clippy::write_with_newline)] + +use std::io::Write as _; +use std::time::{SystemTime, UNIX_EPOCH}; + +use bun_collections::StringSet; +use bun_core::fmt::PathSep; +use bun_core::{Global, Output, strings}; +use bun_install::integrity::{Integrity, Tag as IntegrityTag}; +use bun_install::lockfile::{Lockfile, package::PackageColumns as _}; +use bun_install::resolution::Tag as ResolutionTag; +use bun_install::{ExternalSlice, INVALID_PACKAGE_ID, PackageID, PackageManager, Repository}; +use bun_jsc::uuid::UUID; +use bun_paths::{path_buffer_pool, platform, resolve_path}; +use bun_sys::Fd; + +use crate::cli::package_manager_command::PackageManagerCommand; +use crate::command; +use crate::shell::builtins::ls::civil_from_days; + +pub enum PmSbomCommand {} + +#[derive(Clone, Copy)] +enum Format { + CycloneDX, + Spdx, +} + +impl Format { + fn from_bytes(s: &[u8]) -> Option { + if s == b"cyclonedx" { + Some(Format::CycloneDX) + } else if s == b"spdx" { + Some(Format::Spdx) + } else { + None + } + } +} + +impl PmSbomCommand { + pub fn exec( + _ctx: &command::Context, + pm: &mut PackageManager, + original_cwd: &[u8], + ) -> Result<(), bun_core::Error> { + let format = match pm.options.sbom_format { + Some(f) => match Format::from_bytes(f) { + Some(fmt) => fmt, + None => { + Output::err_generic("invalid --format value: '{s}'", (bstr::BStr::new(f),)); + bun_core::note!("valid values are 'cyclonedx' or 'spdx'"); + Global::exit(1); + } + }, + None => Format::CycloneDX, + }; + + let outfile: Option<&[u8]> = pm.options.sbom_outfile; + + if pm.options.positionals.len() > 1 { + Output::err_generic( + "unexpected argument: '{s}'", + (bstr::BStr::new(pm.options.positionals[1]),), + ); + Output::flush(); + Self::print_help(); + Global::exit(1); + } + + { + let log_level = pm.options.log_level; + let load_lockfile = pm.load_lockfile_from_cwd::(); + PackageManagerCommand::handle_load_lockfile_errors(&load_lockfile, log_level); + } + + let generator = Generator::init(pm); + + let mut out: Vec = Vec::with_capacity(128 * 1024); + match format { + Format::CycloneDX => generator.write_cyclonedx(&mut out), + Format::Spdx => generator.write_spdx(&mut out), + } + + if let Some(path) = outfile { + // `PackageManager::init()` has already chdir'd to the workspace root. + let mut abs_buf = path_buffer_pool::get(); + let Some(abs_path) = resolve_path::join_abs_string_buf_checked::( + original_cwd, + &mut abs_buf[..], + &[path], + ) else { + Output::err_generic("output path is too long: '{s}'", (bstr::BStr::new(path),)); + Global::exit(1); + }; + let path_z = bun_core::ZBox::from_bytes(abs_path); + if let Err(e) = bun_sys::File::write_file(Fd::cwd(), path_z.as_zstr(), &out) { + Output::err(e, "failed to write SBOM to '{}'", (bstr::BStr::new(path),)); + Global::exit(1); + } + if pm.options.log_level != bun_install::LogLevel::Silent { + bun_core::pretty_errorln!( + "Saved {} ({} packages)", + bstr::BStr::new(path), + generator.components.len() + ); + } + } else { + let _ = Output::writer().write_all(&out); + } + Output::flush(); + + Ok(()) + } + + pub fn print_help() { + let help = "Usage: bun pm sbom [flags]\n\ + \n\ + \x20 Generate a Software Bill of Materials (SBOM) from the lockfile.\n\ + \n\ + Flags:\n\ + \x20 --format \\ Output format: cyclonedx (default) or spdx\n\ + \x20 -o, --outfile \\ Write the SBOM to a file instead of stdout\n\ + \n\ + Examples:\n\ + \x20 Write a CycloneDX 1.7 SBOM to stdout\n\ + \x20 bun pm sbom\n\ + \n\ + \x20 Write an SPDX 2.3 SBOM to a file\n\ + \x20 bun pm sbom --format spdx -o sbom.spdx.json\n\ + \n"; + #[allow(clippy::disallowed_methods)] + // help-text const contains markup — must use the runtime tag-walker + Output::pretty(help); + Output::flush(); + } +} + +/// Ordered from strongest to weakest. A path from the root inherits the +/// weakest edge along it; a package's final scope is the strongest over all +/// paths that reach it. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[repr(u8)] +enum Scope { + Required = 0, + Optional = 1, + Excluded = 2, +} + +impl Scope { + fn to_cyclonedx(self) -> &'static str { + match self { + Scope::Required => "required", + Scope::Optional => "optional", + Scope::Excluded => "excluded", + } + } + #[inline] + fn is_stronger_than(self, other: Scope) -> bool { + (self as u8) < (other as u8) + } + #[inline] + fn weaken_by(self, edge: Scope) -> Scope { + if (edge as u8) > (self as u8) { + edge + } else { + self + } + } +} + +/// All string fields are owned; Components are never individually freed. +struct Component { + package_id: PackageID, + /// Unique reference used as `bom-ref` (CycloneDX). For npm packages this + /// is `name@version`. + ref_: Vec, + /// SPDXID suffix (`SPDXRef-Package-`). SPDXIDs allow only + /// `[A-Za-z0-9.-]`, and two distinct refs (e.g. `foo_bar@1.0.0` and + /// `foo-bar@1.0.0`) can sanitize to the same value, so this is + /// deduplicated independently of `ref_`. + spdx_id: Vec, + name: Vec, + /// Version string. Empty if unavailable. + version: Vec, + /// Package URL identifier (`pkg:npm/...`). Empty if not applicable. + /// + purl: Vec, + /// Download URL (tarball for npm, repo for git, etc). Empty if unavailable. + download_url: Vec, + /// Direct dependencies by PackageID. + deps: Vec, + + scope: Scope, + integrity: Integrity, +} + +const INVALID_INDEX: u32 = u32::MAX; +const ROOT_MARKER: u32 = u32::MAX - 1; + +struct Generator<'a> { + lockfile: &'a Lockfile, + + root: Component, + /// All packages in the lockfile other than the root package. Index into + /// this list is unrelated to PackageID. + components: Vec, + /// Maps PackageID to index in `components`, or `ROOT_MARKER` for the root, + /// or `INVALID_INDEX` for packages we skipped (uninitialized resolutions). + id_to_component: Vec, + + /// ISO 8601 UTC timestamp for when this SBOM was generated. + timestamp: String, + serial_uuid: [u8; 36], +} + +impl<'a> Generator<'a> { + fn init(pm: &'a PackageManager) -> Generator<'a> { + let lockfile: &Lockfile = &pm.lockfile; + + let pkg_len = lockfile.packages.len(); + let string_bytes = lockfile.buffers.string_bytes.as_slice(); + let deps_buf = lockfile.buffers.dependencies.as_slice(); + let resolutions_buf = lockfile.buffers.resolutions.as_slice(); + let packages = lockfile.packages.slice(); + let pkg_names = packages.items_name(); + let pkg_name_hashes = packages.items_name_hash(); + let pkg_resolutions = packages.items_resolution(); + let pkg_metas = packages.items_meta(); + let pkg_dependencies = packages.items_dependencies(); + let pkg_dep_resolutions = packages.items_resolutions(); + + let mut id_to_component: Vec = vec![INVALID_INDEX; pkg_len]; + + let timestamp = make_iso_timestamp(); + let mut serial_uuid = [0u8; 36]; + UUID::init().print(&mut serial_uuid); + + // Scope propagation (see `Scope`) mirrors what `bun install --production` + // would install. Seeded from the lockfile root (PackageID 0), not + // `pm.root_package_id`: the SBOM covers the whole lockfile even when run + // from inside a workspace member. + let root_id: PackageID = 0; + let mut pkg_scope: Vec = vec![Scope::Excluded; pkg_len]; + if (root_id as usize) < pkg_len { + pkg_scope[root_id as usize] = Scope::Required; + let mut queue: Vec = vec![root_id]; + while let Some(parent) = queue.pop() { + let parent_scope = pkg_scope[parent as usize]; + let deps = pkg_dependencies[parent as usize].get(deps_buf); + let resolved = pkg_dep_resolutions[parent as usize].get(resolutions_buf); + for (dep, &child) in deps.iter().zip(resolved.iter()) { + if child == INVALID_PACKAGE_ID || child as usize >= pkg_len || child == parent { + continue; + } + // `is_optional()` excludes optional peer deps (it checks + // `optional && !peer`), so check `is_optional_peer()` too. + let edge = if dep.behavior.is_dev() { + Scope::Excluded + } else if dep.behavior.is_optional() || dep.behavior.is_optional_peer() { + Scope::Optional + } else { + Scope::Required + }; + let path_scope = parent_scope.weaken_by(edge); + if path_scope.is_stronger_than(pkg_scope[child as usize]) { + pkg_scope[child as usize] = path_scope; + queue.push(child); + } + } + } + } + + // Build the root component from the root package in the lockfile. + let root = { + let mut root_name: Vec = + if (root_id as usize) < pkg_len && pkg_names[root_id as usize].len() > 0 { + pkg_names[root_id as usize].slice(string_bytes).to_vec() + } else { + pm.root_package_json_name_at_time_of_init.to_vec() + }; + // Root version isn't stored in the lockfile for the root package + // itself; read it from `workspace_versions` or package.json. + let mut root_version: Vec = Vec::new(); + if (root_id as usize) < pkg_len { + if let Some(ws_version) = lockfile + .workspace_versions + .get(&pkg_name_hashes[root_id as usize]) + { + root_version = format!("{}", ws_version.fmt(string_bytes)).into_bytes(); + } + } + if root_version.is_empty() { + read_root_package_json(&mut root_name, &mut root_version); + } + if root_name.is_empty() { + root_name = b"root".to_vec(); + } + let root_ref: Vec = if !root_version.is_empty() { + let mut r = Vec::with_capacity(root_name.len() + 1 + root_version.len()); + r.extend_from_slice(&root_name); + r.push(b'@'); + r.extend_from_slice(&root_version); + r + } else { + root_name.clone() + }; + let spdx_id = sanitize_spdx_id(&root_ref); + let purl = if strings::is_npm_package_name(&root_name) && !root_version.is_empty() { + make_purl(&root_name, &root_version) + } else { + Vec::new() + }; + if (root_id as usize) < pkg_len { + id_to_component[root_id as usize] = ROOT_MARKER; + } + Component { + package_id: root_id, + ref_: root_ref, + spdx_id, + name: root_name, + version: root_version, + purl, + download_url: Vec::new(), + deps: Vec::new(), + scope: Scope::Required, + integrity: Integrity::default(), + } + }; + + // Build a component for every other package. + let mut components: Vec = Vec::with_capacity(pkg_len.saturating_sub(1)); + let mut seen_refs = StringSet::new(); + let mut seen_spdx_ids = StringSet::new(); + let _ = seen_refs.insert(&root.ref_); + let _ = seen_spdx_ids.insert(&root.spdx_id); + + for idx in 0..pkg_len { + let pkg_id = idx as PackageID; + if pkg_id == root_id { + continue; + } + let res = &pkg_resolutions[idx]; + if res.tag == ResolutionTag::Uninitialized { + continue; + } + + let name: &[u8] = pkg_names[idx].slice(string_bytes); + + let mut version: Vec = Vec::new(); + let mut purl: Vec = Vec::new(); + let mut download_url: Vec = Vec::new(); + let ref_: Vec; + + match res.tag { + ResolutionTag::Root => { + ref_ = if !name.is_empty() { + name.to_vec() + } else { + b"root".to_vec() + }; + } + ResolutionTag::Npm => { + let npm = res.npm(); + version = format!("{}", npm.version.fmt(string_bytes)).into_bytes(); + ref_ = fmt_ref(name, &version); + purl = make_purl(name, &version); + let url = npm.url.slice(string_bytes); + if !url.is_empty() { + download_url = url.to_vec(); + } + } + ResolutionTag::Workspace => { + let ws_path = res.workspace().slice(string_bytes); + ref_ = format!( + "{}@workspace:{}", + bstr::BStr::new(name), + bstr::BStr::new(ws_path) + ) + .into_bytes(); + if let Some(ws_version) = lockfile.workspace_versions.get(&pkg_name_hashes[idx]) + { + version = format!("{}", ws_version.fmt(string_bytes)).into_bytes(); + // Workspace names aren't validated as npm names. + if strings::is_npm_package_name(name) { + purl = make_purl(name, &version); + } + } + } + ResolutionTag::Folder + | ResolutionTag::Symlink + | ResolutionTag::SingleFileModule + | ResolutionTag::LocalTarball + | ResolutionTag::RemoteTarball + | ResolutionTag::Git + | ResolutionTag::Github => { + version = format!("{}", res.fmt(string_bytes, PathSep::Posix)).into_bytes(); + ref_ = fmt_ref(name, &version); + if res.tag == ResolutionTag::RemoteTarball { + let url = res.remote_tarball().slice(string_bytes); + if !url.is_empty() { + download_url = url.to_vec(); + } + } else if res.tag == ResolutionTag::Git || res.tag == ResolutionTag::Github { + download_url = vcs_locator( + res.repository(), + res.tag == ResolutionTag::Github, + string_bytes, + ); + } + } + _ => { + ref_ = format!( + "{}@{}", + bstr::BStr::new(name), + res.fmt(string_bytes, PathSep::Posix) + ) + .into_bytes(); + } + } + + // bom-refs must be unique; aliases can yield duplicate name@version. + let mut ref_ = ref_; + while seen_refs.contains(&ref_) { + let unique = format!("{}~{}", bstr::BStr::new(&ref_), idx).into_bytes(); + ref_ = unique; + } + let _ = seen_refs.insert(&ref_); + + // Deduplicated separately from `ref_`; see `Component::spdx_id`. + let mut spdx_id = sanitize_spdx_id(&ref_); + while seen_spdx_ids.contains(&spdx_id) { + let unique = format!("{}.{}", bstr::BStr::new(&spdx_id), idx).into_bytes(); + spdx_id = unique; + } + let _ = seen_spdx_ids.insert(&spdx_id); + + id_to_component[pkg_id as usize] = components.len() as u32; + components.push(Component { + package_id: pkg_id, + ref_, + spdx_id, + name: name.to_vec(), + version, + purl, + download_url, + deps: Vec::new(), + scope: if res.tag == ResolutionTag::Root { + Scope::Required + } else { + pkg_scope[idx] + }, + integrity: pkg_metas[idx].integrity, + }); + } + + let mut this = Generator { + lockfile, + root, + components, + id_to_component, + timestamp, + serial_uuid, + }; + + collect_deps( + &mut this.root, + pkg_dep_resolutions, + resolutions_buf, + pkg_len, + ); + for comp in this.components.iter_mut() { + collect_deps(comp, pkg_dep_resolutions, resolutions_buf, pkg_len); + } + + this + } + + fn component_for(&self, pkg_id: PackageID) -> Option<&Component> { + let idx = *self.id_to_component.get(pkg_id as usize)?; + if idx == INVALID_INDEX { + None + } else if idx == ROOT_MARKER { + Some(&self.root) + } else { + Some(&self.components[idx as usize]) + } + } + + // ==== CycloneDX 1.7 ==================================================== + + fn write_cyclonedx(&self, w: &mut Vec) { + w.extend_from_slice(b"{\n"); + w.extend_from_slice( + b" \"$schema\": \"https://cyclonedx.org/schema/bom-1.7.schema.json\",\n", + ); + w.extend_from_slice(b" \"bomFormat\": \"CycloneDX\",\n"); + w.extend_from_slice(b" \"specVersion\": \"1.7\",\n"); + let _ = write!( + w, + " \"serialNumber\": \"urn:uuid:{}\",\n", + bstr::BStr::new(&self.serial_uuid) + ); + w.extend_from_slice(b" \"version\": 1,\n"); + + // metadata + w.extend_from_slice(b" \"metadata\": {\n"); + let _ = write!(w, " \"timestamp\": \"{}\",\n", self.timestamp); + w.extend_from_slice(b" \"lifecycles\": [{ \"phase\": \"build\" }],\n"); + w.extend_from_slice(b" \"tools\": {\n"); + w.extend_from_slice(b" \"components\": [\n"); + let _ = write!( + w, + " {{ \"type\": \"application\", \"name\": \"bun\", \"version\": \"{}\" }}\n", + Global::package_json_version + ); + w.extend_from_slice(b" ]\n"); + w.extend_from_slice(b" },\n"); + w.extend_from_slice(b" \"component\": "); + self.write_cyclonedx_component(w, &self.root, "application", 4); + w.extend_from_slice(b"\n },\n"); + + // components + w.extend_from_slice(b" \"components\": ["); + for (i, comp) in self.components.iter().enumerate() { + if i != 0 { + w.push(b','); + } + w.extend_from_slice(b"\n "); + self.write_cyclonedx_component(w, comp, "library", 4); + } + if !self.components.is_empty() { + w.push(b'\n'); + } + w.extend_from_slice(b" ],\n"); + + // dependencies + w.extend_from_slice(b" \"dependencies\": [\n"); + self.write_cyclonedx_dependency(w, &self.root); + for comp in self.components.iter() { + w.extend_from_slice(b",\n"); + self.write_cyclonedx_dependency(w, comp); + } + w.extend_from_slice(b"\n ]\n"); + + w.extend_from_slice(b"}\n"); + } + + fn write_cyclonedx_component( + &self, + w: &mut Vec, + comp: &Component, + kind: &str, + base_indent: usize, + ) { + let pad = Indent(base_indent); + let pad1 = Indent(base_indent + 2); + w.extend_from_slice(b"{\n"); + let _ = write!(w, "{pad1}\"type\": \"{kind}\",\n"); + let _ = write!(w, "{pad1}\"bom-ref\": {},\n", json_str(&comp.ref_)); + let _ = write!(w, "{pad1}\"name\": {},\n", json_str(&comp.name)); + if !comp.version.is_empty() { + let _ = write!(w, "{pad1}\"version\": {},\n", json_str(&comp.version)); + } + let _ = write!(w, "{pad1}\"scope\": \"{}\"", comp.scope.to_cyclonedx()); + if !comp.purl.is_empty() { + let _ = write!(w, ",\n{pad1}\"purl\": {}", json_str(&comp.purl)); + } + if !comp.download_url.is_empty() { + let _ = write!( + w, + ",\n{pad1}\"externalReferences\": [{{ \"type\": \"distribution\", \"url\": {} }}]", + json_str(&comp.download_url) + ); + } + if let Some(alg) = cyclonedx_hash_alg(comp.integrity.tag) { + let _ = write!( + w, + ",\n{pad1}\"hashes\": [{{ \"alg\": \"{alg}\", \"content\": \"{}\" }}]", + bun_core::fmt::hex_lower(comp.integrity.slice()) + ); + } + let _ = write!(w, "\n{pad}}}"); + } + + fn write_cyclonedx_dependency(&self, w: &mut Vec, comp: &Component) { + let _ = write!( + w, + " {{ \"ref\": {}, \"dependsOn\": [", + json_str(&comp.ref_) + ); + let mut first = true; + for &dep_id in comp.deps.iter() { + let Some(dep) = self.component_for(dep_id) else { + continue; + }; + if !first { + w.extend_from_slice(b", "); + } + let _ = write!(w, "{}", json_str(&dep.ref_)); + first = false; + } + w.extend_from_slice(b"] }"); + } + + // ==== SPDX 2.3 ========================================================= + + fn write_spdx(&self, w: &mut Vec) { + w.extend_from_slice(b"{\n"); + w.extend_from_slice(b" \"spdxVersion\": \"SPDX-2.3\",\n"); + w.extend_from_slice(b" \"dataLicense\": \"CC0-1.0\",\n"); + w.extend_from_slice(b" \"SPDXID\": \"SPDXRef-DOCUMENT\",\n"); + let _ = write!(w, " \"name\": {},\n", json_str(&self.root.ref_)); + let _ = write!( + w, + " \"documentNamespace\": \"https://spdx.org/spdxdocs/{}-{}\",\n", + bstr::BStr::new(&self.root.spdx_id), + bstr::BStr::new(&self.serial_uuid) + ); + w.extend_from_slice(b" \"creationInfo\": {\n"); + let _ = write!(w, " \"created\": \"{}\",\n", self.timestamp); + let _ = write!( + w, + " \"creators\": [\"Tool: bun-{}\"]\n", + Global::package_json_version + ); + w.extend_from_slice(b" },\n"); + let _ = write!( + w, + " \"documentDescribes\": [\"SPDXRef-Package-{}\"],\n", + bstr::BStr::new(&self.root.spdx_id) + ); + + // packages + w.extend_from_slice(b" \"packages\": [\n"); + self.write_spdx_package(w, &self.root, true); + for comp in self.components.iter() { + w.extend_from_slice(b",\n"); + self.write_spdx_package(w, comp, false); + } + w.extend_from_slice(b"\n ],\n"); + + // relationships + w.extend_from_slice(b" \"relationships\": [\n"); + let _ = write!( + w, + " {{ \"spdxElementId\": \"SPDXRef-DOCUMENT\", \"relatedSpdxElement\": \"SPDXRef-Package-{}\", \"relationshipType\": \"DESCRIBES\" }}", + bstr::BStr::new(&self.root.spdx_id) + ); + self.write_spdx_relationships(w, &self.root); + for comp in self.components.iter() { + self.write_spdx_relationships(w, comp); + } + w.extend_from_slice(b"\n ]\n"); + + w.extend_from_slice(b"}\n"); + } + + fn write_spdx_package(&self, w: &mut Vec, comp: &Component, is_root: bool) { + w.extend_from_slice(b" {\n"); + let _ = write!(w, " \"name\": {},\n", json_str(&comp.name)); + let _ = write!( + w, + " \"SPDXID\": \"SPDXRef-Package-{}\",\n", + bstr::BStr::new(&comp.spdx_id) + ); + if !comp.version.is_empty() { + let _ = write!(w, " \"versionInfo\": {},\n", json_str(&comp.version)); + } + if is_root { + w.extend_from_slice(b" \"primaryPackagePurpose\": \"APPLICATION\",\n"); + } + if !comp.download_url.is_empty() { + let _ = write!( + w, + " \"downloadLocation\": {},\n", + json_str(&comp.download_url) + ); + } else { + w.extend_from_slice(b" \"downloadLocation\": \"NOASSERTION\",\n"); + } + w.extend_from_slice(b" \"filesAnalyzed\": false,\n"); + w.extend_from_slice(b" \"licenseConcluded\": \"NOASSERTION\",\n"); + w.extend_from_slice(b" \"licenseDeclared\": \"NOASSERTION\",\n"); + w.extend_from_slice(b" \"copyrightText\": \"NOASSERTION\""); + if !comp.purl.is_empty() { + let _ = write!( + w, + ",\n \"externalRefs\": [{{ \"referenceCategory\": \"PACKAGE-MANAGER\", \"referenceType\": \"purl\", \"referenceLocator\": {} }}]", + json_str(&comp.purl) + ); + } + if let Some(alg) = spdx_hash_alg(comp.integrity.tag) { + let _ = write!( + w, + ",\n \"checksums\": [{{ \"algorithm\": \"{alg}\", \"checksumValue\": \"{}\" }}]", + bun_core::fmt::hex_lower(comp.integrity.slice()) + ); + } + w.extend_from_slice(b"\n }"); + } + + fn write_spdx_relationships(&self, w: &mut Vec, comp: &Component) { + let packages = self.lockfile.packages.slice(); + let deps_buf = self.lockfile.buffers.dependencies.as_slice(); + let resolutions_buf = self.lockfile.buffers.resolutions.as_slice(); + let pkg_dependencies = packages.items_dependencies(); + let pkg_dep_resolutions = packages.items_resolutions(); + + for &dep_id in comp.deps.iter() { + let Some(dep_comp) = self.component_for(dep_id) else { + continue; + }; + // The same package can appear under several dependency groups; + // pick the strongest edge (required > optional > dev), as for scope. + let rel_type = { + let deps = pkg_dependencies[comp.package_id as usize].get(deps_buf); + let resolved = pkg_dep_resolutions[comp.package_id as usize].get(resolutions_buf); + let mut has_dev = false; + let mut has_optional = false; + let mut is_required = false; + for (dep, &r) in deps.iter().zip(resolved.iter()) { + if r != dep_id { + continue; + } + if dep.behavior.is_dev() { + has_dev = true; + } else if dep.behavior.is_optional() || dep.behavior.is_optional_peer() { + has_optional = true; + } else { + is_required = true; + break; + } + } + if is_required { + RelType::DependsOn + } else if has_optional { + RelType::OptionalOf + } else if has_dev { + RelType::DevOf + } else { + RelType::DependsOn + } + }; + match rel_type { + RelType::DependsOn => { + let _ = write!( + w, + ",\n {{ \"spdxElementId\": \"SPDXRef-Package-{}\", \"relatedSpdxElement\": \"SPDXRef-Package-{}\", \"relationshipType\": \"DEPENDS_ON\" }}", + bstr::BStr::new(&comp.spdx_id), + bstr::BStr::new(&dep_comp.spdx_id) + ); + } + // `*_OF` relationships point from the dependency to the dependent. + RelType::OptionalOf => { + let _ = write!( + w, + ",\n {{ \"spdxElementId\": \"SPDXRef-Package-{}\", \"relatedSpdxElement\": \"SPDXRef-Package-{}\", \"relationshipType\": \"OPTIONAL_DEPENDENCY_OF\" }}", + bstr::BStr::new(&dep_comp.spdx_id), + bstr::BStr::new(&comp.spdx_id) + ); + } + RelType::DevOf => { + let _ = write!( + w, + ",\n {{ \"spdxElementId\": \"SPDXRef-Package-{}\", \"relatedSpdxElement\": \"SPDXRef-Package-{}\", \"relationshipType\": \"DEV_DEPENDENCY_OF\" }}", + bstr::BStr::new(&dep_comp.spdx_id), + bstr::BStr::new(&comp.spdx_id) + ); + } + } + } + } +} + +enum RelType { + DependsOn, + OptionalOf, + DevOf, +} + +// ───── helpers ──────────────────────────────────────────────────────────── + +fn collect_deps( + comp: &mut Component, + pkg_dep_resolutions: &[ExternalSlice], + resolutions_buf: &[PackageID], + pkg_len: usize, +) { + if comp.package_id as usize >= pkg_len { + return; + } + let resolved = pkg_dep_resolutions[comp.package_id as usize].get(resolutions_buf); + for &resolved_id in resolved.iter() { + // Self-edges (`"pkg": "file:."`) are skipped, as in the scope BFS. + if resolved_id == INVALID_PACKAGE_ID + || resolved_id as usize >= pkg_len + || resolved_id == comp.package_id + { + continue; + } + // Deduplicate — a package can list the same dep under both + // `dependencies` and `peerDependencies`, for example. + if !comp.deps.contains(&resolved_id) { + comp.deps.push(resolved_id); + } + } +} + +/// Read the root package's name/version from `package.json` in the current +/// working directory (which `PackageManager::init` has set to the workspace +/// root). Only fills in fields that are currently empty. +fn read_root_package_json(root_name: &mut Vec, root_version: &mut Vec) { + let Ok(contents) = bun_sys::File::read_from(Fd::cwd(), b"package.json") else { + return; + }; + let source = bun_ast::Source::init_path_string(b"package.json", &contents[..]); + let mut log = bun_ast::Log::init(); + let bump = bun_alloc::Arena::new(); + let Ok(json) = bun_parsers::json::parse_package_json_utf8(&source, &mut log, &bump) else { + return; + }; + if root_version.is_empty() { + if let Some(e) = json.get(b"version") { + if let Some(v) = e.as_utf8_string_literal() { + if !v.is_empty() { + *root_version = v.to_vec(); + } + } + } + } + if root_name.is_empty() { + if let Some(e) = json.get(b"name") { + if let Some(n) = e.as_utf8_string_literal() { + if !n.is_empty() { + *root_name = n.to_vec(); + } + } + } + } +} + +/// SPDXID values may only contain letters, numbers, `.`, and `-`. Build the +/// `SPDXRef-Package-…` suffix by replacing anything else with `-`. +fn sanitize_spdx_id(ref_: &[u8]) -> Vec { + ref_.iter() + .map(|&c| match c { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'-' => c, + _ => b'-', + }) + .collect() +} + +fn fmt_ref(name: &[u8], version: &[u8]) -> Vec { + let mut r = Vec::with_capacity(name.len() + 1 + version.len()); + r.extend_from_slice(name); + r.push(b'@'); + r.extend_from_slice(version); + r +} + +/// purl-spec: `pkg:npm/namespace/name@version`. For scoped packages the `@` +/// in the scope must be percent-encoded. The version must also be +/// percent-encoded (semver build metadata `+` -> `%2B`). +fn make_purl(name: &[u8], version: &[u8]) -> Vec { + let mut out: Vec = Vec::with_capacity(8 + name.len() + version.len() + 4); + out.extend_from_slice(b"pkg:npm/"); + if name.first() == Some(&b'@') { + if let Some(slash) = strings::index_of_char(name, b'/') { + let slash = slash as usize; + out.extend_from_slice(b"%40"); + out.extend_from_slice(&name[1..slash]); + out.push(b'/'); + out.extend_from_slice(&name[slash + 1..]); + out.push(b'@'); + purl_encode_into(&mut out, version); + return out; + } + } + out.extend_from_slice(name); + out.push(b'@'); + purl_encode_into(&mut out, version); + out +} + +/// Percent-encodes bytes outside the RFC 3986 unreserved set +/// (`A-Za-z0-9-._~`) for use in purl components. Matches what packageurl-js +/// does via `encodeURIComponent()`. +fn purl_encode_into(out: &mut Vec, s: &[u8]) { + for &c in s { + match c { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => out.push(c), + _ => { + out.push(b'%'); + out.extend_from_slice(&bun_core::fmt::hex_byte_upper(c)); + } + } + } +} + +/// SPDX 2.3 §7.7 VCS locator (`git+@`), also used as the +/// CycloneDX distribution URL. GitHub resolutions become an https clone URL; +/// scp-style `user@host:path` becomes `ssh://user@host/path`. +fn vcs_locator(repo: &Repository, is_github: bool, buf: &[u8]) -> Vec { + let mut out: Vec = Vec::with_capacity(96); + out.extend_from_slice(b"git+"); + if is_github { + out.extend_from_slice(b"https://github.com/"); + out.extend_from_slice(repo.owner.slice(buf)); + out.push(b'/'); + out.extend_from_slice(repo.repo.slice(buf)); + out.extend_from_slice(b".git"); + } else { + let url = repo.repo.slice(buf); + let url = url.strip_prefix(b"git+").unwrap_or(url); + if strings::contains(url, b"://") { + out.extend_from_slice(url); + } else if let Some(colon) = strings::index_of_char_usize(url, b':') { + out.extend_from_slice(b"ssh://"); + out.extend_from_slice(&url[..colon]); + out.push(b'/'); + out.extend_from_slice(&url[colon + 1..]); + } else { + out.extend_from_slice(url); + } + } + // `resolved` is stored as `-`; fall back to the committish. + let mut rev = repo.resolved.slice(buf); + if let Some(dash) = strings::last_index_of_char(rev, b'-') { + rev = &rev[dash as usize + 1..]; + } + if rev.is_empty() { + rev = repo.committish.slice(buf); + } + if !rev.is_empty() { + out.push(b'@'); + out.extend_from_slice(rev); + } + out +} + +fn make_iso_timestamp() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let (y, m, d) = civil_from_days((secs / 86400) as i64); + let sod = (secs % 86400) as u32; + let (hh, mm, ss) = (sod / 3600, (sod % 3600) / 60, sod % 60); + format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z") +} + +fn cyclonedx_hash_alg(tag: IntegrityTag) -> Option<&'static str> { + match tag { + IntegrityTag::SHA1 => Some("SHA-1"), + IntegrityTag::SHA256 => Some("SHA-256"), + IntegrityTag::SHA384 => Some("SHA-384"), + IntegrityTag::SHA512 => Some("SHA-512"), + _ => None, + } +} + +fn spdx_hash_alg(tag: IntegrityTag) -> Option<&'static str> { + match tag { + IntegrityTag::SHA1 => Some("SHA1"), + IntegrityTag::SHA256 => Some("SHA256"), + IntegrityTag::SHA384 => Some("SHA384"), + IntegrityTag::SHA512 => Some("SHA512"), + _ => None, + } +} + +struct Indent(usize); +impl core::fmt::Display for Indent { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + for _ in 0..self.0 { + f.write_str(" ")?; + } + Ok(()) + } +} + +#[inline] +fn json_str(s: &[u8]) -> bun_core::fmt::JSONFormatterUTF8<'_> { + bun_core::fmt::format_json_string_utf8(s, Default::default()) +} diff --git a/src/runtime/shell/builtin/ls.rs b/src/runtime/shell/builtin/ls.rs index 8f6213a5dcad..ceb85dc18c9d 100644 --- a/src/runtime/shell/builtin/ls.rs +++ b/src/runtime/shell/builtin/ls.rs @@ -734,7 +734,7 @@ fn format_time(timestamp: i64, now_secs: u64) -> [u8; 12] { /// Howard Hinnant's `civil_from_days` — converts days-since-1970-01-01 to a /// proleptic-Gregorian (year, month[1..=12], day[1..=31]). -fn civil_from_days(z: i64) -> (i32, u8, u8) { +pub(crate) fn civil_from_days(z: i64) -> (i32, u8, u8) { let z = z + 719_468; let era = z.div_euclid(146_097); let doe = (z - era * 146_097) as u32; // [0, 146096] diff --git a/test/cli/install/bun-pm-sbom.test.ts b/test/cli/install/bun-pm-sbom.test.ts new file mode 100644 index 000000000000..4f5837dda5d8 --- /dev/null +++ b/test/cli/install/bun-pm-sbom.test.ts @@ -0,0 +1,505 @@ +import { spawn, write } from "bun"; +import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { VerdaccioRegistry, bunEnv, bunExe, isWindows, runBunInstall, tempDir } from "harness"; +import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +// Each test does a full `bun install` against a local verdaccio registry; +// on a loaded host the default 5s timeout isn't enough, and a timed-out +// test triggers dangling-process cleanup which kills verdaccio and +// cascades failures into every subsequent test. +setDefaultTimeout(1000 * 60 * 2); + +const registry = new VerdaccioRegistry(); + +beforeAll(async () => { + await registry.start(); +}); + +afterAll(() => { + registry.stop(); +}); + +async function sbom(cwd: string, args: string[] = []) { + await using proc = spawn({ + cmd: [bunExe(), "pm", "sbom", ...args], + env: bunEnv, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // Debug builds emit `Output.debugWarn` noise (e.g. "WorkspaceMap.insert: + // key ... does not exist" when PackageManager.init() re-scans workspaces + // from a subdirectory). It's not gated on BUN_DEBUG_QUIET_LOGS, so strip + // it here the same way `stderrForInstall` strips slow-filesystem warnings. + const stderr = rawStderr.replace(/^debug warn:.*\n?/gm, ""); + return { stdout, stderr, exitCode }; +} + +// The tests run concurrently, so each one gets its own project directory and +// its own install cache and temp directory. `registry.createTestDir()` is not +// used because it also resets registry-wide state on every call. +async function newProject() { + const packageDir = String(tempDir("sbom-", {})); + await registry.writeBunfig(packageDir); + const tmp = join(packageDir, ".tmp"); + mkdirSync(tmp); + const env = { + ...bunEnv, + BUN_INSTALL_CACHE_DIR: join(packageDir, ".bun-cache"), + BUN_TMPDIR: tmp, + TMPDIR: tmp, + TEMP: tmp, + TMP: tmp, + }; + return { packageDir, packageJson: join(packageDir, "package.json"), env }; +} + +async function setup(name: string, pkg: object) { + const { packageDir, packageJson, env } = await newProject(); + await write(packageJson, JSON.stringify({ name, version: "1.0.0", ...pkg })); + await runBunInstall(env, packageDir); + return packageDir; +} + +describe.concurrent("bun pm sbom", () => { + describe("CycloneDX", () => { + test("produces a spec-valid document with components and dependency graph", async () => { + const dir = await setup("sbom-cdx", { + dependencies: { "one-dep": "1.0.0" }, + devDependencies: { "a-dep": "1.0.1" }, + optionalDependencies: { "no-deps": "2.0.0" }, + }); + + const { stdout, stderr, exitCode } = await sbom(dir); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + + const bom = JSON.parse(stdout); + + expect(bom.bomFormat).toBe("CycloneDX"); + expect(bom.specVersion).toBe("1.7"); + expect(bom.$schema).toContain("cyclonedx.org/schema/bom-1.7"); + expect(bom.serialNumber).toMatch(/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + expect(bom.version).toBe(1); + + // metadata + expect(bom.metadata.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); + expect(bom.metadata.tools.components[0].name).toBe("bun"); + expect(typeof bom.metadata.tools.components[0].version).toBe("string"); + expect(bom.metadata.component.name).toBe("sbom-cdx"); + expect(bom.metadata.component.version).toBe("1.0.0"); + expect(bom.metadata.component.type).toBe("application"); + expect(bom.metadata.component["bom-ref"]).toBe("sbom-cdx@1.0.0"); + + // components: one-dep, no-deps@1.0.1 (transitive via one-dep), no-deps@2.0.0 (optional), a-dep + const byRef = Object.fromEntries(bom.components.map((c: any) => [c["bom-ref"], c])); + expect(Object.keys(byRef).sort()).toEqual(["a-dep@1.0.1", "no-deps@1.0.1", "no-deps@2.0.0", "one-dep@1.0.0"]); + + const oneDep = byRef["one-dep@1.0.0"]; + expect(oneDep).toMatchObject({ + type: "library", + name: "one-dep", + version: "1.0.0", + purl: "pkg:npm/one-dep@1.0.0", + scope: "required", + }); + expect(oneDep.externalReferences[0].type).toBe("distribution"); + expect(oneDep.externalReferences[0].url).toContain("/one-dep/-/one-dep-1.0.0.tgz"); + expect(oneDep.hashes[0].alg).toBe("SHA-512"); + // SHA-512 hex is 128 chars + expect(oneDep.hashes[0].content).toMatch(/^[0-9a-f]{128}$/); + + expect(byRef["a-dep@1.0.1"].scope).toBe("excluded"); + expect(byRef["no-deps@1.0.1"].scope).toBe("required"); + expect(byRef["no-deps@2.0.0"].scope).toBe("optional"); + + // dependency graph + const depsByRef = Object.fromEntries(bom.dependencies.map((d: any) => [d.ref, d.dependsOn])); + expect(depsByRef["sbom-cdx@1.0.0"].sort()).toEqual(["a-dep@1.0.1", "no-deps@2.0.0", "one-dep@1.0.0"]); + expect(depsByRef["one-dep@1.0.0"]).toEqual(["no-deps@1.0.1"]); + expect(depsByRef["no-deps@1.0.1"]).toEqual([]); + expect(depsByRef["no-deps@2.0.0"]).toEqual([]); + expect(depsByRef["a-dep@1.0.1"]).toEqual([]); + + // every bom-ref appearing in dependencies must be declared + const declared = new Set([ + bom.metadata.component["bom-ref"], + ...bom.components.map((c: any) => c["bom-ref"]), + ]); + for (const d of bom.dependencies) { + expect(declared.has(d.ref)).toBe(true); + for (const r of d.dependsOn) expect(declared.has(r)).toBe(true); + } + }); + + test("percent-encodes scope in purl", async () => { + const dir = await setup("sbom-scoped", { + dependencies: { "@types/no-deps": "1.0.0" }, + }); + + const { stdout, stderr, exitCode } = await sbom(dir); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + + const bom = JSON.parse(stdout); + const scoped = bom.components.find((c: any) => c.name === "@types/no-deps"); + expect(scoped).toBeDefined(); + expect(scoped.purl).toBe("pkg:npm/%40types/no-deps@1.0.0"); + }); + + test("marks transitive deps of a devDependency as excluded", async () => { + // one-dep depends on no-deps@1.0.1. As a root devDependency, both + // one-dep AND its transitive dep no-deps must be `excluded` (neither + // would be installed under --production). + const dir = await setup("sbom-trans-dev", { + devDependencies: { "one-dep": "1.0.0" }, + }); + + const { stdout, stderr, exitCode } = await sbom(dir); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + + const bom = JSON.parse(stdout); + const byRef = Object.fromEntries(bom.components.map((c: any) => [c["bom-ref"], c])); + expect(byRef["one-dep@1.0.0"].scope).toBe("excluded"); + expect(byRef["no-deps@1.0.1"].scope).toBe("excluded"); + }); + + test("a package reachable via both a prod path and a transitive-dev path is required", async () => { + // no-deps@1.0.1 is reachable via root -> one-dep (dev) -> no-deps, + // AND directly via root -> no-deps (prod). The prod path wins. + const dir = await setup("sbom-mixed", { + dependencies: { "no-deps": "1.0.1" }, + devDependencies: { "one-dep": "1.0.0" }, + }); + + const { stdout, stderr, exitCode } = await sbom(dir); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + + const bom = JSON.parse(stdout); + const byRef = Object.fromEntries(bom.components.map((c: any) => [c["bom-ref"], c])); + expect(byRef["one-dep@1.0.0"].scope).toBe("excluded"); + expect(byRef["no-deps@1.0.1"].scope).toBe("required"); + }); + }); + + describe("SPDX", () => { + test("produces a spec-valid document with packages and relationships", async () => { + const dir = await setup("sbom-spdx", { + dependencies: { "one-dep": "1.0.0" }, + devDependencies: { "a-dep": "1.0.1" }, + optionalDependencies: { "no-deps": "2.0.0" }, + }); + + const { stdout, stderr, exitCode } = await sbom(dir, ["--format", "spdx"]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + + const doc = JSON.parse(stdout); + + expect(doc.spdxVersion).toBe("SPDX-2.3"); + expect(doc.dataLicense).toBe("CC0-1.0"); + expect(doc.SPDXID).toBe("SPDXRef-DOCUMENT"); + expect(doc.name).toBe("sbom-spdx@1.0.0"); + expect(doc.documentNamespace).toMatch( + /^https:\/\/spdx\.org\/spdxdocs\/sbom-spdx-1\.0\.0-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + expect(doc.creationInfo.created).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); + expect(doc.creationInfo.creators[0]).toMatch(/^Tool: bun-/); + expect(doc.documentDescribes).toEqual(["SPDXRef-Package-sbom-spdx-1.0.0"]); + + // packages + const byId = Object.fromEntries(doc.packages.map((p: any) => [p.SPDXID, p])); + expect(Object.keys(byId).sort()).toEqual([ + "SPDXRef-Package-a-dep-1.0.1", + "SPDXRef-Package-no-deps-1.0.1", + "SPDXRef-Package-no-deps-2.0.0", + "SPDXRef-Package-one-dep-1.0.0", + "SPDXRef-Package-sbom-spdx-1.0.0", + ]); + + const root = byId["SPDXRef-Package-sbom-spdx-1.0.0"]; + expect(root).toMatchObject({ + name: "sbom-spdx", + versionInfo: "1.0.0", + downloadLocation: "NOASSERTION", + filesAnalyzed: false, + primaryPackagePurpose: "APPLICATION", + }); + + const oneDep = byId["SPDXRef-Package-one-dep-1.0.0"]; + expect(oneDep.name).toBe("one-dep"); + expect(oneDep.versionInfo).toBe("1.0.0"); + expect(oneDep.downloadLocation).toContain("/one-dep/-/one-dep-1.0.0.tgz"); + expect(oneDep.externalRefs).toEqual([ + { referenceCategory: "PACKAGE-MANAGER", referenceType: "purl", referenceLocator: "pkg:npm/one-dep@1.0.0" }, + ]); + expect(oneDep.checksums[0].algorithm).toBe("SHA512"); + expect(oneDep.checksums[0].checksumValue).toMatch(/^[0-9a-f]{128}$/); + + // relationships + const rels = doc.relationships; + expect(rels).toContainEqual({ + spdxElementId: "SPDXRef-DOCUMENT", + relatedSpdxElement: "SPDXRef-Package-sbom-spdx-1.0.0", + relationshipType: "DESCRIBES", + }); + expect(rels).toContainEqual({ + spdxElementId: "SPDXRef-Package-sbom-spdx-1.0.0", + relatedSpdxElement: "SPDXRef-Package-one-dep-1.0.0", + relationshipType: "DEPENDS_ON", + }); + expect(rels).toContainEqual({ + spdxElementId: "SPDXRef-Package-one-dep-1.0.0", + relatedSpdxElement: "SPDXRef-Package-no-deps-1.0.1", + relationshipType: "DEPENDS_ON", + }); + // *_OF relationships point from the dependency to the dependent. + expect(rels).toContainEqual({ + spdxElementId: "SPDXRef-Package-a-dep-1.0.1", + relatedSpdxElement: "SPDXRef-Package-sbom-spdx-1.0.0", + relationshipType: "DEV_DEPENDENCY_OF", + }); + expect(rels).toContainEqual({ + spdxElementId: "SPDXRef-Package-no-deps-2.0.0", + relatedSpdxElement: "SPDXRef-Package-sbom-spdx-1.0.0", + relationshipType: "OPTIONAL_DEPENDENCY_OF", + }); + + // every SPDXID referenced in a relationship must be declared + const declared = new Set([doc.SPDXID, ...doc.packages.map((p: any) => p.SPDXID)]); + for (const r of rels) { + expect(declared.has(r.spdxElementId)).toBe(true); + expect(declared.has(r.relatedSpdxElement)).toBe(true); + } + + // all SPDXIDs match the required pattern + for (const p of doc.packages) { + expect(p.SPDXID).toMatch(/^SPDXRef-[A-Za-z0-9.\-]+$/); + } + }); + }); + + test("writes to a file with -o", async () => { + const dir = await setup("sbom-outfile", { dependencies: { "no-deps": "1.0.0" } }); + const outfile = join(dir, "sbom.cdx.json"); + + const { stdout, exitCode } = await sbom(dir, ["-o", outfile]); + expect(stdout).toBe(""); + expect(exitCode).toBe(0); + expect(existsSync(outfile)).toBe(true); + + const bom = JSON.parse(readFileSync(outfile, "utf8")); + expect(bom.bomFormat).toBe("CycloneDX"); + expect(bom.components.map((c: any) => c.name)).toEqual(["no-deps"]); + }); + + // On Windows the path buffer (~96 KiB) is larger than the command line + // itself can be, so this condition is not reachable from argv there. + test.skipIf(isWindows)("errors cleanly when the -o path is too long", async () => { + const dir = await setup("sbom-outfile-long", { dependencies: { "no-deps": "1.0.0" } }); + const tooLong = Buffer.alloc(8192, "a").toString(); + + const { stdout, stderr, exitCode } = await sbom(dir, ["-o", tooLong]); + expect(stdout).toBe(""); + expect(stderr).toContain("output path is too long"); + expect(exitCode).toBe(1); + }); + + test("workspaces are included as components", async () => { + const { packageDir, packageJson, env } = await newProject(); + await write( + packageJson, + JSON.stringify({ + name: "sbom-ws-root", + version: "1.0.0", + workspaces: ["packages/*"], + }), + ); + await write( + join(packageDir, "packages", "pkg-a", "package.json"), + JSON.stringify({ + name: "pkg-a", + version: "2.0.0", + dependencies: { "no-deps": "1.0.0" }, + }), + ); + await runBunInstall(env, packageDir); + + const { stdout, stderr, exitCode } = await sbom(packageDir); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + const bom = JSON.parse(stdout); + + const pkgA = bom.components.find((c: any) => c.name === "pkg-a"); + expect(pkgA).toBeDefined(); + expect(pkgA.version).toBe("2.0.0"); + expect(pkgA["bom-ref"]).toContain("workspace:"); + expect(pkgA.purl).toBe("pkg:npm/pkg-a@2.0.0"); + + const depsByRef = Object.fromEntries(bom.dependencies.map((d: any) => [d.ref, d.dependsOn])); + expect(depsByRef[bom.metadata.component["bom-ref"]]).toContain(pkgA["bom-ref"]); + expect(depsByRef[pkgA["bom-ref"]]).toEqual(["no-deps@1.0.0"]); + }); + + test("produces the same SBOM when run from inside a workspace subdirectory", async () => { + const { packageDir, packageJson, env } = await newProject(); + await write(packageJson, JSON.stringify({ name: "sbom-ws-sub", version: "1.0.0", workspaces: ["packages/*"] })); + await write( + join(packageDir, "packages", "pkg-a", "package.json"), + JSON.stringify({ name: "pkg-a", version: "1.0.0", dependencies: { "no-deps": "1.0.0" } }), + ); + await write( + join(packageDir, "packages", "pkg-b", "package.json"), + JSON.stringify({ name: "pkg-b", version: "1.0.0", dependencies: { "a-dep": "1.0.1" } }), + ); + await runBunInstall(env, packageDir); + + const fromRoot = await sbom(packageDir); + const fromPkgA = await sbom(join(packageDir, "packages", "pkg-a")); + expect(fromRoot.stderr).toBe(""); + expect(fromPkgA.stderr).toBe(""); + expect(fromRoot.exitCode).toBe(0); + expect(fromPkgA.exitCode).toBe(0); + + const bomRoot = JSON.parse(fromRoot.stdout); + const bomA = JSON.parse(fromPkgA.stdout); + + // The SBOM describes the whole lockfile regardless of cwd; the root + // component is the monorepo root in both cases. + expect(bomA.metadata.component.name).toBe("sbom-ws-sub"); + expect(bomA.metadata.component["bom-ref"]).toBe(bomRoot.metadata.component["bom-ref"]); + + // Sibling workspace and its dep must be `required`, not `excluded`. + const byRef = Object.fromEntries(bomA.components.map((c: any) => [c.name, c])); + expect(byRef["pkg-a"].scope).toBe("required"); + expect(byRef["pkg-b"].scope).toBe("required"); + expect(byRef["no-deps"].scope).toBe("required"); + expect(byRef["a-dep"].scope).toBe("required"); + + // Same component set from either cwd. + expect(bomA.components.map((c: any) => c["bom-ref"]).sort()).toEqual( + bomRoot.components.map((c: any) => c["bom-ref"]).sort(), + ); + + // A relative `-o` path should resolve against the invocation + // directory, not the monorepo root that PackageManager.init() chdirs to. + const relOut = await sbom(join(packageDir, "packages", "pkg-a"), ["-o", "out.cdx.json"]); + expect(relOut.stderr).toContain("Saved"); + expect(relOut.exitCode).toBe(0); + expect(existsSync(join(packageDir, "packages", "pkg-a", "out.cdx.json"))).toBe(true); + expect(existsSync(join(packageDir, "out.cdx.json"))).toBe(false); + }); + + test("git and github dependencies get SPDX-style VCS locators", async () => { + const { packageDir, packageJson, env } = await newProject(); + const gitEnv = { + ...env, + GIT_CONFIG_NOSYSTEM: "1", + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + }; + async function run(cwd: string, ...cmd: string[]) { + await using proc = spawn({ cmd, cwd, env: gitEnv, stdout: "ignore", stderr: "pipe" }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + if (exitCode !== 0) throw new Error(`${cmd.join(" ")} failed:\n${stderr}`); + } + + // A git dependency: a local repo reachable over git+file://. + const repo = join(packageDir, "git-dep"); + mkdirSync(repo); + await write(join(repo, "package.json"), JSON.stringify({ name: "git-dep", version: "1.0.0" })); + await run(repo, "git", "init", "-q"); + await run(repo, "git", "add", "package.json"); + await run(repo, "git", "commit", "-q", "-m", "init", "--no-gpg-sign"); + + // A github dependency: GITHUB_API_URL points bun at a local server whose + // tarball has the `--` top-level directory GitHub uses, + // which becomes the resolved revision. + const top = "acme-gh-dep-0123456"; + mkdirSync(join(packageDir, "gh", top), { recursive: true }); + await write(join(packageDir, "gh", top, "package.json"), JSON.stringify({ name: "gh-dep", version: "2.0.0" })); + await run(packageDir, "tar", "-czf", join(packageDir, "gh-dep.tgz"), "-C", join(packageDir, "gh"), top); + await using github = Bun.serve({ + port: 0, + fetch(req) { + return new URL(req.url).pathname.startsWith("/repos/acme/gh-dep/tarball") + ? new Response(Bun.file(join(packageDir, "gh-dep.tgz"))) + : new Response("not found", { status: 404 }); + }, + }); + + await write( + packageJson, + JSON.stringify({ + name: "sbom-vcs", + version: "1.0.0", + dependencies: { "git-dep": `git+${pathToFileURL(repo).href}`, "gh-dep": "github:acme/gh-dep" }, + }), + ); + await runBunInstall({ ...gitEnv, GITHUB_API_URL: `http://localhost:${github.port}` }, packageDir); + + const cdx = await sbom(packageDir); + expect(cdx.stderr).toBe(""); + expect(cdx.exitCode).toBe(0); + const byName = Object.fromEntries(JSON.parse(cdx.stdout).components.map((c: any) => [c.name, c])); + expect(byName["git-dep"].externalReferences[0].url).toMatch(/^git\+file:.*git-dep@[0-9a-f]{40}$/); + expect(byName["gh-dep"].externalReferences[0].url).toBe("git+https://github.com/acme/gh-dep.git@0123456"); + + const spdx = await sbom(packageDir, ["--format", "spdx"]); + expect(spdx.stderr).toBe(""); + expect(spdx.exitCode).toBe(0); + const pkgs = Object.fromEntries(JSON.parse(spdx.stdout).packages.map((p: any) => [p.name, p])); + expect(pkgs["git-dep"].downloadLocation).toMatch(/^git\+file:.*git-dep@[0-9a-f]{40}$/); + expect(pkgs["gh-dep"].downloadLocation).toBe("git+https://github.com/acme/gh-dep.git@0123456"); + }); + + test("rejects unknown --format", async () => { + const dir = await setup("sbom-badfmt", { dependencies: { "no-deps": "1.0.0" } }); + + const { stderr, exitCode } = await sbom(dir, ["--format", "toml"]); + expect(stderr).toContain("invalid --format value"); + expect(stderr).toContain("cyclonedx"); + expect(stderr).toContain("spdx"); + expect(exitCode).toBe(1); + }); + + test("errors when lockfile is missing", async () => { + const { packageDir, packageJson } = await newProject(); + await write(packageJson, JSON.stringify({ name: "sbom-nolock", version: "1.0.0" })); + + const { stderr, exitCode } = await sbom(packageDir); + expect(stderr).toContain("missing lockfile"); + expect(exitCode).toBe(1); + }); + + test("--format=spdx syntax works", async () => { + const dir = await setup("sbom-eqfmt", { dependencies: { "no-deps": "1.0.0" } }); + + const { stdout, stderr, exitCode } = await sbom(dir, ["--format=spdx"]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(JSON.parse(stdout).spdxVersion).toBe("SPDX-2.3"); + }); + + test("prints in `bun pm` help", async () => { + // `bun pm --help` short-circuits in CommandLineArguments::parse before + // PackageManager::init() runs, so this is hermetic regardless of cwd. + // Bare `bun pm` (no --help) would go through init() and depend on the + // runner's cwd having a package.json in its ancestry. + await using proc = spawn({ + cmd: [bunExe(), "pm", "--help"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toContain("bun pm sbom"); + }); +});