From 7b5ea5f0c99442976a18a2ac0ee99f3a16ef8a0b Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Wed, 21 Oct 2026 13:20:53 +0300 Subject: [PATCH 1/8] feat(bun): lockfile-only analysis from bun.lock and fix reader defects Make Bun dependency analysis read bun.lock directly, with no `bun --version` or `bun install` invocation, and require a pre-existing text lockfile. Rewrite the tree-builder into the shared `{rootId, nodes}` graph contract (`readBunGraph`/`parseBunEntry`/`readBunLock` in javascript_lockfile.js) and fix the prior reader's correctness defects: - aliases report their npm-target identity instead of the alias name - nested resolutions are keyed by their `parent/child` install path, so conflicting versions stay distinct instead of collapsing onto the root - workspace members report their real manifest identity and dependencies - optional branches are preserved; missing required resolutions and unsupported lockfileVersions now fail closed instead of being dropped Reject a binary `bun.lockb` without a text `bun.lock` with an actionable migrate diagnostic. Supports lockfileVersion 0, 1 and 2, hosted/git 3-tuples, empty-registry entries, and SHA-1/SHA-512 integrity. Adds golden-SBOM and regression fixtures plus their dependabot suppression entries. Implements TC-7014 Assisted-by: Claude Code Signed-off-by: Adva Oren --- .github/dependabot.yml | 8 + src/providers/javascript_bun.js | 172 +++++-------- src/providers/javascript_lockfile.js | 167 +++++++++++++ test/providers/javascript.test.js | 226 +++++++++++++++++- .../tst_manifests/bun/alias/bun.lock | 20 ++ .../bun/alias/expected_component_sbom.json | 51 ++++ .../bun/alias/expected_stack_sbom.json | 51 ++++ .../tst_manifests/bun/alias/package.json | 7 + .../tst_manifests/bun/lockfile_v0/bun.lock | 20 ++ .../lockfile_v0/expected_component_sbom.json | 51 ++++ .../bun/lockfile_v0/expected_stack_sbom.json | 51 ++++ .../bun/lockfile_v0/package.json | 7 + .../tst_manifests/bun/lockfile_v1/bun.lock | 20 ++ .../lockfile_v1/expected_component_sbom.json | 51 ++++ .../bun/lockfile_v1/expected_stack_sbom.json | 51 ++++ .../bun/lockfile_v1/package.json | 7 + .../tst_manifests/bun/lockfile_v2/bun.lock | 20 ++ .../lockfile_v2/expected_component_sbom.json | 51 ++++ .../bun/lockfile_v2/expected_stack_sbom.json | 51 ++++ .../bun/lockfile_v2/package.json | 7 + .../tst_manifests/bun/nested/bun.lock | 47 ++++ .../bun/nested/expected_component_sbom.json | 69 ++++++ .../bun/nested/expected_stack_sbom.json | 107 +++++++++ .../tst_manifests/bun/nested/package.json | 8 + .../tst_manifests/bun/optional/bun.lock | 31 +++ .../bun/optional/expected_component_sbom.json | 51 ++++ .../bun/optional/expected_stack_sbom.json | 70 ++++++ .../tst_manifests/bun/optional/package.json | 7 + .../tst_manifests/bun/workspace/bun.lock | 27 +++ .../workspace/expected_component_sbom.json | 51 ++++ .../bun/workspace/expected_stack_sbom.json | 51 ++++ .../tst_manifests/bun/workspace/package.json | 7 + .../workspace/packages/member/package.json | 7 + 33 files changed, 1505 insertions(+), 117 deletions(-) create mode 100644 test/providers/tst_manifests/bun/alias/bun.lock create mode 100644 test/providers/tst_manifests/bun/alias/expected_component_sbom.json create mode 100644 test/providers/tst_manifests/bun/alias/expected_stack_sbom.json create mode 100644 test/providers/tst_manifests/bun/alias/package.json create mode 100644 test/providers/tst_manifests/bun/lockfile_v0/bun.lock create mode 100644 test/providers/tst_manifests/bun/lockfile_v0/expected_component_sbom.json create mode 100644 test/providers/tst_manifests/bun/lockfile_v0/expected_stack_sbom.json create mode 100644 test/providers/tst_manifests/bun/lockfile_v0/package.json create mode 100644 test/providers/tst_manifests/bun/lockfile_v1/bun.lock create mode 100644 test/providers/tst_manifests/bun/lockfile_v1/expected_component_sbom.json create mode 100644 test/providers/tst_manifests/bun/lockfile_v1/expected_stack_sbom.json create mode 100644 test/providers/tst_manifests/bun/lockfile_v1/package.json create mode 100644 test/providers/tst_manifests/bun/lockfile_v2/bun.lock create mode 100644 test/providers/tst_manifests/bun/lockfile_v2/expected_component_sbom.json create mode 100644 test/providers/tst_manifests/bun/lockfile_v2/expected_stack_sbom.json create mode 100644 test/providers/tst_manifests/bun/lockfile_v2/package.json create mode 100644 test/providers/tst_manifests/bun/nested/bun.lock create mode 100644 test/providers/tst_manifests/bun/nested/expected_component_sbom.json create mode 100644 test/providers/tst_manifests/bun/nested/expected_stack_sbom.json create mode 100644 test/providers/tst_manifests/bun/nested/package.json create mode 100644 test/providers/tst_manifests/bun/optional/bun.lock create mode 100644 test/providers/tst_manifests/bun/optional/expected_component_sbom.json create mode 100644 test/providers/tst_manifests/bun/optional/expected_stack_sbom.json create mode 100644 test/providers/tst_manifests/bun/optional/package.json create mode 100644 test/providers/tst_manifests/bun/workspace/bun.lock create mode 100644 test/providers/tst_manifests/bun/workspace/expected_component_sbom.json create mode 100644 test/providers/tst_manifests/bun/workspace/expected_stack_sbom.json create mode 100644 test/providers/tst_manifests/bun/workspace/package.json create mode 100644 test/providers/tst_manifests/bun/workspace/packages/member/package.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1978c40c..bfbd95e3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -182,9 +182,17 @@ updates: - "/test/providers/provider_manifests/yarn-berry/without_lock_file" - "/test/providers/provider_manifests/yarn-classic/with_lock_file" - "/test/providers/provider_manifests/yarn-classic/without_lock_file" + - "/test/providers/tst_manifests/bun/alias" + - "/test/providers/tst_manifests/bun/lockfile_v0" + - "/test/providers/tst_manifests/bun/lockfile_v1" + - "/test/providers/tst_manifests/bun/lockfile_v2" + - "/test/providers/tst_manifests/bun/nested" + - "/test/providers/tst_manifests/bun/optional" - "/test/providers/tst_manifests/bun/package_json_deps_with_exhortignore_object" - "/test/providers/tst_manifests/bun/package_json_deps_with_mixed_dep_types" - "/test/providers/tst_manifests/bun/package_json_deps_without_exhortignore_object" + - "/test/providers/tst_manifests/bun/workspace" + - "/test/providers/tst_manifests/bun/workspace/packages/member" - "/test/providers/tst_manifests/bun/workspace_member" - "/test/providers/tst_manifests/bun/workspace_member/packages/member-a" - "/test/providers/tst_manifests/npm/package_json_deps_with_exhortignore_object" diff --git a/src/providers/javascript_bun.js b/src/providers/javascript_bun.js index 3816cf69..68793c79 100644 --- a/src/providers/javascript_bun.js +++ b/src/providers/javascript_bun.js @@ -1,9 +1,8 @@ import fs from 'node:fs' import path from 'node:path' -import { parse } from 'jsonc-parser'; - import Base_javascript, { sriToHash } from './base_javascript.js'; +import { parseBunEntry, readBunGraph, readBunLock } from './javascript_lockfile.js'; export default class Javascript_bun extends Base_javascript { @@ -11,10 +10,20 @@ export default class Javascript_bun extends Base_javascript { return "bun.lock"; } + _cmdName() { + return "bun"; + } + + /** Initializes the provider without resolving the bun executable. */ + _setUp(manifestPath) { + this._setUpManifest(manifestPath); + } + /** - * Parses `bun.lock` into a hash map keyed by `name@version`. Each entry in - * the `packages` section is an array whose first element is the resolved id - * (`name@version`) and whose fourth element is the SRI integrity string. + * Parses `bun.lock` into a hash map keyed by `name@version`. Each entry in the + * `packages` section is a tuple whose resolved id (`name@version`) and trailing + * integrity string are extracted by {@link parseBunEntry}, so npm, hosted/git + * and empty-registry entries are all handled. * @param {string} lockDir - Directory containing bun.lock * @returns {Map>} Hash map * @protected @@ -27,23 +36,17 @@ export default class Javascript_bun extends Base_javascript { } let lockData; try { - lockData = parse(fs.readFileSync(lockPath, 'utf-8')); + lockData = readBunLock(lockPath); } catch (_) { return map; } const packages = lockData?.packages || {}; for (const entry of Object.values(packages)) { - if (!Array.isArray(entry) || typeof entry[0] !== 'string' || typeof entry[3] !== 'string') { - continue; - } - const id = entry[0]; - const at = id.lastIndexOf('@'); - if (at <= 0) { + if (!Array.isArray(entry) || typeof entry[0] !== 'string') { continue; } - const name = id.slice(0, at); - const version = id.slice(at + 1); - const hash = sriToHash(entry[3]); + const { name, version, integrity } = parseBunEntry(entry); + const hash = integrity ? sriToHash(integrity) : null; if (name && version && hash) { map.set(`${name}@${version}`, [hash]); } @@ -51,113 +54,52 @@ export default class Javascript_bun extends Base_javascript { return map; } - _cmdName() { - return "bun"; - } - - _listCmdArgs() { - throw new Error("not supported by Bun"); - } - - _updateLockFileCmdArgs() { - return ['install', '--lockfile-only']; - } - - _buildDependencyTree(includeTransitive, opts = {}) { - this._version(); - const manifestDir = path.dirname(this._getManifest().manifestPath); - const lockDir = this._findLockFileDir(manifestDir, opts) || manifestDir; - this._createLockFile(lockDir); - this._loadHashes(lockDir); - - const lockContent = fs.readFileSync(path.join(lockDir, 'bun.lock'), 'utf-8'); - const lockData = parse(lockContent); - - const packages = lockData.packages || {}; - const memberName = this._getManifest().name; - const workspaceEntry = this.#findWorkspaceEntry(lockData, lockDir, manifestDir, memberName); - - const directDeps = workspaceEntry?.dependencies || {}; - const tree = { name: memberName, version: this._getManifest().version, dependencies: {} }; - - const visited = new Set(); - for (const depName of Object.keys(directDeps)) { - const resolved = this.#resolvePackage(depName, '', packages); - if (resolved) { - tree.dependencies[depName] = this.#buildNode(depName, resolved, packages, includeTransitive, visited); - } - } - - return tree; - } - - #findWorkspaceEntry(lockData, lockDir, manifestDir, memberName) { - const workspaces = lockData.workspaces || {}; - const relPath = path.relative(lockDir, path.resolve(manifestDir)); - if (!relPath || relPath === '.') { - return workspaces[''] || {}; - } - const normalised = relPath.split(path.sep).join('/'); - if (workspaces[normalised]) { - return workspaces[normalised]; - } - for (const [wsPath, entry] of Object.entries(workspaces)) { - if (entry.name === memberName && wsPath !== '') { - return entry; + /** + * Builds the dependency graph directly from `bun.lock`, requiring a + * pre-existing text lockfile and invoking no package manager. A binary + * `bun.lockb` without a text `bun.lock` is rejected with a migrate diagnostic. + * @param {boolean} _includeTransitive - Unused; the full graph is always read + * @param {Object} [opts={}] - Configuration options (may set the workspace dir) + * @returns {import('./javascript_lockfile.js').LockGraph} The dependency graph + * @protected + */ + _buildDependencyTree(_includeTransitive, opts = {}) { + const manifest = this._getManifest(); + const manifestDir = path.dirname(manifest.manifestPath); + const lockDir = this._findLockFileDir(manifestDir, opts); + if (!lockDir) { + if (this.#binaryLockFileDir(manifestDir)) { + throw new Error("Bun analysis found a binary 'bun.lockb' but no text 'bun.lock'. Migrate it with 'bun install --save-text-lockfile' (Bun >= 1.2) before scanning."); } + throw new Error('Bun analysis requires a lockfile. Generate bun.lock before scanning.'); } - return workspaces[''] || {}; + const lock = readBunLock(path.join(lockDir, this._lockFileName())); + const graph = readBunGraph(lockDir, manifest.manifestPath, manifest.content, lock); + this._loadHashes(lockDir); + return graph; } - #resolvePackage(depName, parentKey, packages) { - if (parentKey) { - const scopedKey = `${parentKey}/${depName}`; - if (packages[scopedKey]) { - return packages[scopedKey]; + /** + * Walks up from the manifest directory looking for a binary `bun.lockb`, + * stopping at the workspace root, to drive an actionable migrate diagnostic. + * @param {string} manifestDir - Directory containing the manifest file + * @returns {string|null} The directory containing bun.lockb, or null + * @private + */ + #binaryLockFileDir(manifestDir) { + let dir = path.resolve(manifestDir); + while (true) { + if (fs.existsSync(path.join(dir, 'bun.lockb'))) { + return dir; } - } - return packages[depName] || null; - } - - #buildNode(depName, resolved, packages, includeTransitive, visited) { - const resolvedId = Array.isArray(resolved) ? resolved[0] : resolved; - const version = this.#extractVersion(resolvedId); - const node = { version }; - - if (!includeTransitive) { - return node; - } - - const metadata = Array.isArray(resolved) ? (resolved[2] || {}) : {}; - const subDeps = metadata.dependencies || {}; - - if (Object.keys(subDeps).length > 0) { - const visitKey = `${depName}@${version}`; - if (visited.has(visitKey)) { - return node; + if (this._isWorkspaceRoot(dir)) { + return null; } - visited.add(visitKey); - - node.dependencies = {}; - for (const subName of Object.keys(subDeps)) { - const subResolved = this.#resolvePackage(subName, depName, packages); - if (subResolved) { - node.dependencies[subName] = this.#buildNode(subName, subResolved, packages, true, visited); - } + const parent = path.dirname(dir); + if (parent === dir) { + return null; } + dir = parent; } - - return node; - } - - #extractVersion(resolvedId) { - if (typeof resolvedId !== 'string') { - return '0.0.0'; - } - const atIdx = resolvedId.lastIndexOf('@'); - if (atIdx > 0) { - return resolvedId.substring(atIdx + 1); - } - return '0.0.0'; } } diff --git a/src/providers/javascript_lockfile.js b/src/providers/javascript_lockfile.js index 99f6b4be..38e02dff 100644 --- a/src/providers/javascript_lockfile.js +++ b/src/providers/javascript_lockfile.js @@ -5,6 +5,7 @@ import { isDeepStrictEqual } from 'node:util'; import { parseResolution, parseSyml } from '@yarnpkg/parsers'; import glob from 'fast-glob'; import { load as yamlLoad, loadAll as yamlLoadAll } from 'js-yaml'; +import { parse as parseJsonc } from 'jsonc-parser'; import micromatch from 'micromatch'; import semver from 'semver'; @@ -752,3 +753,169 @@ export function readPnpmGraph(root, manifestPath, manifest, lock) { visit(graph.rootId); return graph; } + +/** + * Reads a `bun.lock` document. Bun's text lockfile is JSONC (comments and + * trailing commas allowed), so it is parsed leniently; genuine syntax errors + * still surface as a read failure. Returns the parsed lockfile mapping. + * @param {string} file - Absolute path to bun.lock + * @returns {Object} The parsed lockfile mapping + */ +export function readBunLock(file) { + let lock; + try { + const errors = []; + lock = parseJsonc(fs.readFileSync(file, 'utf8'), errors, { allowTrailingComma: true }); + if (errors.length > 0) { throw new Error('invalid JSONC syntax'); } + if (!object(lock)) { throw new Error('expected a lockfile mapping'); } + } catch (cause) { + throw new Error(`Cannot read ${file}: ${cause.message}`, { cause }); + } + return lock; +} + +/** + * Parses a single `bun.lock` `packages` tuple. Bun uses several shapes: + * npm `["name@version", registry, info, integrity]`, hosted/git + * `["name@url", info, resolvedName, integrity]` (the info object shifts to + * index 1), and a workspace pointer `["name@workspace:path"]` with no info. + * The info object is located positionally and the integrity is the trailing + * string, so all shapes parse through one code path. + * @param {Array} tuple - A `bun.lock` packages entry + * @returns {{id: string, name: string, range: string, version: string, info: Object, integrity: (string|undefined), workspace: boolean}} + */ +export function parseBunEntry(tuple) { + const id = tuple[0]; + const { name, range } = descriptor(id); + const info = tuple.slice(1).find(object) ?? {}; + const last = tuple.length > 1 ? tuple[tuple.length - 1] : undefined; + const integrity = typeof last === 'string' ? last : undefined; + const workspace = range.startsWith('workspace:'); + // For registry packages the range is the resolved version; for hosted/git + // packages it is the URL/ref, which is preserved as the reported version. + return { id, name, range, version: workspace ? undefined : range, info, integrity, workspace }; +} + +/** + * Reads Bun's resolved graph from a text `bun.lock` without invoking Bun. + * Supports lockfileVersion 0, 1 and 2. Package identities come from each + * tuple's resolved `name@version` (so npm aliases report their real target), + * nested resolutions are keyed by their `parent/child` install path (so + * conflicting versions stay distinct), workspace members resolve to their + * on-disk manifest identity, and optional branches are preserved while missing + * required resolutions are rejected. + * @param {string} root - Workspace root (directory holding bun.lock) + * @param {string} manifestPath - Path to the analyzed package.json + * @param {Object} manifest - Parsed package.json content + * @param {Object} lock - Parsed lockfile (see {@link readBunLock}) + * @returns {LockGraph} The resolved dependency graph + */ +export function readBunGraph(root, manifestPath, manifest, lock) { + const file = path.join(root, 'bun.lock'); + if (![0, 1, 2].includes(Number(lock.lockfileVersion))) { + throw new Error(`Unsupported bun lockfile version ${lock.lockfileVersion} in ${file}. Regenerate a version 0, 1, or 2 bun.lock before scanning.`); + } + if (!object(lock.workspaces)) { unsupported(file, 'workspaces', 'missing workspaces table'); } + const workspaces = lock.workspaces; + const packages = object(lock.packages) ? lock.packages : {}; + + const manifests = projectManifests(root, manifestPath, manifest); + const locals = new Map(); + for (const [manifestFile, data] of manifests) { + const location = path.relative(root, path.dirname(manifestFile)).split(path.sep).join('/') || '.'; + const entry = workspaces[location === '.' ? '' : location]; + if (!object(entry)) { stale(manifestFile, data.name, 'is missing from the lockfile'); } + if (entry.name != null && data.name !== entry.name) { stale(manifestFile, data.name, 'has a different locked identity'); } + validateSnapshot(manifestFile, data, entry, ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']); + locals.set(location, { data, file: manifestFile }); + } + const rootLocation = path.relative(root, path.dirname(path.resolve(manifestPath))).split(path.sep).join('/') || '.'; + if (!locals.has(rootLocation)) { unsupported(file, manifest.name, 'manifest is not in the workspace'); } + + // Resolves a dependency to its `packages` key using Bun's node_modules + // hoisting: a `parent/child` nested entry wins over a hoisted top-level one, + // walking up the owner's install path before falling back to the root. + function resolveKey(ownerPath, name) { + let prefix = ownerPath; + while (true) { + const candidate = prefix ? `${prefix}/${name}` : name; + if (packages[candidate] !== undefined) { return candidate; } + if (!prefix) { return undefined; } + const slash = prefix.lastIndexOf('/'); + prefix = slash === -1 ? '' : prefix.slice(0, slash); + } + } + + const graph = { rootId: `ws:${rootLocation}`, nodes: new Map() }; + + // Resolves one dependency edge to a node id. Returns undefined for an absent + // optional edge; throws for an absent required edge. Workspace pointers + // redirect to the member's importer node. + function childId(ownerPath, name, spec, source, optional) { + const key = resolveKey(ownerPath, name); + if (key === undefined) { + if (optional) { return undefined; } + stale(source, name, 'has no locked resolution'); + } + const entry = packages[key]; + if (!Array.isArray(entry) || typeof entry[0] !== 'string') { unsupported(file, key, 'malformed package entry'); } + const parsed = parseBunEntry(entry); + if (parsed.workspace) { + const location = parsed.range.slice('workspace:'.length) || '.'; + if (!locals.has(location)) { unsupported(file, key, 'workspace package missing from the lockfile'); } + return `ws:${location}`; + } + if (spec != null) { validateRange(source, name, spec, { name: parsed.name, version: parsed.version }); } + return key; + } + + // An explicit stack avoids call-stack overflow on deep dependency chains. + function visit(startId) { + const stack = [startId]; + while (stack.length > 0) { + const id = stack.pop(); + if (graph.nodes.has(id)) { continue; } + const children = []; + if (id.startsWith('ws:')) { + const local = locals.get(id.slice(3)); + const node = { name: local.data.name ?? path.basename(path.dirname(local.file)), version: local.data.version ?? 'v0.0.0', dependencies: new Map(), hashes: undefined }; + graph.nodes.set(id, node); + const optionalDeps = local.data.optionalDependencies ?? {}; + for (const [name, spec] of Object.entries(manifestDependencies(local.data, local.file))) { + const cid = childId('', name, spec, local.file, Object.hasOwn(optionalDeps, name)); + if (cid == null) { continue; } + node.dependencies.set(name, cid); + children.push(cid); + } + } else { + const parsed = parseBunEntry(packages[id]); + if (typeof parsed.name !== 'string' || !parsed.name || typeof parsed.version !== 'string' || !parsed.version) { unsupported(file, id, 'missing package name or version'); } + const node = { name: parsed.name, version: parsed.version, dependencies: new Map(), hashes: hashes({ integrity: parsed.integrity }) }; + graph.nodes.set(id, node); + const required = object(parsed.info.dependencies) ? parsed.info.dependencies : {}; + const optionalDeps = object(parsed.info.optionalDependencies) ? parsed.info.optionalDependencies : {}; + // ponytail: transitive peerDependencies are omitted (as the prior + // reader did); direct peers are supplemented by the SBOM builder. Add + // peer-edge resolution here if a fixture ever needs transitive peers. + for (const [name, spec] of Object.entries(required)) { + const cid = childId(id, name, spec, file, false); + if (cid == null) { continue; } + node.dependencies.set(name, cid); + children.push(cid); + } + for (const [name, spec] of Object.entries(optionalDeps)) { + if (node.dependencies.has(name)) { continue; } + const cid = childId(id, name, spec, file, true); + if (cid == null) { continue; } + node.dependencies.set(name, cid); + children.push(cid); + } + } + // Push in reverse to visit dependencies in their original declaration order. + for (let i = children.length - 1; i >= 0; i--) { stack.push(children[i]); } + } + } + + visit(graph.rootId); + return graph; +} diff --git a/test/providers/javascript.test.js b/test/providers/javascript.test.js index 03d5bc4c..3f0d49f7 100644 --- a/test/providers/javascript.test.js +++ b/test/providers/javascript.test.js @@ -7,13 +7,36 @@ import { expect } from 'chai' import esmock from 'esmock'; import { useFakeTimers } from "sinon"; +import { getPackageVersion } from '../../src/package_version.js'; import { availableProviders, match } from '../../src/provider.js'; import { sriToHash } from '../../src/providers/base_javascript.js'; +import Bun from '../../src/providers/javascript_bun.js'; import Manifest from '../../src/providers/manifest.js'; import { compareSboms } from '../utils/sbom_utils.js'; let clock +/** A valid SHA-512 SRI (is-number@7.0.0) reused across bun lockfile fixtures. */ +const BUN_SRI = 'sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=='; +const BUN_SRI_HEX = Buffer.from(BUN_SRI.slice('sha512-'.length), 'base64').toString('hex'); + +/** Sorts components and dependency edges so two SBOMs compare order-independently. */ +function canonical(sbom) { + const data = typeof sbom === 'string' ? JSON.parse(sbom) : structuredClone(sbom); + data.components.sort((a, b) => a.purl.localeCompare(b.purl)); + data.dependencies?.forEach(dep => dep.dependsOn.sort()); + data.dependencies?.sort((a, b) => a.ref.localeCompare(b.ref)); + return data; +} + +/** Captures a directory's file contents so a run can be shown to leave it untouched. */ +function snapshot(dir) { + return fs.readdirSync(dir, { recursive: true }).sort().map(file => { + const full = path.join(dir, file); + return [file, fs.statSync(full).isDirectory() ? null : fs.readFileSync(full).toString('base64')]; + }); +} + async function mockProvider(providerName, listingOutput, version) { if (providerName === 'npm' || providerName === 'yarn') { return (await import(`../../src/providers/javascript_${providerName}.js`)).default; @@ -52,8 +75,8 @@ async function createMockProvider(providerName, listingOutput) { return new Javascript_yarn(); } case 'bun': { - const Javascript_bun = await mockProvider(providerName, listingOutput); - return new Javascript_bun(); + // Bun analysis is lockfile-only, so no subprocess mock is needed. + return new Bun(); } default: { fail('Not implemented'); } } @@ -403,3 +426,202 @@ suite('lock-file hash extraction (TC-5548)', () => { }); }); }); + +suite('bun lockfile-only analysis (TC-7014)', () => { + let root; + setup(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'bun-lockfile-')); }); + teardown(() => { fs.rmSync(root, { recursive: true, force: true }); }); + + /** Writes a package.json, a bun.lock and any extra manifests, returning the manifest path. */ + function project(manifest, lock, { manifestPath = 'package.json', extra = {} } = {}) { + for (const [rel, content] of Object.entries(extra)) { + const p = path.join(root, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(content)); + } + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify(manifest)); + fs.writeFileSync(path.join(root, 'bun.lock'), typeof lock === 'string' ? lock : JSON.stringify(lock)); + return path.join(root, manifestPath); + } + const stack = mp => JSON.parse(new Bun().provideStack(mp).content); + const component = mp => JSON.parse(new Bun().provideComponent(mp).content); + const edge = (sbom, from, to) => sbom.dependencies.find(dep => dep.ref === `pkg:npm/${from}`)?.dependsOn.includes(`pkg:npm/${to}`); + + for (const method of ['provideStack', 'provideComponent']) { + /// Verifies bun analysis reads the graph without any subprocess or file writes (mirrors TC-6762). + test(`bun ${method} makes no subprocess calls and changes no project files`, async () => { + // Given a project with a bun.lock and a stale node_modules copy + const lock = { lockfileVersion: 2, configVersion: 1, + workspaces: { '': { name: 'app', dependencies: { alpha: '1.0.0' } } }, + packages: { alpha: ['alpha@1.0.0', '', { dependencies: { shared: '^1.0.0' } }, BUN_SRI], shared: ['shared@1.0.0', '', {}, BUN_SRI] } }; + const mp = project({ name: 'app', version: '1.0.0', dependencies: { alpha: '1.0.0' } }, lock); + fs.mkdirSync(path.join(root, 'node_modules', 'alpha'), { recursive: true }); + fs.writeFileSync(path.join(root, 'node_modules', 'alpha', 'package.json'), '{"version":"99.0.0"}'); + const before = snapshot(root); + + // When analyzing with every subprocess path wired to throw + const fail = () => { throw new Error('Unexpected subprocess invocation'); }; + const Provider = await esmock('../../src/providers/javascript_bun.js', {}, { + '../../src/tools.js': { invokeCommand: fail }, + child_process: { execFileSync: fail, spawnSync: fail }, + }); + const provided = new Provider()[method](mp); + + // Then the SBOM comes from the lockfile and the project tree is untouched + expect(JSON.parse(provided.content).components.some(c => c.purl === 'pkg:npm/alpha@1.0.0')).to.be.true; + expect(provided.ecosystem).to.equal('npm'); + expect(snapshot(root)).to.deep.equal(before); + }); + } + + /// Regression: aliases must report the npm-target identity, not the alias name. + test('reports the npm-target identity and hash for aliases, not the alias name', () => { + const mp = project({ name: 'app', version: '1.0.0', dependencies: { myalias: 'npm:is-number@7.0.0' } }, + { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { myalias: 'npm:is-number@7.0.0' } } }, + packages: { myalias: ['is-number@7.0.0', '', {}, BUN_SRI] } }); + for (const read of [stack, component]) { + const sbom = read(mp); + expect(sbom.components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@7.0.0']); + expect(sbom.components[0].hashes).to.deep.equal([{ alg: 'SHA-512', content: BUN_SRI_HEX }]); + } + }); + + /// Regression: a nested resolution must keep its own version instead of collapsing onto the root. + test('resolves nested versions distinctly instead of collapsing onto the top-level entry', () => { + // Given two parents that need conflicting versions of a shared dependency + const mp = project({ name: 'app', version: '1.0.0', dependencies: { alpha: '1.0.0', beta: '1.0.0' } }, + { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { alpha: '1.0.0', beta: '1.0.0' } } }, + packages: { + alpha: ['alpha@1.0.0', '', { dependencies: { shared: '^1.0.0' } }, BUN_SRI], + beta: ['beta@1.0.0', '', { dependencies: { shared: '^2.0.0' } }, BUN_SRI], + shared: ['shared@1.0.0', '', {}, BUN_SRI], + 'beta/shared': ['shared@2.0.0', '', {}, BUN_SRI], + } }); + + // When building the SBOM + const sbom = stack(mp); + + // Then each parent keeps its own resolved version of the shared dependency + expect(edge(sbom, 'alpha@1.0.0', 'shared@1.0.0')).to.be.true; + expect(edge(sbom, 'beta@1.0.0', 'shared@2.0.0')).to.be.true; + expect(sbom.components.filter(c => c.name === 'shared').map(c => c.version).sort()).to.deep.equal(['1.0.0', '2.0.0']); + }); + + /// Regression: a workspace member reports its real version and dependencies, not a `workspace:` pseudo-version. + test('reports a workspace member\'s real identity and dependencies', () => { + const mp = project({ name: 'ws-root', private: true, workspaces: ['packages/member'] }, + { lockfileVersion: 2, configVersion: 1, + workspaces: { '': { name: 'ws-root' }, 'packages/member': { name: 'member', version: '2.0.0', dependencies: { alpha: '1.0.0' } } }, + packages: { alpha: ['alpha@1.0.0', '', {}, BUN_SRI], member: ['member@workspace:packages/member'] } }, + { manifestPath: 'packages/member/package.json', extra: { 'packages/member/package.json': { name: 'member', version: '2.0.0', dependencies: { alpha: '1.0.0' } } } }); + const sbom = stack(mp); + expect(sbom.metadata.component.purl).to.equal('pkg:npm/member@2.0.0'); + expect(edge(sbom, 'member@2.0.0', 'alpha@1.0.0')).to.be.true; + }); + + /// Preserves resolvable optional branches while skipping absent optional dependencies. + test('preserves resolvable optional branches and skips absent ones', () => { + const mp = project({ name: 'app', version: '1.0.0', dependencies: { host: '1.0.0' } }, + { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { host: '1.0.0' } } }, + packages: { + host: ['host@1.0.0', '', { optionalDependencies: { 'present-opt': '^1.0.0', 'absent-opt': '^1.0.0' } }, BUN_SRI], + 'present-opt': ['present-opt@1.0.0', '', {}, BUN_SRI], + } }); + const sbom = stack(mp); + expect(edge(sbom, 'host@1.0.0', 'present-opt@1.0.0')).to.be.true; + expect(sbom.components.some(c => c.name === 'absent-opt')).to.be.false; + }); + + /// Regression: a missing required resolution is an error, not a silent omission. + test('rejects a missing required resolution instead of silently dropping it', () => { + const mp = project({ name: 'app', version: '1.0.0', dependencies: { alpha: '1.0.0' } }, + { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { alpha: '1.0.0' } } }, + packages: { alpha: ['alpha@1.0.0', '', { dependencies: { missing: '^1.0.0' } }, BUN_SRI] } }); + expect(() => stack(mp)).to.throw(/out of date.*missing/); + }); + + /// Rejects a changed manifest declaration now that bun no longer re-syncs the lockfile. + test('rejects a changed manifest declaration without writing files', () => { + const lock = { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { alpha: '1.0.0' } } }, + packages: { alpha: ['alpha@1.0.0', '', {}, BUN_SRI] } }; + const mp = project({ name: 'app', version: '1.0.0', dependencies: { alpha: '1.0.0' } }, lock); + fs.writeFileSync(mp, JSON.stringify({ name: 'app', version: '1.0.0', dependencies: { alpha: '^2.0.0' } })); + const before = snapshot(root); + expect(() => stack(mp)).to.throw(/out of date.*alpha/); + expect(snapshot(root)).to.deep.equal(before); + }); + + for (const version of [0, 1, 2]) { + /// Verifies every real lockfileVersion (0, 1, 2) is supported. + test(`supports lockfileVersion ${version}`, () => { + const mp = project({ name: 'app', version: '1.0.0', dependencies: { leaf: '1.0.0' } }, + { lockfileVersion: version, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { leaf: '1.0.0' } } }, + packages: { leaf: ['leaf@1.0.0', '', {}, BUN_SRI] } }); + expect(stack(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/leaf@1.0.0']); + }); + } + + /// Verifies an unsupported lockfileVersion fails closed with an actionable error. + test('rejects an unsupported lockfile version', () => { + const mp = project({ name: 'app', version: '1.0.0', dependencies: {} }, + { lockfileVersion: 3, configVersion: 1, workspaces: { '': { name: 'app' } }, packages: {} }); + expect(() => stack(mp)).to.throw(/Unsupported bun lockfile version 3/); + }); + + /// Verifies hosted 3-tuple entries, empty-registry entries and SHA-1 integrity are all handled. + test('handles hosted 3-tuple entries, empty-registry entries and SHA-1 integrity', () => { + const sha1 = `sha1-${Buffer.from('ab'.repeat(20), 'hex').toString('base64')}`; + const mp = project({ name: 'app', version: '1.0.0', dependencies: { hosted: 'github:o/r#abc', reg: '1.0.0' } }, + { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { hosted: 'github:o/r#abc', reg: '1.0.0' } } }, + packages: { + hosted: ['hosted@github:o/r#abc', {}, sha1], + reg: ['reg@1.0.0', '', {}, BUN_SRI], + } }); + const sbom = stack(mp); + expect(sbom.components.some(c => c.name === 'hosted')).to.be.true; + expect(sbom.components.find(c => c.name === 'hosted').hashes).to.deep.equal([{ alg: 'SHA-1', content: 'ab'.repeat(20) }]); + expect(sbom.components.find(c => c.name === 'reg').hashes).to.deep.equal([{ alg: 'SHA-512', content: BUN_SRI_HEX }]); + }); + + /// Verifies a malformed bun.lock is rejected rather than silently yielding an empty graph. + test('rejects a malformed bun.lock', () => { + const mp = project({ name: 'app', version: '1.0.0', dependencies: {} }, '{ invalid'); + expect(() => stack(mp)).to.throw(/Cannot read/); + }); + + /// Verifies analysis requires a text lockfile when none is present. + test('requires a bun.lock when none is present', () => { + const mp = path.join(root, 'package.json'); + fs.writeFileSync(mp, JSON.stringify({ name: 'app', version: '1.0.0', dependencies: {} })); + expect(() => stack(mp)).to.throw(/requires a lockfile/); + }); + + /// Verifies a binary bun.lockb (without a text bun.lock) yields an actionable migrate diagnostic. + test('rejects a binary bun.lockb with a migrate-to-bun.lock diagnostic', () => { + const mp = path.join(root, 'package.json'); + fs.writeFileSync(mp, JSON.stringify({ name: 'app', version: '1.0.0', dependencies: {} })); + fs.writeFileSync(path.join(root, 'bun.lockb'), Buffer.from([0, 1, 2, 3])); + expect(() => stack(mp)).to.throw(/bun\.lockb[\s\S]*bun\.lock/); + }); +}); + +suite('bun complete SBOM reference comparisons (TC-7014)', () => { + let refClock; + suiteSetup(() => { refClock = useFakeTimers(new Date('2023-08-07T00:00:00.000Z')); }); + suiteTeardown(() => { refClock.restore(); }); + const base = 'test/providers/tst_manifests/bun'; + const fixtures = ['alias', 'nested', 'optional', 'workspace', 'lockfile_v0', 'lockfile_v1', 'lockfile_v2']; + for (const fixture of fixtures) { + for (const { method, type } of [{ method: 'provideStack', type: 'stack' }, { method: 'provideComponent', type: 'component' }]) { + test(`bun ${fixture} ${type} matches every SBOM field`, () => { + const dir = `${base}/${fixture}`; + const manifest = `${dir}/${fixture === 'workspace' ? 'packages/member/package.json' : 'package.json'}`; + const actual = new Bun()[method](manifest); + const expected = JSON.parse(fs.readFileSync(`${dir}/expected_${type}_sbom.json`, 'utf8')); + expected.metadata.tools.components[0].version = getPackageVersion(); + expect(actual.contentType).to.equal('application/vnd.cyclonedx+json'); + expect(canonical(actual.content)).to.deep.equal(canonical(expected)); + }); + } + } +}); diff --git a/test/providers/tst_manifests/bun/alias/bun.lock b/test/providers/tst_manifests/bun/alias/bun.lock new file mode 100644 index 00000000..9463c0c3 --- /dev/null +++ b/test/providers/tst_manifests/bun/alias/bun.lock @@ -0,0 +1,20 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "alias-app", + "dependencies": { + "myalias": "npm:is-number@7.0.0" + } + } + }, + "packages": { + "myalias": [ + "is-number@7.0.0", + "", + {}, + "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + ] + } +} diff --git a/test/providers/tst_manifests/bun/alias/expected_component_sbom.json b/test/providers/tst_manifests/bun/alias/expected_component_sbom.json new file mode 100644 index 00000000..5854f1cc --- /dev/null +++ b/test/providers/tst_manifests/bun/alias/expected_component_sbom.json @@ -0,0 +1,51 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "timestamp": "2023-08-07T00:00:00.000Z", + "component": { + "name": "alias-app", + "version": "1.0.0", + "purl": "pkg:npm/alias-app@1.0.0", + "type": "application", + "bom-ref": "pkg:npm/alias-app@1.0.0" + }, + "tools": { + "components": [ + { + "type": "application", + "name": "trustify-da-javascript-client", + "version": "0.4.0" + } + ] + } + }, + "components": [ + { + "name": "is-number", + "version": "7.0.0", + "purl": "pkg:npm/is-number@7.0.0", + "type": "library", + "bom-ref": "pkg:npm/is-number@7.0.0", + "hashes": [ + { + "alg": "SHA-512", + "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/alias-app@1.0.0", + "dependsOn": [ + "pkg:npm/is-number@7.0.0" + ] + }, + { + "ref": "pkg:npm/is-number@7.0.0", + "dependsOn": [] + } + ] +} diff --git a/test/providers/tst_manifests/bun/alias/expected_stack_sbom.json b/test/providers/tst_manifests/bun/alias/expected_stack_sbom.json new file mode 100644 index 00000000..5854f1cc --- /dev/null +++ b/test/providers/tst_manifests/bun/alias/expected_stack_sbom.json @@ -0,0 +1,51 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "timestamp": "2023-08-07T00:00:00.000Z", + "component": { + "name": "alias-app", + "version": "1.0.0", + "purl": "pkg:npm/alias-app@1.0.0", + "type": "application", + "bom-ref": "pkg:npm/alias-app@1.0.0" + }, + "tools": { + "components": [ + { + "type": "application", + "name": "trustify-da-javascript-client", + "version": "0.4.0" + } + ] + } + }, + "components": [ + { + "name": "is-number", + "version": "7.0.0", + "purl": "pkg:npm/is-number@7.0.0", + "type": "library", + "bom-ref": "pkg:npm/is-number@7.0.0", + "hashes": [ + { + "alg": "SHA-512", + "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/alias-app@1.0.0", + "dependsOn": [ + "pkg:npm/is-number@7.0.0" + ] + }, + { + "ref": "pkg:npm/is-number@7.0.0", + "dependsOn": [] + } + ] +} diff --git a/test/providers/tst_manifests/bun/alias/package.json b/test/providers/tst_manifests/bun/alias/package.json new file mode 100644 index 00000000..fa0fd102 --- /dev/null +++ b/test/providers/tst_manifests/bun/alias/package.json @@ -0,0 +1,7 @@ +{ + "name": "alias-app", + "version": "1.0.0", + "dependencies": { + "myalias": "npm:is-number@7.0.0" + } +} diff --git a/test/providers/tst_manifests/bun/lockfile_v0/bun.lock b/test/providers/tst_manifests/bun/lockfile_v0/bun.lock new file mode 100644 index 00000000..c22bc71d --- /dev/null +++ b/test/providers/tst_manifests/bun/lockfile_v0/bun.lock @@ -0,0 +1,20 @@ +{ + "lockfileVersion": 0, + "configVersion": 1, + "workspaces": { + "": { + "name": "versioned-app-v0", + "dependencies": { + "leaf": "1.0.0" + } + } + }, + "packages": { + "leaf": [ + "leaf@1.0.0", + "", + {}, + "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + ] + } +} diff --git a/test/providers/tst_manifests/bun/lockfile_v0/expected_component_sbom.json b/test/providers/tst_manifests/bun/lockfile_v0/expected_component_sbom.json new file mode 100644 index 00000000..17f40aff --- /dev/null +++ b/test/providers/tst_manifests/bun/lockfile_v0/expected_component_sbom.json @@ -0,0 +1,51 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "timestamp": "2023-08-07T00:00:00.000Z", + "component": { + "name": "versioned-app-v0", + "version": "1.0.0", + "purl": "pkg:npm/versioned-app-v0@1.0.0", + "type": "application", + "bom-ref": "pkg:npm/versioned-app-v0@1.0.0" + }, + "tools": { + "components": [ + { + "type": "application", + "name": "trustify-da-javascript-client", + "version": "0.4.0" + } + ] + } + }, + "components": [ + { + "name": "leaf", + "version": "1.0.0", + "purl": "pkg:npm/leaf@1.0.0", + "type": "library", + "bom-ref": "pkg:npm/leaf@1.0.0", + "hashes": [ + { + "alg": "SHA-512", + "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/versioned-app-v0@1.0.0", + "dependsOn": [ + "pkg:npm/leaf@1.0.0" + ] + }, + { + "ref": "pkg:npm/leaf@1.0.0", + "dependsOn": [] + } + ] +} diff --git a/test/providers/tst_manifests/bun/lockfile_v0/expected_stack_sbom.json b/test/providers/tst_manifests/bun/lockfile_v0/expected_stack_sbom.json new file mode 100644 index 00000000..17f40aff --- /dev/null +++ b/test/providers/tst_manifests/bun/lockfile_v0/expected_stack_sbom.json @@ -0,0 +1,51 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "timestamp": "2023-08-07T00:00:00.000Z", + "component": { + "name": "versioned-app-v0", + "version": "1.0.0", + "purl": "pkg:npm/versioned-app-v0@1.0.0", + "type": "application", + "bom-ref": "pkg:npm/versioned-app-v0@1.0.0" + }, + "tools": { + "components": [ + { + "type": "application", + "name": "trustify-da-javascript-client", + "version": "0.4.0" + } + ] + } + }, + "components": [ + { + "name": "leaf", + "version": "1.0.0", + "purl": "pkg:npm/leaf@1.0.0", + "type": "library", + "bom-ref": "pkg:npm/leaf@1.0.0", + "hashes": [ + { + "alg": "SHA-512", + "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/versioned-app-v0@1.0.0", + "dependsOn": [ + "pkg:npm/leaf@1.0.0" + ] + }, + { + "ref": "pkg:npm/leaf@1.0.0", + "dependsOn": [] + } + ] +} diff --git a/test/providers/tst_manifests/bun/lockfile_v0/package.json b/test/providers/tst_manifests/bun/lockfile_v0/package.json new file mode 100644 index 00000000..6a9a0de7 --- /dev/null +++ b/test/providers/tst_manifests/bun/lockfile_v0/package.json @@ -0,0 +1,7 @@ +{ + "name": "versioned-app-v0", + "version": "1.0.0", + "dependencies": { + "leaf": "1.0.0" + } +} diff --git a/test/providers/tst_manifests/bun/lockfile_v1/bun.lock b/test/providers/tst_manifests/bun/lockfile_v1/bun.lock new file mode 100644 index 00000000..9ac0ba6a --- /dev/null +++ b/test/providers/tst_manifests/bun/lockfile_v1/bun.lock @@ -0,0 +1,20 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "versioned-app-v1", + "dependencies": { + "leaf": "1.0.0" + } + } + }, + "packages": { + "leaf": [ + "leaf@1.0.0", + "", + {}, + "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + ] + } +} diff --git a/test/providers/tst_manifests/bun/lockfile_v1/expected_component_sbom.json b/test/providers/tst_manifests/bun/lockfile_v1/expected_component_sbom.json new file mode 100644 index 00000000..102ebd12 --- /dev/null +++ b/test/providers/tst_manifests/bun/lockfile_v1/expected_component_sbom.json @@ -0,0 +1,51 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "timestamp": "2023-08-07T00:00:00.000Z", + "component": { + "name": "versioned-app-v1", + "version": "1.0.0", + "purl": "pkg:npm/versioned-app-v1@1.0.0", + "type": "application", + "bom-ref": "pkg:npm/versioned-app-v1@1.0.0" + }, + "tools": { + "components": [ + { + "type": "application", + "name": "trustify-da-javascript-client", + "version": "0.4.0" + } + ] + } + }, + "components": [ + { + "name": "leaf", + "version": "1.0.0", + "purl": "pkg:npm/leaf@1.0.0", + "type": "library", + "bom-ref": "pkg:npm/leaf@1.0.0", + "hashes": [ + { + "alg": "SHA-512", + "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/versioned-app-v1@1.0.0", + "dependsOn": [ + "pkg:npm/leaf@1.0.0" + ] + }, + { + "ref": "pkg:npm/leaf@1.0.0", + "dependsOn": [] + } + ] +} diff --git a/test/providers/tst_manifests/bun/lockfile_v1/expected_stack_sbom.json b/test/providers/tst_manifests/bun/lockfile_v1/expected_stack_sbom.json new file mode 100644 index 00000000..102ebd12 --- /dev/null +++ b/test/providers/tst_manifests/bun/lockfile_v1/expected_stack_sbom.json @@ -0,0 +1,51 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "timestamp": "2023-08-07T00:00:00.000Z", + "component": { + "name": "versioned-app-v1", + "version": "1.0.0", + "purl": "pkg:npm/versioned-app-v1@1.0.0", + "type": "application", + "bom-ref": "pkg:npm/versioned-app-v1@1.0.0" + }, + "tools": { + "components": [ + { + "type": "application", + "name": "trustify-da-javascript-client", + "version": "0.4.0" + } + ] + } + }, + "components": [ + { + "name": "leaf", + "version": "1.0.0", + "purl": "pkg:npm/leaf@1.0.0", + "type": "library", + "bom-ref": "pkg:npm/leaf@1.0.0", + "hashes": [ + { + "alg": "SHA-512", + "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/versioned-app-v1@1.0.0", + "dependsOn": [ + "pkg:npm/leaf@1.0.0" + ] + }, + { + "ref": "pkg:npm/leaf@1.0.0", + "dependsOn": [] + } + ] +} diff --git a/test/providers/tst_manifests/bun/lockfile_v1/package.json b/test/providers/tst_manifests/bun/lockfile_v1/package.json new file mode 100644 index 00000000..7320ce6f --- /dev/null +++ b/test/providers/tst_manifests/bun/lockfile_v1/package.json @@ -0,0 +1,7 @@ +{ + "name": "versioned-app-v1", + "version": "1.0.0", + "dependencies": { + "leaf": "1.0.0" + } +} diff --git a/test/providers/tst_manifests/bun/lockfile_v2/bun.lock b/test/providers/tst_manifests/bun/lockfile_v2/bun.lock new file mode 100644 index 00000000..76cb29c8 --- /dev/null +++ b/test/providers/tst_manifests/bun/lockfile_v2/bun.lock @@ -0,0 +1,20 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "versioned-app-v2", + "dependencies": { + "leaf": "1.0.0" + } + } + }, + "packages": { + "leaf": [ + "leaf@1.0.0", + "", + {}, + "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + ] + } +} diff --git a/test/providers/tst_manifests/bun/lockfile_v2/expected_component_sbom.json b/test/providers/tst_manifests/bun/lockfile_v2/expected_component_sbom.json new file mode 100644 index 00000000..24a9dbb5 --- /dev/null +++ b/test/providers/tst_manifests/bun/lockfile_v2/expected_component_sbom.json @@ -0,0 +1,51 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "timestamp": "2023-08-07T00:00:00.000Z", + "component": { + "name": "versioned-app-v2", + "version": "1.0.0", + "purl": "pkg:npm/versioned-app-v2@1.0.0", + "type": "application", + "bom-ref": "pkg:npm/versioned-app-v2@1.0.0" + }, + "tools": { + "components": [ + { + "type": "application", + "name": "trustify-da-javascript-client", + "version": "0.4.0" + } + ] + } + }, + "components": [ + { + "name": "leaf", + "version": "1.0.0", + "purl": "pkg:npm/leaf@1.0.0", + "type": "library", + "bom-ref": "pkg:npm/leaf@1.0.0", + "hashes": [ + { + "alg": "SHA-512", + "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/versioned-app-v2@1.0.0", + "dependsOn": [ + "pkg:npm/leaf@1.0.0" + ] + }, + { + "ref": "pkg:npm/leaf@1.0.0", + "dependsOn": [] + } + ] +} diff --git a/test/providers/tst_manifests/bun/lockfile_v2/expected_stack_sbom.json b/test/providers/tst_manifests/bun/lockfile_v2/expected_stack_sbom.json new file mode 100644 index 00000000..24a9dbb5 --- /dev/null +++ b/test/providers/tst_manifests/bun/lockfile_v2/expected_stack_sbom.json @@ -0,0 +1,51 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "timestamp": "2023-08-07T00:00:00.000Z", + "component": { + "name": "versioned-app-v2", + "version": "1.0.0", + "purl": "pkg:npm/versioned-app-v2@1.0.0", + "type": "application", + "bom-ref": "pkg:npm/versioned-app-v2@1.0.0" + }, + "tools": { + "components": [ + { + "type": "application", + "name": "trustify-da-javascript-client", + "version": "0.4.0" + } + ] + } + }, + "components": [ + { + "name": "leaf", + "version": "1.0.0", + "purl": "pkg:npm/leaf@1.0.0", + "type": "library", + "bom-ref": "pkg:npm/leaf@1.0.0", + "hashes": [ + { + "alg": "SHA-512", + "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/versioned-app-v2@1.0.0", + "dependsOn": [ + "pkg:npm/leaf@1.0.0" + ] + }, + { + "ref": "pkg:npm/leaf@1.0.0", + "dependsOn": [] + } + ] +} diff --git a/test/providers/tst_manifests/bun/lockfile_v2/package.json b/test/providers/tst_manifests/bun/lockfile_v2/package.json new file mode 100644 index 00000000..5c284c69 --- /dev/null +++ b/test/providers/tst_manifests/bun/lockfile_v2/package.json @@ -0,0 +1,7 @@ +{ + "name": "versioned-app-v2", + "version": "1.0.0", + "dependencies": { + "leaf": "1.0.0" + } +} diff --git a/test/providers/tst_manifests/bun/nested/bun.lock b/test/providers/tst_manifests/bun/nested/bun.lock new file mode 100644 index 00000000..476eca2f --- /dev/null +++ b/test/providers/tst_manifests/bun/nested/bun.lock @@ -0,0 +1,47 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "nested-app", + "dependencies": { + "alpha": "1.0.0", + "beta": "1.0.0" + } + } + }, + "packages": { + "alpha": [ + "alpha@1.0.0", + "", + { + "dependencies": { + "shared": "^1.0.0" + } + }, + "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + ], + "beta": [ + "beta@1.0.0", + "", + { + "dependencies": { + "shared": "^2.0.0" + } + }, + "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + ], + "shared": [ + "shared@1.0.0", + "", + {}, + "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + ], + "beta/shared": [ + "shared@2.0.0", + "", + {}, + "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + ] + } +} diff --git a/test/providers/tst_manifests/bun/nested/expected_component_sbom.json b/test/providers/tst_manifests/bun/nested/expected_component_sbom.json new file mode 100644 index 00000000..ad9089e4 --- /dev/null +++ b/test/providers/tst_manifests/bun/nested/expected_component_sbom.json @@ -0,0 +1,69 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "timestamp": "2023-08-07T00:00:00.000Z", + "component": { + "name": "nested-app", + "version": "1.0.0", + "purl": "pkg:npm/nested-app@1.0.0", + "type": "application", + "bom-ref": "pkg:npm/nested-app@1.0.0" + }, + "tools": { + "components": [ + { + "type": "application", + "name": "trustify-da-javascript-client", + "version": "0.4.0" + } + ] + } + }, + "components": [ + { + "name": "alpha", + "version": "1.0.0", + "purl": "pkg:npm/alpha@1.0.0", + "type": "library", + "bom-ref": "pkg:npm/alpha@1.0.0", + "hashes": [ + { + "alg": "SHA-512", + "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" + } + ] + }, + { + "name": "beta", + "version": "1.0.0", + "purl": "pkg:npm/beta@1.0.0", + "type": "library", + "bom-ref": "pkg:npm/beta@1.0.0", + "hashes": [ + { + "alg": "SHA-512", + "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/nested-app@1.0.0", + "dependsOn": [ + "pkg:npm/alpha@1.0.0", + "pkg:npm/beta@1.0.0" + ] + }, + { + "ref": "pkg:npm/alpha@1.0.0", + "dependsOn": [] + }, + { + "ref": "pkg:npm/beta@1.0.0", + "dependsOn": [] + } + ] +} diff --git a/test/providers/tst_manifests/bun/nested/expected_stack_sbom.json b/test/providers/tst_manifests/bun/nested/expected_stack_sbom.json new file mode 100644 index 00000000..cb749f72 --- /dev/null +++ b/test/providers/tst_manifests/bun/nested/expected_stack_sbom.json @@ -0,0 +1,107 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "timestamp": "2023-08-07T00:00:00.000Z", + "component": { + "name": "nested-app", + "version": "1.0.0", + "purl": "pkg:npm/nested-app@1.0.0", + "type": "application", + "bom-ref": "pkg:npm/nested-app@1.0.0" + }, + "tools": { + "components": [ + { + "type": "application", + "name": "trustify-da-javascript-client", + "version": "0.4.0" + } + ] + } + }, + "components": [ + { + "name": "alpha", + "version": "1.0.0", + "purl": "pkg:npm/alpha@1.0.0", + "type": "library", + "bom-ref": "pkg:npm/alpha@1.0.0", + "hashes": [ + { + "alg": "SHA-512", + "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" + } + ] + }, + { + "name": "beta", + "version": "1.0.0", + "purl": "pkg:npm/beta@1.0.0", + "type": "library", + "bom-ref": "pkg:npm/beta@1.0.0", + "hashes": [ + { + "alg": "SHA-512", + "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" + } + ] + }, + { + "name": "shared", + "version": "1.0.0", + "purl": "pkg:npm/shared@1.0.0", + "type": "library", + "bom-ref": "pkg:npm/shared@1.0.0", + "hashes": [ + { + "alg": "SHA-512", + "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" + } + ] + }, + { + "name": "shared", + "version": "2.0.0", + "purl": "pkg:npm/shared@2.0.0", + "type": "library", + "bom-ref": "pkg:npm/shared@2.0.0", + "hashes": [ + { + "alg": "SHA-512", + "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/nested-app@1.0.0", + "dependsOn": [ + "pkg:npm/alpha@1.0.0", + "pkg:npm/beta@1.0.0" + ] + }, + { + "ref": "pkg:npm/alpha@1.0.0", + "dependsOn": [ + "pkg:npm/shared@1.0.0" + ] + }, + { + "ref": "pkg:npm/beta@1.0.0", + "dependsOn": [ + "pkg:npm/shared@2.0.0" + ] + }, + { + "ref": "pkg:npm/shared@1.0.0", + "dependsOn": [] + }, + { + "ref": "pkg:npm/shared@2.0.0", + "dependsOn": [] + } + ] +} diff --git a/test/providers/tst_manifests/bun/nested/package.json b/test/providers/tst_manifests/bun/nested/package.json new file mode 100644 index 00000000..617f4600 --- /dev/null +++ b/test/providers/tst_manifests/bun/nested/package.json @@ -0,0 +1,8 @@ +{ + "name": "nested-app", + "version": "1.0.0", + "dependencies": { + "alpha": "1.0.0", + "beta": "1.0.0" + } +} diff --git a/test/providers/tst_manifests/bun/optional/bun.lock b/test/providers/tst_manifests/bun/optional/bun.lock new file mode 100644 index 00000000..9432e65c --- /dev/null +++ b/test/providers/tst_manifests/bun/optional/bun.lock @@ -0,0 +1,31 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "optional-app", + "dependencies": { + "host": "1.0.0" + } + } + }, + "packages": { + "host": [ + "host@1.0.0", + "", + { + "optionalDependencies": { + "present-opt": "^1.0.0", + "absent-opt": "^1.0.0" + } + }, + "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + ], + "present-opt": [ + "present-opt@1.0.0", + "", + {}, + "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + ] + } +} diff --git a/test/providers/tst_manifests/bun/optional/expected_component_sbom.json b/test/providers/tst_manifests/bun/optional/expected_component_sbom.json new file mode 100644 index 00000000..0a8d99b9 --- /dev/null +++ b/test/providers/tst_manifests/bun/optional/expected_component_sbom.json @@ -0,0 +1,51 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "timestamp": "2023-08-07T00:00:00.000Z", + "component": { + "name": "optional-app", + "version": "1.0.0", + "purl": "pkg:npm/optional-app@1.0.0", + "type": "application", + "bom-ref": "pkg:npm/optional-app@1.0.0" + }, + "tools": { + "components": [ + { + "type": "application", + "name": "trustify-da-javascript-client", + "version": "0.4.0" + } + ] + } + }, + "components": [ + { + "name": "host", + "version": "1.0.0", + "purl": "pkg:npm/host@1.0.0", + "type": "library", + "bom-ref": "pkg:npm/host@1.0.0", + "hashes": [ + { + "alg": "SHA-512", + "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/optional-app@1.0.0", + "dependsOn": [ + "pkg:npm/host@1.0.0" + ] + }, + { + "ref": "pkg:npm/host@1.0.0", + "dependsOn": [] + } + ] +} diff --git a/test/providers/tst_manifests/bun/optional/expected_stack_sbom.json b/test/providers/tst_manifests/bun/optional/expected_stack_sbom.json new file mode 100644 index 00000000..e6287ce0 --- /dev/null +++ b/test/providers/tst_manifests/bun/optional/expected_stack_sbom.json @@ -0,0 +1,70 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "timestamp": "2023-08-07T00:00:00.000Z", + "component": { + "name": "optional-app", + "version": "1.0.0", + "purl": "pkg:npm/optional-app@1.0.0", + "type": "application", + "bom-ref": "pkg:npm/optional-app@1.0.0" + }, + "tools": { + "components": [ + { + "type": "application", + "name": "trustify-da-javascript-client", + "version": "0.4.0" + } + ] + } + }, + "components": [ + { + "name": "host", + "version": "1.0.0", + "purl": "pkg:npm/host@1.0.0", + "type": "library", + "bom-ref": "pkg:npm/host@1.0.0", + "hashes": [ + { + "alg": "SHA-512", + "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" + } + ] + }, + { + "name": "present-opt", + "version": "1.0.0", + "purl": "pkg:npm/present-opt@1.0.0", + "type": "library", + "bom-ref": "pkg:npm/present-opt@1.0.0", + "hashes": [ + { + "alg": "SHA-512", + "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/optional-app@1.0.0", + "dependsOn": [ + "pkg:npm/host@1.0.0" + ] + }, + { + "ref": "pkg:npm/host@1.0.0", + "dependsOn": [ + "pkg:npm/present-opt@1.0.0" + ] + }, + { + "ref": "pkg:npm/present-opt@1.0.0", + "dependsOn": [] + } + ] +} diff --git a/test/providers/tst_manifests/bun/optional/package.json b/test/providers/tst_manifests/bun/optional/package.json new file mode 100644 index 00000000..83a97202 --- /dev/null +++ b/test/providers/tst_manifests/bun/optional/package.json @@ -0,0 +1,7 @@ +{ + "name": "optional-app", + "version": "1.0.0", + "dependencies": { + "host": "1.0.0" + } +} diff --git a/test/providers/tst_manifests/bun/workspace/bun.lock b/test/providers/tst_manifests/bun/workspace/bun.lock new file mode 100644 index 00000000..ef19faea --- /dev/null +++ b/test/providers/tst_manifests/bun/workspace/bun.lock @@ -0,0 +1,27 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "ws-root" + }, + "packages/member": { + "name": "member", + "version": "2.0.0", + "dependencies": { + "alpha": "1.0.0" + } + } + }, + "packages": { + "alpha": [ + "alpha@1.0.0", + "", + {}, + "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + ], + "member": [ + "member@workspace:packages/member" + ] + } +} diff --git a/test/providers/tst_manifests/bun/workspace/expected_component_sbom.json b/test/providers/tst_manifests/bun/workspace/expected_component_sbom.json new file mode 100644 index 00000000..1fc0310e --- /dev/null +++ b/test/providers/tst_manifests/bun/workspace/expected_component_sbom.json @@ -0,0 +1,51 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "timestamp": "2023-08-07T00:00:00.000Z", + "component": { + "name": "member", + "version": "2.0.0", + "purl": "pkg:npm/member@2.0.0", + "type": "application", + "bom-ref": "pkg:npm/member@2.0.0" + }, + "tools": { + "components": [ + { + "type": "application", + "name": "trustify-da-javascript-client", + "version": "0.4.0" + } + ] + } + }, + "components": [ + { + "name": "alpha", + "version": "1.0.0", + "purl": "pkg:npm/alpha@1.0.0", + "type": "library", + "bom-ref": "pkg:npm/alpha@1.0.0", + "hashes": [ + { + "alg": "SHA-512", + "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/member@2.0.0", + "dependsOn": [ + "pkg:npm/alpha@1.0.0" + ] + }, + { + "ref": "pkg:npm/alpha@1.0.0", + "dependsOn": [] + } + ] +} diff --git a/test/providers/tst_manifests/bun/workspace/expected_stack_sbom.json b/test/providers/tst_manifests/bun/workspace/expected_stack_sbom.json new file mode 100644 index 00000000..1fc0310e --- /dev/null +++ b/test/providers/tst_manifests/bun/workspace/expected_stack_sbom.json @@ -0,0 +1,51 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "timestamp": "2023-08-07T00:00:00.000Z", + "component": { + "name": "member", + "version": "2.0.0", + "purl": "pkg:npm/member@2.0.0", + "type": "application", + "bom-ref": "pkg:npm/member@2.0.0" + }, + "tools": { + "components": [ + { + "type": "application", + "name": "trustify-da-javascript-client", + "version": "0.4.0" + } + ] + } + }, + "components": [ + { + "name": "alpha", + "version": "1.0.0", + "purl": "pkg:npm/alpha@1.0.0", + "type": "library", + "bom-ref": "pkg:npm/alpha@1.0.0", + "hashes": [ + { + "alg": "SHA-512", + "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/member@2.0.0", + "dependsOn": [ + "pkg:npm/alpha@1.0.0" + ] + }, + { + "ref": "pkg:npm/alpha@1.0.0", + "dependsOn": [] + } + ] +} diff --git a/test/providers/tst_manifests/bun/workspace/package.json b/test/providers/tst_manifests/bun/workspace/package.json new file mode 100644 index 00000000..461f068e --- /dev/null +++ b/test/providers/tst_manifests/bun/workspace/package.json @@ -0,0 +1,7 @@ +{ + "name": "ws-root", + "private": true, + "workspaces": [ + "packages/member" + ] +} diff --git a/test/providers/tst_manifests/bun/workspace/packages/member/package.json b/test/providers/tst_manifests/bun/workspace/packages/member/package.json new file mode 100644 index 00000000..91cf3d16 --- /dev/null +++ b/test/providers/tst_manifests/bun/workspace/packages/member/package.json @@ -0,0 +1,7 @@ +{ + "name": "member", + "version": "2.0.0", + "dependencies": { + "alpha": "1.0.0" + } +} From cc89160353d3646235b293f19304d596c861870f Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Sun, 25 Oct 2026 10:21:03 +0200 Subject: [PATCH 2/8] docs(bun): document lockfile-only analysis Signed-off-by: Adva Oren --- README.md | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ac441a0e..1ed9c2c6 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,7 @@ $ trustify-da remediate /path/to/pom.xml --exclude 'pkg:maven/com.example/*' --e
  • JavaScript - Npm
  • JavaScript - pnpm
  • JavaScript - Yarn Classic / Yarn Berry
  • +
  • JavaScript - Bun
  • Golang - Go Modules
  • Python - pip Installer (requirements.txt)
  • Python - Poetry / uv (pyproject.toml)
  • @@ -522,7 +523,7 @@ let options = { 'TRUSTIFY_DA_POETRY_PATH' : '/path/to/poetry', 'TRUSTIFY_DA_GRADLE_PATH' : '/path/to/gradle', 'TRUSTIFY_DA_CARGO_PATH' : '/path/to/cargo', - // Workspace root for monorepos (Cargo, npm/pnpm/yarn); lock file expected here + // Workspace root for monorepos (Cargo, npm/pnpm/yarn/Bun); lock file expected here 'workspaceDir': '/path/to/workspace-root', // Configure proxy for all requests 'TRUSTIFY_DA_PROXY_URL': 'http://proxy.example.com:8080' @@ -548,14 +549,14 @@ let imageAnalysisWithArch = await client.imageAnalysis(['httpd:2.4.49^^amd64'],

    Monorepo / Workspace Support

    -For monorepos (Cargo workspaces, npm/pnpm/yarn workspaces) where the lock file lives at the workspace root rather than next to the manifest, pass the workspace root via workspaceDir or TRUSTIFY_DA_WORKSPACE_DIR: +For monorepos (Cargo workspaces, npm/pnpm/yarn/Bun workspaces) where the lock file lives at the workspace root rather than next to the manifest, pass the workspace root via workspaceDir or TRUSTIFY_DA_WORKSPACE_DIR:

    • Cargo: When set, the client checks only the given directory for Cargo.lock instead of walking up from the manifest.
    • -
    • JavaScript (npm, pnpm, yarn): When set, the client looks for the lock file (package-lock.json, pnpm-lock.yaml, yarn.lock) at the workspace root.
    • +
    • JavaScript (npm, pnpm, yarn, Bun): When set, the client looks for the lock file (package-lock.json, pnpm-lock.yaml, yarn.lock, bun.lock) at the workspace root.

    -Use stackAnalysisBatch(workspaceRoot, html, opts) to analyze all packages/crates in a workspace in one request. Supports Cargo workspaces and JS/TS workspaces (pnpm, npm, yarn). Optional batchConcurrency (or TRUSTIFY_DA_BATCH_CONCURRENCY) limits parallel SBOM generation (default 10). For JS/TS, each package.json must have non-empty name and version; invalid manifests are skipped (warnings). Per-manifest SBOM failures are skipped if at least one SBOM succeeds (unless continueOnError: false). Set batchMetadata: true (or TRUSTIFY_DA_BATCH_METADATA) to receive { analysis, metadata } with errors[]. CLI: stack-batch --metadata, --fail-fast. See monorepo implementation plan §2.3 and §3.5. +Use stackAnalysisBatch(workspaceRoot, html, opts) to analyze all packages/crates in a workspace in one request. Supports Cargo workspaces and JS/TS workspaces (pnpm, npm, yarn, Bun). Optional batchConcurrency (or TRUSTIFY_DA_BATCH_CONCURRENCY) limits parallel SBOM generation (default 10). For JS/TS, each package.json must have non-empty name and version; invalid manifests are skipped (warnings). Per-manifest SBOM failures are skipped if at least one SBOM succeeds (unless continueOnError: false). Set batchMetadata: true (or TRUSTIFY_DA_BATCH_METADATA) to receive { analysis, metadata } with errors[]. CLI: stack-batch --metadata, --fail-fast. See monorepo implementation plan §2.3 and §3.5.

    See VS Code Extension Integration Requirements for integration details. @@ -563,16 +564,19 @@ See VS Code Extens

    JavaScript Lockfile Analysis

    -npm and Yarn component/stack analysis reads package manifests and lockfiles directly. It does not run npm, Yarn, or Corepack, install dependencies, access package registries, or generate project files. Installed node_modules and Yarn installation state are not required. +npm, Yarn, and Bun component/stack analysis reads package manifests and lockfiles directly. It does not run npm, Yarn, Bun, or Corepack, install dependencies, access package registries, or generate or update project files. These package managers do not need to be installed for scanning. Installed node_modules and Yarn installation state are not required.

    npm supports lockfile versions 2 and 3, including npm-shrinkwrap.json (which takes precedence over package-lock.json). Yarn Classic and Berry are selected from the lockfile format, so a packageManager declaration is not required. Missing, stale, unsupported, or incomplete required dependency metadata produces an error; synchronize or regenerate the lockfile with the project's package manager before scanning. npm version 1 lockfiles must be upgraded first. Peer and optional dependencies that have no locked resolution retain the existing manifest-only SBOM entries.

    +Bun requires a pre-existing text bun.lock with numeric lockfileVersion 0, 1, or 2. Binary bun.lockb files are unsupported; migrate them before scanning using Bun 1.2 or later: bun install --save-text-lockfile --frozen-lockfile --lockfile-only. +

    +

    Workspaces and package-aware local dependencies use their manifests and recorded dependency metadata. Unsupported custom resolution protocols, package-bearing link: dependencies, and Classic dependency cycles with path-dependent resolutions produce explicit errors. Yarn plugins are not executed. Validation covers declarations and resolutions recorded in these files; it does not replace package-manager installation or validate package contents.

    -TRUSTIFY_DA_NPM_PATH and TRUSTIFY_DA_YARN_PATH are retained for compatibility but are not used for dependency analysis. pnpm, Bun, and other ecosystems retain their existing tool requirements. Lockfile hashes are reported when available; analysis does not download archives to verify them. +TRUSTIFY_DA_NPM_PATH, TRUSTIFY_DA_YARN_PATH, and TRUSTIFY_DA_BUN_PATH are retained for compatibility but are not used for dependency analysis. pnpm and other ecosystems retain their existing tool requirements. Lockfile hashes are reported when available; analysis does not download archives to verify them.

    Proxy Configuration

    @@ -605,9 +609,7 @@ The client can resolve the project license from the manifest (e

    Customizing Executables

    -This project uses each ecosystem's executable for creating dependency trees. These executables are expected to be -present on the system's PATH environment. If they are not, or perhaps you want to use custom ones. Use can use the -following keys for setting custom paths for the said executables. +For ecosystems that require external tools, executables must be available on the system's PATH or configured using the keys below.

    @@ -642,6 +644,11 @@ following keys for setting custom paths for the said executables. + + + + + From 05e6749bc17f1a8acb9b5d648657641ee701c697 Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Sun, 25 Oct 2026 10:21:33 +0200 Subject: [PATCH 3/8] fix(bun): correct lockfile dependency resolution Signed-off-by: Adva Oren --- src/providers/javascript_lockfile.js | 65 ++++-- test/providers/javascript.test.js | 326 +++++++++++++++++++++++++++ 2 files changed, 378 insertions(+), 13 deletions(-) diff --git a/src/providers/javascript_lockfile.js b/src/providers/javascript_lockfile.js index 38e02dff..b8243970 100644 --- a/src/providers/javascript_lockfile.js +++ b/src/providers/javascript_lockfile.js @@ -812,7 +812,7 @@ export function parseBunEntry(tuple) { */ export function readBunGraph(root, manifestPath, manifest, lock) { const file = path.join(root, 'bun.lock'); - if (![0, 1, 2].includes(Number(lock.lockfileVersion))) { + if (![0, 1, 2].includes(lock.lockfileVersion)) { throw new Error(`Unsupported bun lockfile version ${lock.lockfileVersion} in ${file}. Regenerate a version 0, 1, or 2 bun.lock before scanning.`); } if (!object(lock.workspaces)) { unsupported(file, 'workspaces', 'missing workspaces table'); } @@ -820,14 +820,38 @@ export function readBunGraph(root, manifestPath, manifest, lock) { const packages = object(lock.packages) ? lock.packages : {}; const manifests = projectManifests(root, manifestPath, manifest); + const rootFile = path.join(root, 'package.json'); + const rootData = manifests.get(rootFile); + const overrideField = Object.hasOwn(rootData, 'overrides') ? 'overrides' : 'resolutions'; + const overrides = Object.fromEntries(Object.entries(dependencyMap(rootData, overrideField, rootFile)).map(([name, spec]) => { + if (overrideField === 'resolutions') { name = name.replace(/^\*\*\//, ''); } + if (spec.startsWith('$')) { + const dependencies = { ...dependencyMap(rootData, 'peerDependencies', rootFile), ...manifestDependencies(rootData, rootFile, true) }; + spec = dependencies[spec.slice(1)]; + if (typeof spec !== 'string' || !spec) { unsupported(rootFile, name, 'override reference has no declared dependency'); } + } + return [name, spec]; + })); + validateSnapshot(rootFile, { overrides }, lock, ['overrides']); const locals = new Map(); for (const [manifestFile, data] of manifests) { const location = path.relative(root, path.dirname(manifestFile)).split(path.sep).join('/') || '.'; const entry = workspaces[location === '.' ? '' : location]; if (!object(entry)) { stale(manifestFile, data.name, 'is missing from the lockfile'); } if (entry.name != null && data.name !== entry.name) { stale(manifestFile, data.name, 'has a different locked identity'); } - validateSnapshot(manifestFile, data, entry, ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']); - locals.set(location, { data, file: manifestFile }); + const snapshot = { + dependencies: { ...dependencyMap(data, 'dependencies', manifestFile) }, + devDependencies: { ...dependencyMap(data, 'devDependencies', manifestFile) }, + optionalDependencies: dependencyMap(data, 'optionalDependencies', manifestFile), + peerDependencies: data.peerDependencies, + }; + // Bun replaces the production declaration first, otherwise the development declaration. + for (const name of Object.keys(snapshot.optionalDependencies)) { + if (Object.hasOwn(snapshot.dependencies, name)) { delete snapshot.dependencies[name]; } + else { delete snapshot.devDependencies[name]; } + } + validateSnapshot(manifestFile, snapshot, entry, ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']); + locals.set(location, { data, file: manifestFile, packageKey: location === '.' ? '' : data.name, devDependencies: snapshot.devDependencies }); } const rootLocation = path.relative(root, path.dirname(path.resolve(manifestPath))).split(path.sep).join('/') || '.'; if (!locals.has(rootLocation)) { unsupported(file, manifest.name, 'manifest is not in the workspace'); } @@ -836,13 +860,14 @@ export function readBunGraph(root, manifestPath, manifest, lock) { // hoisting: a `parent/child` nested entry wins over a hoisted top-level one, // walking up the owner's install path before falling back to the root. function resolveKey(ownerPath, name) { - let prefix = ownerPath; + const parts = ownerPath ? ownerPath.split('/') : []; while (true) { - const candidate = prefix ? `${prefix}/${name}` : name; + const candidate = [...parts, name].join('/'); if (packages[candidate] !== undefined) { return candidate; } - if (!prefix) { return undefined; } - const slash = prefix.lastIndexOf('/'); - prefix = slash === -1 ? '' : prefix.slice(0, slash); + if (parts.length === 0) { return undefined; } + parts.pop(); + // A scope and its package name occupy one resolution level. + if (parts.at(-1)?.startsWith('@')) { parts.pop(); } } } @@ -862,10 +887,16 @@ export function readBunGraph(root, manifestPath, manifest, lock) { const parsed = parseBunEntry(entry); if (parsed.workspace) { const location = parsed.range.slice('workspace:'.length) || '.'; - if (!locals.has(location)) { unsupported(file, key, 'workspace package missing from the lockfile'); } + const local = locals.get(location); + if (!local) { unsupported(file, key, 'workspace package missing from the lockfile'); } + if (spec != null && !['workspace:*', 'workspace:^', 'workspace:~'].includes(spec)) { + validateRange(source, name, spec, { name: local.data.name, version: local.data.version ?? 'v0.0.0' }); + } return `ws:${location}`; } - if (spec != null) { validateRange(source, name, spec, { name: parsed.name, version: parsed.version }); } + const aliased = spec?.startsWith('npm:') && !semver.validRange(spec.slice(4)); + const effective = !aliased && Object.hasOwn(overrides, name) ? overrides[name] : spec; + if (effective != null) { validateRange(source, name, effective, { name: parsed.name, version: parsed.version }); } return key; } @@ -882,7 +913,10 @@ export function readBunGraph(root, manifestPath, manifest, lock) { graph.nodes.set(id, node); const optionalDeps = local.data.optionalDependencies ?? {}; for (const [name, spec] of Object.entries(manifestDependencies(local.data, local.file))) { - const cid = childId('', name, spec, local.file, Object.hasOwn(optionalDeps, name)); + const devSpec = local.devDependencies[name]; + // Bun may lock either range when it retains both declarations for the same name. + const effective = typeof devSpec === 'string' && semver.validRange(spec) && semver.validRange(devSpec) ? `${spec} || ${devSpec}` : spec; + const cid = childId(local.packageKey, name, effective, local.file, Object.hasOwn(optionalDeps, name)); if (cid == null) { continue; } node.dependencies.set(name, cid); children.push(cid); @@ -892,8 +926,13 @@ export function readBunGraph(root, manifestPath, manifest, lock) { if (typeof parsed.name !== 'string' || !parsed.name || typeof parsed.version !== 'string' || !parsed.version) { unsupported(file, id, 'missing package name or version'); } const node = { name: parsed.name, version: parsed.version, dependencies: new Map(), hashes: hashes({ integrity: parsed.integrity }) }; graph.nodes.set(id, node); - const required = object(parsed.info.dependencies) ? parsed.info.dependencies : {}; - const optionalDeps = object(parsed.info.optionalDependencies) ? parsed.info.optionalDependencies : {}; + for (const field of ['dependencies', 'optionalDependencies']) { + if (Object.hasOwn(parsed.info, field) && !object(parsed.info[field])) { + throw new Error(`Invalid ${field} in ${file} for ${id}: expected a dependency mapping.`); + } + } + const required = dependencyMap(parsed.info, 'dependencies', `${file} for ${id}`); + const optionalDeps = dependencyMap(parsed.info, 'optionalDependencies', `${file} for ${id}`); // ponytail: transitive peerDependencies are omitted (as the prior // reader did); direct peers are supplemented by the SBOM builder. Add // peer-edge resolution here if a fixture ever needs transitive peers. diff --git a/test/providers/javascript.test.js b/test/providers/javascript.test.js index 3f0d49f7..75bc7b6e 100644 --- a/test/providers/javascript.test.js +++ b/test/providers/javascript.test.js @@ -507,6 +507,30 @@ suite('bun lockfile-only analysis (TC-7014)', () => { expect(sbom.components.filter(c => c.name === 'shared').map(c => c.version).sort()).to.deep.equal(['1.0.0', '2.0.0']); }); + for (const ancestor of ['', 'ancestor', '@outer/ancestor']) { + const prefix = ancestor ? `${ancestor}/` : ''; + const owner = `${prefix}@scope/parent`; + test(`keeps scoped package names intact when resolving dependencies of ${owner}`, () => { + const dependencies = ancestor ? { [ancestor]: '1.0.0' } : { '@scope/parent': 'npm:is-odd@3.0.1' }; + const packages = { + [owner]: ['is-odd@3.0.1', '', { dependencies: { 'is-number': '^6.0.0' } }, BUN_SRI], + [`${prefix}@scope/is-number`]: ['kind-of@6.0.0', '', {}, BUN_SRI], + [`${prefix}is-number`]: ['is-number@6.0.0', '', {}, BUN_SRI], + }; + if (ancestor) { + packages[ancestor] = [`${ancestor}@1.0.0`, '', { dependencies: { '@scope/parent': 'npm:is-odd@3.0.1' } }, BUN_SRI]; + packages['is-number'] = ['is-number@7.0.0', '', {}, BUN_SRI]; + } + const mp = project({ name: 'app', version: '1.0.0', dependencies }, + { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies } }, packages }); + + const sbom = stack(mp); + + expect(edge(sbom, 'is-odd@3.0.1', 'is-number@6.0.0')).to.be.true; + expect(sbom.components.some(c => c.name === 'kind-of')).to.be.false; + }); + } + /// Regression: a workspace member reports its real version and dependencies, not a `workspace:` pseudo-version. test('reports a workspace member\'s real identity and dependencies', () => { const mp = project({ name: 'ws-root', private: true, workspaces: ['packages/member'] }, @@ -519,6 +543,194 @@ suite('bun lockfile-only analysis (TC-7014)', () => { expect(edge(sbom, 'member@2.0.0', 'alpha@1.0.0')).to.be.true; }); + for (const [memberName, reference] of [['member', 'member'], ['@scope/member', '@scope/member'], ['member', 'alias']]) { + for (const nested of [true, false]) { + test(`resolves ${nested ? 'nested' : 'shared'} dependencies in workspace context for ${reference}`, () => { + const memberVersion = nested ? '7.0.0' : '6.0.0'; + const dependencies = { 'is-number': '6.0.0', [reference]: 'workspace:*' }; + const memberDependencies = { 'is-number': memberVersion }; + const packages = { + 'is-number': ['is-number@6.0.0', '', {}, BUN_SRI], + [memberName]: [`${memberName}@workspace:packages/member`], + }; + if (reference !== memberName) { packages[reference] = packages[memberName]; } + if (nested) { + packages[`${memberName}/is-number`] = ['is-number@7.0.0', '', {}, BUN_SRI]; + if (reference !== memberName) { packages[`${reference}/is-number`] = packages[`${memberName}/is-number`]; } + } + const mp = project({ name: 'app', version: '1.0.0', workspaces: ['packages/member'], dependencies }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies }, + 'packages/member': { name: memberName, version: '2.0.0', dependencies: memberDependencies } }, packages }, + { extra: { 'packages/member/package.json': { name: memberName, version: '2.0.0', dependencies: memberDependencies } } }); + const before = snapshot(root); + const memberId = `${memberName.replace('@', '%40')}@2.0.0`; + + const rootStack = stack(mp); + expect(edge(rootStack, 'app@1.0.0', 'is-number@6.0.0')).to.be.true; + expect(edge(rootStack, memberId, `is-number@${memberVersion}`)).to.be.true; + expect(component(mp).components.map(c => c.purl).sort()).to.deep.equal([`pkg:npm/${memberId}`, 'pkg:npm/is-number@6.0.0'].sort()); + for (const read of [stack, component]) { + const memberSbom = read(path.join(root, 'packages/member/package.json')); + expect(memberSbom.components.map(c => c.purl)).to.deep.equal([`pkg:npm/is-number@${memberVersion}`]); + } + expect(snapshot(root)).to.deep.equal(before); + }); + } + } + + for (const [range, version, compatible] of [ + ['^1.0.0', '2.0.0', false], + ['~1.0.0', '1.1.0', false], + ['1.0.0', '2.0.0', false], + ['^1.0.0', '1.1.0', true], + ['*', '2.0.0', true], + ['*', '2.0.0-beta.1', false], + ['workspace:^1.0.0', '2.0.0', false], + ['workspace:~1.0.0', '1.1.0', false], + ['workspace:1.0.0', '2.0.0', false], + ['workspace:^1.0.0', '1.1.0', true], + ['workspace:*', '2.0.0', true], + ['workspace:*', '2.0.0-beta.1', true], + ['workspace:*', undefined, true], + ['workspace:^', '2.0.0-beta.1', true], + ['workspace:~', '2.0.0-beta.1', true], + ]) { + test(`${range} ${compatible ? 'accepts' : 'rejects'} local version ${version ?? 'unspecified'}`, () => { + const dependencies = { local: range }; + const localDependencies = { alpha: '1.0.0' }; + const mp = project({ name: 'app', version: '1.0.0', workspaces: ['packages/local'], dependencies }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies }, + 'packages/local': { name: 'local', version: '1.0.0', dependencies: localDependencies } }, + packages: { local: ['local@workspace:packages/local'], alpha: ['alpha@1.0.0', '', {}, BUN_SRI] } }, + { extra: { 'packages/local/package.json': { name: 'local', version, dependencies: localDependencies } } }); + const before = snapshot(root); + for (const read of [stack, component]) { + if (compatible) { + expect(read(mp).components.find(c => c.name === 'local').version).to.equal(version ?? 'v0.0.0'); + } else { + expect(() => read(mp)).to.throw(`requires ${range} but resolves to ${version}`); + } + } + expect(snapshot(root)).to.deep.equal(before); + }); + } + + test('rejects stale workspace dependency declarations even with workspace:*', () => { + const dependencies = { local: 'workspace:*' }; + const mp = project({ name: 'app', version: '1.0.0', workspaces: ['packages/local'], dependencies }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies }, + 'packages/local': { name: 'local', version: '1.0.0', dependencies: { alpha: '1.0.0' } } }, + packages: { local: ['local@workspace:packages/local'], alpha: ['alpha@1.0.0', '', {}, BUN_SRI] } }, + { extra: { 'packages/local/package.json': { name: 'local', version: '2.0.0', dependencies: { alpha: '2.0.0' } } } }); + for (const read of [stack, component]) { + expect(() => read(mp)).to.throw(/out of date.*alpha.*dependencies declaration/); + } + }); + + for (const [fields, recordedFields, version] of [ + [['dependencies', 'optionalDependencies'], ['optionalDependencies'], '7.0.0'], + [['devDependencies', 'optionalDependencies'], ['optionalDependencies'], '7.0.0'], + [['dependencies', 'devDependencies', 'optionalDependencies'], ['devDependencies', 'optionalDependencies'], '6.0.0'], + [['peerDependencies', 'optionalDependencies'], ['peerDependencies', 'optionalDependencies'], '7.0.0'], + [['dependencies', 'peerDependencies', 'optionalDependencies'], ['peerDependencies', 'optionalDependencies'], '7.0.0'], + [['devDependencies', 'peerDependencies', 'optionalDependencies'], ['peerDependencies', 'optionalDependencies'], '7.0.0'], + [['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'], ['devDependencies', 'peerDependencies', 'optionalDependencies'], '6.0.0'], + [['dependencies', 'devDependencies'], ['dependencies', 'devDependencies'], '6.0.0'], + ]) { + test(`normalizes Bun optional precedence for ${fields.join(' + ')}`, () => { + const declarations = Object.fromEntries(fields.map(field => [field, { 'is-number': field === 'optionalDependencies' ? '7.0.0' : '6.0.0' }])); + const recorded = Object.fromEntries(recordedFields.map(field => [field, declarations[field]])); + const mp = project({ name: 'app', version: '1.0.0', ...declarations }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', ...recorded } }, + packages: { 'is-number': [`is-number@${version}`, '', {}, BUN_SRI] } }); + const before = snapshot(root); + for (const read of [stack, component]) { + expect(read(mp).components.map(c => c.purl)).to.deep.equal([`pkg:npm/is-number@${version}`]); + } + expect(snapshot(root)).to.deep.equal(before); + }); + } + + test('rejects a Bun resolution outside both retained production and development ranges', () => { + const declarations = { devDependencies: { 'is-number': '6.0.0' }, optionalDependencies: { 'is-number': '7.0.0' } }; + const mp = project({ name: 'app', version: '1.0.0', dependencies: { 'is-number': '6.0.0' }, ...declarations }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', ...declarations } }, + packages: { 'is-number': ['is-number@5.0.0', '', {}, BUN_SRI] } }); + for (const read of [stack, component]) { + expect(() => read(mp)).to.throw(/requires 7\.0\.0 \|\| 6\.0\.0 but resolves to 5\.0\.0/); + } + }); + + test('normalizes Bun optional precedence in workspace member snapshots', () => { + const declarations = { dependencies: { 'is-number': '6.0.0' }, optionalDependencies: { 'is-number': '7.0.0' } }; + const mp = project({ name: 'app', version: '1.0.0', workspaces: ['packages/member'] }, + { lockfileVersion: 2, workspaces: { '': { name: 'app' }, + 'packages/member': { name: 'member', version: '1.0.0', optionalDependencies: declarations.optionalDependencies } }, + packages: { member: ['member@workspace:packages/member'], 'is-number': ['is-number@7.0.0', '', {}, BUN_SRI] } }, + { manifestPath: 'packages/member/package.json', extra: { 'packages/member/package.json': { name: 'member', version: '1.0.0', ...declarations } } }); + for (const read of [stack, component]) { + expect(read(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@7.0.0']); + } + }); + + for (const [change, declarations] of [ + ['optional version', { optionalDependencies: { 'is-number': '6.0.0' } }], + ['optional removal', { optionalDependencies: undefined }], + ['dependency classification', { dependencies: { 'is-number': '7.0.0' }, optionalDependencies: undefined }], + ['retained development version', { devDependencies: { 'is-number': '7.0.0' } }], + ['peer version', { peerDependencies: { 'is-number': '7.0.0' } }], + ]) { + test(`rejects stale Bun dependency snapshots after changing ${change}`, () => { + const original = { dependencies: { 'is-number': '6.0.0' }, devDependencies: { 'is-number': '6.0.0' }, + peerDependencies: { 'is-number': '6.0.0' }, optionalDependencies: { 'is-number': '7.0.0' } }; + const mp = project({ name: 'app', version: '1.0.0', ...original, ...declarations }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', devDependencies: original.devDependencies, + peerDependencies: original.peerDependencies, optionalDependencies: original.optionalDependencies } }, + packages: { 'is-number': ['is-number@6.0.0', '', {}, BUN_SRI] } }); + const before = snapshot(root); + for (const read of [stack, component]) { + expect(() => read(mp)).to.throw(/out of date/); + } + expect(snapshot(root)).to.deep.equal(before); + }); + } + + test('accepts changes to a shadowed Bun production dependency declaration', () => { + const mp = project({ name: 'app', version: '1.0.0', dependencies: { 'is-number': '^6.0.0' }, optionalDependencies: { 'is-number': '7.0.0' } }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', optionalDependencies: { 'is-number': '7.0.0' } } }, + packages: { 'is-number': ['is-number@7.0.0', '', {}, BUN_SRI] } }); + expect(stack(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@7.0.0']); + }); + + test('rejects malformed Bun dependency maps even when an optional dependency overrides them', () => { + const mp = project({ name: 'app', version: '1.0.0', dependencies: { 'is-number': 6 }, optionalDependencies: { 'is-number': '7.0.0' } }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', optionalDependencies: { 'is-number': '7.0.0' } } }, + packages: { 'is-number': ['is-number@7.0.0', '', {}, BUN_SRI] } }); + expect(() => stack(mp)).to.throw(/Invalid dependencies/); + }); + + for (const field of ['dependencies', 'optionalDependencies']) { + for (const [label, value] of [ + ['array', ['beta']], ['string', 'beta'], ['number', 1], ['boolean', false], ['null', null], + ['non-string specification', { beta: 2 }], ['null specification', { beta: null }], ['empty specification', { beta: '' }], + ]) { + test(`rejects Bun transitive ${field} with ${label} metadata`, () => { + const dependencies = { alpha: '1.0.0' }; + const mp = project({ name: 'app', version: '1.0.0', dependencies }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, + packages: { + alpha: ['alpha@1.0.0', '', { [field]: value }, BUN_SRI], + beta: ['beta@2.0.0', '', {}, BUN_SRI], + } }); + const before = snapshot(root); + for (const read of [stack, component]) { + expect(() => read(mp)).to.throw(new RegExp(`Invalid ${field}.*bun\\.lock`)); + } + expect(snapshot(root)).to.deep.equal(before); + }); + } + } + /// Preserves resolvable optional branches while skipping absent optional dependencies. test('preserves resolvable optional branches and skips absent ones', () => { const mp = project({ name: 'app', version: '1.0.0', dependencies: { host: '1.0.0' } }, @@ -540,6 +752,108 @@ suite('bun lockfile-only analysis (TC-7014)', () => { expect(() => stack(mp)).to.throw(/out of date.*missing/); }); + for (const [field, key] of [['overrides', 'is-number'], ['resolutions', 'is-number'], ['resolutions', '**/is-number']]) { + test(`uses the effective Bun override from ${field}.${key}`, () => { + const dependencies = { 'is-number': '6.0.0' }; + const mp = project({ name: 'app', version: '1.0.0', dependencies, [field]: { [key]: '7.0.0' } }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, + overrides: { 'is-number': '7.0.0' }, packages: { 'is-number': ['is-number@7.0.0', '', {}, BUN_SRI] } }); + for (const read of [stack, component]) { + expect(read(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@7.0.0']); + } + }); + } + + for (const [change, declared, recorded] of [ + ['changed', { 'is-number': '7.0.0' }, { 'is-number': '6.0.0' }], + ['added', { 'is-number': '7.0.0' }, {}], + ['removed', {}, { 'is-number': '6.0.0' }], + ]) { + test(`rejects ${change} Bun overrides without changing project files`, () => { + const dependencies = { 'is-number': '*' }; + const mp = project({ name: 'app', version: '1.0.0', dependencies, overrides: declared }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, + overrides: recorded, packages: { 'is-number': ['is-number@6.0.0', '', {}, BUN_SRI] } }); + const before = snapshot(root); + for (const read of [stack, component]) { + expect(() => read(mp)).to.throw(/out of date.*overrides/); + } + expect(snapshot(root)).to.deep.equal(before); + }); + } + + test('applies Bun overrides to transitive dependencies and rejects an incompatible locked version', () => { + const dependencies = { 'is-odd': '3.0.1' }; + const lock = { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, + overrides: { 'is-number': '7.0.0' }, packages: { + 'is-odd': ['is-odd@3.0.1', '', { dependencies: { 'is-number': '^6.0.0' } }, BUN_SRI], + 'is-number': ['is-number@7.0.0', '', {}, BUN_SRI], + } }; + const mp = project({ name: 'app', version: '1.0.0', dependencies, overrides: lock.overrides }, lock); + expect(edge(stack(mp), 'is-odd@3.0.1', 'is-number@7.0.0')).to.be.true; + + lock.packages['is-number'][0] = 'is-number@6.0.0'; + fs.writeFileSync(path.join(root, 'bun.lock'), JSON.stringify(lock)); + expect(() => stack(mp)).to.throw(/requires 7\.0\.0 but resolves to 6\.0\.0/); + }); + + test('normalizes Bun override dependency references to their recorded specifications', () => { + const dependencies = { 'is-number': '6.0.0', pin: 'npm:is-number@7.0.0' }; + const mp = project({ name: 'app', version: '1.0.0', dependencies, overrides: { 'is-number': '$pin' } }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, + overrides: { 'is-number': 'npm:is-number@7.0.0' }, packages: { + 'is-number': ['is-number@7.0.0', '', {}, BUN_SRI], pin: ['is-number@7.0.0', '', {}, BUN_SRI], + } }); + expect(stack(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@7.0.0']); + }); + + test('preserves npm aliases when a Bun override names the alias', () => { + const dependencies = { pick: 'npm:is-number@7.0.0' }; + const mp = project({ name: 'app', version: '1.0.0', dependencies, overrides: { pick: '6.0.0' } }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, + overrides: { pick: '6.0.0' }, packages: { pick: ['is-number@7.0.0', '', {}, BUN_SRI] } }); + expect(stack(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@7.0.0']); + }); + + test('reports the replacement package identity for a Bun override using an npm alias', () => { + const dependencies = { 'is-number': '6.0.0' }; + const overrides = { 'is-number': 'npm:kind-of@6.0.0' }; + const mp = project({ name: 'app', version: '1.0.0', dependencies, overrides }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, overrides, + packages: { 'is-number': ['kind-of@6.0.0', '', {}, BUN_SRI] } }); + for (const read of [stack, component]) { + expect(read(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/kind-of@6.0.0']); + } + }); + + test('rejects a Bun override reference to an undeclared dependency', () => { + const dependencies = { 'is-number': '*' }; + const mp = project({ name: 'app', version: '1.0.0', dependencies, overrides: { 'is-number': '$missing' } }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, + packages: { 'is-number': ['is-number@6.0.0', '', {}, BUN_SRI] } }); + expect(() => stack(mp)).to.throw(/override reference has no declared dependency/); + }); + + test('uses root Bun overrides when analyzing a workspace member', () => { + const dependencies = { 'is-number': '6.0.0' }; + const overrides = { 'is-number': '7.0.0' }; + const mp = project({ name: 'ws-root', private: true, workspaces: ['packages/member'], overrides }, + { lockfileVersion: 2, workspaces: { '': { name: 'ws-root' }, 'packages/member': { name: 'member', version: '1.0.0', dependencies } }, + overrides, packages: { member: ['member@workspace:packages/member'], 'is-number': ['is-number@7.0.0', '', {}, BUN_SRI] } }, + { manifestPath: 'packages/member/package.json', extra: { + 'packages/member/package.json': { name: 'member', version: '1.0.0', dependencies, overrides: { 'is-number': '6.0.0' } }, + } }); + expect(stack(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@7.0.0']); + }); + + test('ignores Bun resolutions when an overrides field is present', () => { + const dependencies = { 'is-number': '6.0.0' }; + const mp = project({ name: 'app', version: '1.0.0', dependencies, overrides: {}, resolutions: { 'is-number': '7.0.0' } }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, + packages: { 'is-number': ['is-number@6.0.0', '', {}, BUN_SRI] } }); + expect(stack(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@6.0.0']); + }); + /// Rejects a changed manifest declaration now that bun no longer re-syncs the lockfile. test('rejects a changed manifest declaration without writing files', () => { const lock = { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { alpha: '1.0.0' } } }, @@ -561,6 +875,18 @@ suite('bun lockfile-only analysis (TC-7014)', () => { }); } + for (const version of [null, false, true, '', '0', '1', '2', [], [2], {}, undefined]) { + test(`rejects invalid lockfileVersion ${JSON.stringify(version)}`, () => { + const mp = project({ name: 'app', version: '1.0.0' }, + { lockfileVersion: version, workspaces: { '': { name: 'app' } }, packages: {} }); + const before = snapshot(root); + for (const read of [stack, component]) { + expect(() => read(mp)).to.throw(/Unsupported bun lockfile version/); + } + expect(snapshot(root)).to.deep.equal(before); + }); + } + /// Verifies an unsupported lockfileVersion fails closed with an actionable error. test('rejects an unsupported lockfile version', () => { const mp = project({ name: 'app', version: '1.0.0', dependencies: {} }, From 122e2f823e4da9149fa5dc2e370a2d62915871ad Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Sun, 25 Oct 2026 10:49:04 +0200 Subject: [PATCH 4/8] test(bun): drop tautological versioned lockfile fixtures The lockfile_v0/v1/v2 fixtures were byte-identical except the lockfileVersion field (and the v0 one was not a genuine v0 lockfile, just the v1/v2 shape with the number set to 0). The version gate is already covered by the inline supports/rejects lockfileVersion unit tests, so these full-SBOM comparisons added no coverage. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Adva Oren --- .github/dependabot.yml | 3 -- test/providers/javascript.test.js | 2 +- .../tst_manifests/bun/lockfile_v0/bun.lock | 20 -------- .../lockfile_v0/expected_component_sbom.json | 51 ------------------- .../bun/lockfile_v0/expected_stack_sbom.json | 51 ------------------- .../bun/lockfile_v0/package.json | 7 --- .../tst_manifests/bun/lockfile_v1/bun.lock | 20 -------- .../lockfile_v1/expected_component_sbom.json | 51 ------------------- .../bun/lockfile_v1/expected_stack_sbom.json | 51 ------------------- .../bun/lockfile_v1/package.json | 7 --- .../tst_manifests/bun/lockfile_v2/bun.lock | 20 -------- .../lockfile_v2/expected_component_sbom.json | 51 ------------------- .../bun/lockfile_v2/expected_stack_sbom.json | 51 ------------------- .../bun/lockfile_v2/package.json | 7 --- 14 files changed, 1 insertion(+), 391 deletions(-) delete mode 100644 test/providers/tst_manifests/bun/lockfile_v0/bun.lock delete mode 100644 test/providers/tst_manifests/bun/lockfile_v0/expected_component_sbom.json delete mode 100644 test/providers/tst_manifests/bun/lockfile_v0/expected_stack_sbom.json delete mode 100644 test/providers/tst_manifests/bun/lockfile_v0/package.json delete mode 100644 test/providers/tst_manifests/bun/lockfile_v1/bun.lock delete mode 100644 test/providers/tst_manifests/bun/lockfile_v1/expected_component_sbom.json delete mode 100644 test/providers/tst_manifests/bun/lockfile_v1/expected_stack_sbom.json delete mode 100644 test/providers/tst_manifests/bun/lockfile_v1/package.json delete mode 100644 test/providers/tst_manifests/bun/lockfile_v2/bun.lock delete mode 100644 test/providers/tst_manifests/bun/lockfile_v2/expected_component_sbom.json delete mode 100644 test/providers/tst_manifests/bun/lockfile_v2/expected_stack_sbom.json delete mode 100644 test/providers/tst_manifests/bun/lockfile_v2/package.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml index bfbd95e3..16abf26b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -183,9 +183,6 @@ updates: - "/test/providers/provider_manifests/yarn-classic/with_lock_file" - "/test/providers/provider_manifests/yarn-classic/without_lock_file" - "/test/providers/tst_manifests/bun/alias" - - "/test/providers/tst_manifests/bun/lockfile_v0" - - "/test/providers/tst_manifests/bun/lockfile_v1" - - "/test/providers/tst_manifests/bun/lockfile_v2" - "/test/providers/tst_manifests/bun/nested" - "/test/providers/tst_manifests/bun/optional" - "/test/providers/tst_manifests/bun/package_json_deps_with_exhortignore_object" diff --git a/test/providers/javascript.test.js b/test/providers/javascript.test.js index 75bc7b6e..675e558c 100644 --- a/test/providers/javascript.test.js +++ b/test/providers/javascript.test.js @@ -936,7 +936,7 @@ suite('bun complete SBOM reference comparisons (TC-7014)', () => { suiteSetup(() => { refClock = useFakeTimers(new Date('2023-08-07T00:00:00.000Z')); }); suiteTeardown(() => { refClock.restore(); }); const base = 'test/providers/tst_manifests/bun'; - const fixtures = ['alias', 'nested', 'optional', 'workspace', 'lockfile_v0', 'lockfile_v1', 'lockfile_v2']; + const fixtures = ['alias', 'nested', 'optional', 'workspace']; for (const fixture of fixtures) { for (const { method, type } of [{ method: 'provideStack', type: 'stack' }, { method: 'provideComponent', type: 'component' }]) { test(`bun ${fixture} ${type} matches every SBOM field`, () => { diff --git a/test/providers/tst_manifests/bun/lockfile_v0/bun.lock b/test/providers/tst_manifests/bun/lockfile_v0/bun.lock deleted file mode 100644 index c22bc71d..00000000 --- a/test/providers/tst_manifests/bun/lockfile_v0/bun.lock +++ /dev/null @@ -1,20 +0,0 @@ -{ - "lockfileVersion": 0, - "configVersion": 1, - "workspaces": { - "": { - "name": "versioned-app-v0", - "dependencies": { - "leaf": "1.0.0" - } - } - }, - "packages": { - "leaf": [ - "leaf@1.0.0", - "", - {}, - "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" - ] - } -} diff --git a/test/providers/tst_manifests/bun/lockfile_v0/expected_component_sbom.json b/test/providers/tst_manifests/bun/lockfile_v0/expected_component_sbom.json deleted file mode 100644 index 17f40aff..00000000 --- a/test/providers/tst_manifests/bun/lockfile_v0/expected_component_sbom.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "bomFormat": "CycloneDX", - "specVersion": "1.6", - "version": 1, - "metadata": { - "timestamp": "2023-08-07T00:00:00.000Z", - "component": { - "name": "versioned-app-v0", - "version": "1.0.0", - "purl": "pkg:npm/versioned-app-v0@1.0.0", - "type": "application", - "bom-ref": "pkg:npm/versioned-app-v0@1.0.0" - }, - "tools": { - "components": [ - { - "type": "application", - "name": "trustify-da-javascript-client", - "version": "0.4.0" - } - ] - } - }, - "components": [ - { - "name": "leaf", - "version": "1.0.0", - "purl": "pkg:npm/leaf@1.0.0", - "type": "library", - "bom-ref": "pkg:npm/leaf@1.0.0", - "hashes": [ - { - "alg": "SHA-512", - "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" - } - ] - } - ], - "dependencies": [ - { - "ref": "pkg:npm/versioned-app-v0@1.0.0", - "dependsOn": [ - "pkg:npm/leaf@1.0.0" - ] - }, - { - "ref": "pkg:npm/leaf@1.0.0", - "dependsOn": [] - } - ] -} diff --git a/test/providers/tst_manifests/bun/lockfile_v0/expected_stack_sbom.json b/test/providers/tst_manifests/bun/lockfile_v0/expected_stack_sbom.json deleted file mode 100644 index 17f40aff..00000000 --- a/test/providers/tst_manifests/bun/lockfile_v0/expected_stack_sbom.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "bomFormat": "CycloneDX", - "specVersion": "1.6", - "version": 1, - "metadata": { - "timestamp": "2023-08-07T00:00:00.000Z", - "component": { - "name": "versioned-app-v0", - "version": "1.0.0", - "purl": "pkg:npm/versioned-app-v0@1.0.0", - "type": "application", - "bom-ref": "pkg:npm/versioned-app-v0@1.0.0" - }, - "tools": { - "components": [ - { - "type": "application", - "name": "trustify-da-javascript-client", - "version": "0.4.0" - } - ] - } - }, - "components": [ - { - "name": "leaf", - "version": "1.0.0", - "purl": "pkg:npm/leaf@1.0.0", - "type": "library", - "bom-ref": "pkg:npm/leaf@1.0.0", - "hashes": [ - { - "alg": "SHA-512", - "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" - } - ] - } - ], - "dependencies": [ - { - "ref": "pkg:npm/versioned-app-v0@1.0.0", - "dependsOn": [ - "pkg:npm/leaf@1.0.0" - ] - }, - { - "ref": "pkg:npm/leaf@1.0.0", - "dependsOn": [] - } - ] -} diff --git a/test/providers/tst_manifests/bun/lockfile_v0/package.json b/test/providers/tst_manifests/bun/lockfile_v0/package.json deleted file mode 100644 index 6a9a0de7..00000000 --- a/test/providers/tst_manifests/bun/lockfile_v0/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "versioned-app-v0", - "version": "1.0.0", - "dependencies": { - "leaf": "1.0.0" - } -} diff --git a/test/providers/tst_manifests/bun/lockfile_v1/bun.lock b/test/providers/tst_manifests/bun/lockfile_v1/bun.lock deleted file mode 100644 index 9ac0ba6a..00000000 --- a/test/providers/tst_manifests/bun/lockfile_v1/bun.lock +++ /dev/null @@ -1,20 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "versioned-app-v1", - "dependencies": { - "leaf": "1.0.0" - } - } - }, - "packages": { - "leaf": [ - "leaf@1.0.0", - "", - {}, - "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" - ] - } -} diff --git a/test/providers/tst_manifests/bun/lockfile_v1/expected_component_sbom.json b/test/providers/tst_manifests/bun/lockfile_v1/expected_component_sbom.json deleted file mode 100644 index 102ebd12..00000000 --- a/test/providers/tst_manifests/bun/lockfile_v1/expected_component_sbom.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "bomFormat": "CycloneDX", - "specVersion": "1.6", - "version": 1, - "metadata": { - "timestamp": "2023-08-07T00:00:00.000Z", - "component": { - "name": "versioned-app-v1", - "version": "1.0.0", - "purl": "pkg:npm/versioned-app-v1@1.0.0", - "type": "application", - "bom-ref": "pkg:npm/versioned-app-v1@1.0.0" - }, - "tools": { - "components": [ - { - "type": "application", - "name": "trustify-da-javascript-client", - "version": "0.4.0" - } - ] - } - }, - "components": [ - { - "name": "leaf", - "version": "1.0.0", - "purl": "pkg:npm/leaf@1.0.0", - "type": "library", - "bom-ref": "pkg:npm/leaf@1.0.0", - "hashes": [ - { - "alg": "SHA-512", - "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" - } - ] - } - ], - "dependencies": [ - { - "ref": "pkg:npm/versioned-app-v1@1.0.0", - "dependsOn": [ - "pkg:npm/leaf@1.0.0" - ] - }, - { - "ref": "pkg:npm/leaf@1.0.0", - "dependsOn": [] - } - ] -} diff --git a/test/providers/tst_manifests/bun/lockfile_v1/expected_stack_sbom.json b/test/providers/tst_manifests/bun/lockfile_v1/expected_stack_sbom.json deleted file mode 100644 index 102ebd12..00000000 --- a/test/providers/tst_manifests/bun/lockfile_v1/expected_stack_sbom.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "bomFormat": "CycloneDX", - "specVersion": "1.6", - "version": 1, - "metadata": { - "timestamp": "2023-08-07T00:00:00.000Z", - "component": { - "name": "versioned-app-v1", - "version": "1.0.0", - "purl": "pkg:npm/versioned-app-v1@1.0.0", - "type": "application", - "bom-ref": "pkg:npm/versioned-app-v1@1.0.0" - }, - "tools": { - "components": [ - { - "type": "application", - "name": "trustify-da-javascript-client", - "version": "0.4.0" - } - ] - } - }, - "components": [ - { - "name": "leaf", - "version": "1.0.0", - "purl": "pkg:npm/leaf@1.0.0", - "type": "library", - "bom-ref": "pkg:npm/leaf@1.0.0", - "hashes": [ - { - "alg": "SHA-512", - "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" - } - ] - } - ], - "dependencies": [ - { - "ref": "pkg:npm/versioned-app-v1@1.0.0", - "dependsOn": [ - "pkg:npm/leaf@1.0.0" - ] - }, - { - "ref": "pkg:npm/leaf@1.0.0", - "dependsOn": [] - } - ] -} diff --git a/test/providers/tst_manifests/bun/lockfile_v1/package.json b/test/providers/tst_manifests/bun/lockfile_v1/package.json deleted file mode 100644 index 7320ce6f..00000000 --- a/test/providers/tst_manifests/bun/lockfile_v1/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "versioned-app-v1", - "version": "1.0.0", - "dependencies": { - "leaf": "1.0.0" - } -} diff --git a/test/providers/tst_manifests/bun/lockfile_v2/bun.lock b/test/providers/tst_manifests/bun/lockfile_v2/bun.lock deleted file mode 100644 index 76cb29c8..00000000 --- a/test/providers/tst_manifests/bun/lockfile_v2/bun.lock +++ /dev/null @@ -1,20 +0,0 @@ -{ - "lockfileVersion": 2, - "configVersion": 1, - "workspaces": { - "": { - "name": "versioned-app-v2", - "dependencies": { - "leaf": "1.0.0" - } - } - }, - "packages": { - "leaf": [ - "leaf@1.0.0", - "", - {}, - "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" - ] - } -} diff --git a/test/providers/tst_manifests/bun/lockfile_v2/expected_component_sbom.json b/test/providers/tst_manifests/bun/lockfile_v2/expected_component_sbom.json deleted file mode 100644 index 24a9dbb5..00000000 --- a/test/providers/tst_manifests/bun/lockfile_v2/expected_component_sbom.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "bomFormat": "CycloneDX", - "specVersion": "1.6", - "version": 1, - "metadata": { - "timestamp": "2023-08-07T00:00:00.000Z", - "component": { - "name": "versioned-app-v2", - "version": "1.0.0", - "purl": "pkg:npm/versioned-app-v2@1.0.0", - "type": "application", - "bom-ref": "pkg:npm/versioned-app-v2@1.0.0" - }, - "tools": { - "components": [ - { - "type": "application", - "name": "trustify-da-javascript-client", - "version": "0.4.0" - } - ] - } - }, - "components": [ - { - "name": "leaf", - "version": "1.0.0", - "purl": "pkg:npm/leaf@1.0.0", - "type": "library", - "bom-ref": "pkg:npm/leaf@1.0.0", - "hashes": [ - { - "alg": "SHA-512", - "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" - } - ] - } - ], - "dependencies": [ - { - "ref": "pkg:npm/versioned-app-v2@1.0.0", - "dependsOn": [ - "pkg:npm/leaf@1.0.0" - ] - }, - { - "ref": "pkg:npm/leaf@1.0.0", - "dependsOn": [] - } - ] -} diff --git a/test/providers/tst_manifests/bun/lockfile_v2/expected_stack_sbom.json b/test/providers/tst_manifests/bun/lockfile_v2/expected_stack_sbom.json deleted file mode 100644 index 24a9dbb5..00000000 --- a/test/providers/tst_manifests/bun/lockfile_v2/expected_stack_sbom.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "bomFormat": "CycloneDX", - "specVersion": "1.6", - "version": 1, - "metadata": { - "timestamp": "2023-08-07T00:00:00.000Z", - "component": { - "name": "versioned-app-v2", - "version": "1.0.0", - "purl": "pkg:npm/versioned-app-v2@1.0.0", - "type": "application", - "bom-ref": "pkg:npm/versioned-app-v2@1.0.0" - }, - "tools": { - "components": [ - { - "type": "application", - "name": "trustify-da-javascript-client", - "version": "0.4.0" - } - ] - } - }, - "components": [ - { - "name": "leaf", - "version": "1.0.0", - "purl": "pkg:npm/leaf@1.0.0", - "type": "library", - "bom-ref": "pkg:npm/leaf@1.0.0", - "hashes": [ - { - "alg": "SHA-512", - "content": "e350a27e483a7bc4f2952a5db53a5e2d532abd20445734edb47bc4443ef8d7ea6767c00dbf4d34e0c44be3740a3c394af5c1af369e8d6566540656c65d8c719e" - } - ] - } - ], - "dependencies": [ - { - "ref": "pkg:npm/versioned-app-v2@1.0.0", - "dependsOn": [ - "pkg:npm/leaf@1.0.0" - ] - }, - { - "ref": "pkg:npm/leaf@1.0.0", - "dependsOn": [] - } - ] -} diff --git a/test/providers/tst_manifests/bun/lockfile_v2/package.json b/test/providers/tst_manifests/bun/lockfile_v2/package.json deleted file mode 100644 index 5c284c69..00000000 --- a/test/providers/tst_manifests/bun/lockfile_v2/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "versioned-app-v2", - "version": "1.0.0", - "dependencies": { - "leaf": "1.0.0" - } -} From 18b2fbf4c26c178e9396df551ccdb67f7542f429 Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Mon, 26 Oct 2026 11:17:44 +0200 Subject: [PATCH 5/8] fix(bun): skip malformed lockfile entry instead of dropping all hashes _parseLockFileHashes iterated every bun.lock packages entry and let parseBunEntry throw on an id without '@'. Base_javascript._loadHashes caught that single throw and cleared the whole hash map, so provideComponent omitted integrity hashes even for valid direct dependencies while provideStack (which only parses reachable entries) kept them. Skip the malformed/unreachable entry per-entry so valid hashes survive and both SBOMs agree. Implements TC-7069 Co-Authored-By: Claude Opus 4.8 Assisted-by: Claude Code Signed-off-by: Adva Oren --- src/providers/javascript_bun.js | 12 +++++++++++- test/providers/javascript.test.js | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/providers/javascript_bun.js b/src/providers/javascript_bun.js index 68793c79..39c5105a 100644 --- a/src/providers/javascript_bun.js +++ b/src/providers/javascript_bun.js @@ -45,7 +45,17 @@ export default class Javascript_bun extends Base_javascript { if (!Array.isArray(entry) || typeof entry[0] !== 'string') { continue; } - const { name, version, integrity } = parseBunEntry(entry); + // Skip a malformed/unreachable entry (e.g. an id without `@`, which + // makes parseBunEntry throw) rather than aborting the whole scan and + // dropping valid direct-dependency hashes. Entries reachable from the + // manifest are still validated by readBunGraph (the stack path). + let parsed; + try { + parsed = parseBunEntry(entry); + } catch (_) { + continue; + } + const { name, version, integrity } = parsed; const hash = integrity ? sriToHash(integrity) : null; if (name && version && hash) { map.set(`${name}@${version}`, [hash]); diff --git a/test/providers/javascript.test.js b/test/providers/javascript.test.js index 675e558c..ecc6c7de 100644 --- a/test/providers/javascript.test.js +++ b/test/providers/javascript.test.js @@ -486,6 +486,23 @@ suite('bun lockfile-only analysis (TC-7014)', () => { } }); + /// Regression: one malformed/unreachable packages entry must not wipe all component-SBOM hashes. + test('keeps a valid dependency hash when another packages entry is malformed/unreachable', () => { + // Given a lockfile with a valid direct dependency plus an unreachable entry whose id has no `@` + const mp = project({ name: 'app', version: '1.0.0', dependencies: { leaf: '1.0.0' } }, + { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { leaf: '1.0.0' } } }, + packages: { leaf: ['leaf@1.0.0', '', {}, BUN_SRI], junknoatsign: ['junknoatsign', '', {}, BUN_SRI] } }); + + // When building both SBOMs + const componentSbom = component(mp); + const stackSbom = stack(mp); + const leafHashes = sbom => sbom.components.find(c => c.purl === 'pkg:npm/leaf@1.0.0')?.hashes; + + // Then the component path still reports leaf's hash and agrees with the stack path + expect(leafHashes(componentSbom)).to.deep.equal([{ alg: 'SHA-512', content: BUN_SRI_HEX }]); + expect(leafHashes(componentSbom)).to.deep.equal(leafHashes(stackSbom)); + }); + /// Regression: a nested resolution must keep its own version instead of collapsing onto the root. test('resolves nested versions distinctly instead of collapsing onto the top-level entry', () => { // Given two parents that need conflicting versions of a shared dependency From 9a4a68eb3fa2b3871011808dae2cc8fc1438bb8f Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Mon, 26 Oct 2026 15:13:17 +0200 Subject: [PATCH 6/8] fix(bun): surface bun.lockb migrate diagnostic through match() A bun.lockb-only project failed match() with the generic "requires a lock file" error because Bun only advertised bun.lock, so the migrate diagnostic was reachable only on direct provider calls. Let the base validateLockFile also match a provider-declared binary lockfile and have Bun declare bun.lockb, so match()/stackAnalysis/componentAnalysis select Bun and raise the actionable diagnostic. Generalize _findLockFileDir to accept lockfile names, replacing Bun's duplicated #binaryLockFileDir walk so the diagnostic honors TRUSTIFY_DA_WORKSPACE_DIR. Align the suggested command with the README. Implements TC-7199 Co-Authored-By: Claude Opus 4.8 Assisted-by: Claude Code Signed-off-by: Adva Oren --- src/providers/base_javascript.js | 26 +++++++++++++++--- src/providers/javascript_bun.js | 37 ++++++++----------------- test/providers/javascript.test.js | 45 ++++++++++++++++++++++++++++--- 3 files changed, 75 insertions(+), 33 deletions(-) diff --git a/src/providers/base_javascript.js b/src/providers/base_javascript.js index 80e7fdd4..70a7be9c 100644 --- a/src/providers/base_javascript.js +++ b/src/providers/base_javascript.js @@ -133,6 +133,17 @@ export default class Base_javascript { return [this._lockFileName()]; } + /** + * Returns the name of a binary lock file the provider cannot analyze but + * should still recognize during matching so it can surface an actionable + * migrate diagnostic (e.g. Bun's `bun.lockb`). Null means none. + * @returns {string|null} The binary lock file name, or null + * @protected + */ + _binaryLockFileName() { + return null; + } + /** * Returns the command name to use for the specific JS package manager * @returns {string} The command name @@ -266,11 +277,11 @@ export default class Base_javascript { return false } - _findLockFileDir(manifestDir, opts = {}) { + _findLockFileDir(manifestDir, opts = {}, names = this._lockFileNames()) { const workspaceDir = getCustom('TRUSTIFY_DA_WORKSPACE_DIR', null, opts) if (workspaceDir) { const dir = path.resolve(workspaceDir) - return this._lockFileNames().some(name => fs.existsSync(path.join(dir, name))) ? dir : null + return names.some(name => fs.existsSync(path.join(dir, name))) ? dir : null } let dir = path.resolve(manifestDir) @@ -279,7 +290,7 @@ export default class Base_javascript { do { dir = parent - if (this._lockFileNames().some(name => fs.existsSync(path.join(dir, name)))) { + if (names.some(name => fs.existsSync(path.join(dir, name)))) { return dir } @@ -299,7 +310,14 @@ export default class Base_javascript { * @returns {boolean} True if the lock file exists */ validateLockFile(manifestDir, opts = {}) { - return this._findLockFileDir(manifestDir, opts) !== null + if (this._findLockFileDir(manifestDir, opts) !== null) { + return true + } + // Also match on a binary lock file (e.g. bun.lockb) so the provider is + // selected and can raise its own actionable migrate diagnostic instead of + // the generic "requires a lock file" error from match(). + const binary = this._binaryLockFileName() + return binary !== null && this._findLockFileDir(manifestDir, opts, [binary]) !== null } /** diff --git a/src/providers/javascript_bun.js b/src/providers/javascript_bun.js index 39c5105a..1fecc944 100644 --- a/src/providers/javascript_bun.js +++ b/src/providers/javascript_bun.js @@ -10,6 +10,11 @@ export default class Javascript_bun extends Base_javascript { return "bun.lock"; } + /** The binary lock file Bun cannot analyze but recognizes to drive a migrate diagnostic. */ + _binaryLockFileName() { + return "bun.lockb"; + } + _cmdName() { return "bun"; } @@ -78,8 +83,12 @@ export default class Javascript_bun extends Base_javascript { const manifestDir = path.dirname(manifest.manifestPath); const lockDir = this._findLockFileDir(manifestDir, opts); if (!lockDir) { - if (this.#binaryLockFileDir(manifestDir)) { - throw new Error("Bun analysis found a binary 'bun.lockb' but no text 'bun.lock'. Migrate it with 'bun install --save-text-lockfile' (Bun >= 1.2) before scanning."); + if (this._findLockFileDir(manifestDir, opts, [this._binaryLockFileName()])) { + throw new Error( + "Bun analysis found a binary 'bun.lockb' but no text 'bun.lock'. " + + "Migrate it with 'bun install --save-text-lockfile --frozen-lockfile " + + "--lockfile-only' (Bun >= 1.2) before scanning." + ); } throw new Error('Bun analysis requires a lockfile. Generate bun.lock before scanning.'); } @@ -88,28 +97,4 @@ export default class Javascript_bun extends Base_javascript { this._loadHashes(lockDir); return graph; } - - /** - * Walks up from the manifest directory looking for a binary `bun.lockb`, - * stopping at the workspace root, to drive an actionable migrate diagnostic. - * @param {string} manifestDir - Directory containing the manifest file - * @returns {string|null} The directory containing bun.lockb, or null - * @private - */ - #binaryLockFileDir(manifestDir) { - let dir = path.resolve(manifestDir); - while (true) { - if (fs.existsSync(path.join(dir, 'bun.lockb'))) { - return dir; - } - if (this._isWorkspaceRoot(dir)) { - return null; - } - const parent = path.dirname(dir); - if (parent === dir) { - return null; - } - dir = parent; - } - } } diff --git a/test/providers/javascript.test.js b/test/providers/javascript.test.js index ecc6c7de..732a8477 100644 --- a/test/providers/javascript.test.js +++ b/test/providers/javascript.test.js @@ -939,12 +939,51 @@ suite('bun lockfile-only analysis (TC-7014)', () => { expect(() => stack(mp)).to.throw(/requires a lockfile/); }); - /// Verifies a binary bun.lockb (without a text bun.lock) yields an actionable migrate diagnostic. - test('rejects a binary bun.lockb with a migrate-to-bun.lock diagnostic', () => { + /// Verifies a binary bun.lockb reaches the migrate diagnostic through the public match() + /// path real callers use (stack/componentAnalysis), not only a direct provider call. + test('rejects a binary bun.lockb with a migrate diagnostic through match()', () => { + // Given a project whose only lockfile is a binary bun.lockb (no text bun.lock) const mp = path.join(root, 'package.json'); fs.writeFileSync(mp, JSON.stringify({ name: 'app', version: '1.0.0', dependencies: {} })); fs.writeFileSync(path.join(root, 'bun.lockb'), Buffer.from([0, 1, 2, 3])); - expect(() => stack(mp)).to.throw(/bun\.lockb[\s\S]*bun\.lock/); + + // When selecting a provider the way the public entry points do + const provider = match(mp, availableProviders); + + // Then match() picks Bun instead of failing with the generic "requires a lock file" + // error, and both analyses surface the actionable migrate diagnostic + expect(provider).to.be.instanceOf(Bun); + expect(() => provider.provideStack(mp)).to.throw(/bun\.lockb[\s\S]*bun\.lock/); + expect(() => provider.provideComponent(mp)).to.throw(/bun\.lockb[\s\S]*bun\.lock/); + }); + + /// Verifies the suggested migrate command matches the README so the fix is copy-pasteable. + test('suggests the README migrate command for a binary bun.lockb', () => { + const mp = path.join(root, 'package.json'); + fs.writeFileSync(mp, JSON.stringify({ name: 'app', version: '1.0.0', dependencies: {} })); + fs.writeFileSync(path.join(root, 'bun.lockb'), Buffer.from([0, 1, 2, 3])); + expect(() => match(mp, availableProviders).provideStack(mp)) + .to.throw('bun install --save-text-lockfile --frozen-lockfile --lockfile-only'); + }); + + /// Regression: the bun.lockb diagnostic must honor TRUSTIFY_DA_WORKSPACE_DIR (the old + /// #binaryLockFileDir walked from manifestDir and ignored opts entirely). + test('honors TRUSTIFY_DA_WORKSPACE_DIR for a binary bun.lockb at the workspace root', () => { + // Given a workspace member whose only lockfile is a binary bun.lockb at the workspace root + const memberDir = path.join(root, 'packages', 'module-a'); + fs.mkdirSync(memberDir, { recursive: true }); + const mp = path.join(memberDir, 'package.json'); + fs.writeFileSync(mp, JSON.stringify({ name: 'member', version: '1.0.0', dependencies: {} })); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'root', workspaces: ['packages/*'] })); + fs.writeFileSync(path.join(root, 'bun.lockb'), Buffer.from([0, 1, 2, 3])); + const opts = { TRUSTIFY_DA_WORKSPACE_DIR: root }; + + // When selecting and analyzing through match() with the workspace dir configured + const provider = match(mp, availableProviders, opts); + + // Then Bun is selected and the migrate diagnostic fires from the workspace root + expect(provider).to.be.instanceOf(Bun); + expect(() => provider.provideStack(mp, opts)).to.throw(/bun\.lockb[\s\S]*bun\.lock/); }); }); From 9b46906256482b4a20682ae3dd9d2caa3d027b45 Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Mon, 26 Oct 2026 15:43:28 +0200 Subject: [PATCH 7/8] test(javascript): reuse shared SBOM_CASES pattern for bun golden SBOMs Move the bun lockfile-only and golden-SBOM suites into test/javascript_lockfile.test.js so they reuse that file's shared SBOM_CASES, canonical() and snapshot() helpers instead of duplicating them in test/providers/javascript.test.js. The bun golden reference suite now iterates SBOM_CASES rather than an inline {method,type} list, matching CONVENTIONS.md Testing Conventions and the other provider suites. Implements TC-7200 Co-Authored-By: Claude Opus 4.8 Assisted-by: Claude Code Signed-off-by: Adva Oren --- test/javascript_lockfile.test.js | 587 +++++++++++++++++++++++++++++ test/providers/javascript.test.js | 603 ------------------------------ 2 files changed, 587 insertions(+), 603 deletions(-) diff --git a/test/javascript_lockfile.test.js b/test/javascript_lockfile.test.js index 60865785..ed9cd7d8 100644 --- a/test/javascript_lockfile.test.js +++ b/test/javascript_lockfile.test.js @@ -8,6 +8,8 @@ import { load as yamlLoad } from 'js-yaml'; import { useFakeTimers } from 'sinon'; import { getPackageVersion } from '../src/package_version.js'; +import { availableProviders, match } from '../src/provider.js'; +import Bun from '../src/providers/javascript_bun.js'; import Npm from '../src/providers/javascript_npm.js'; import Pnpm from '../src/providers/javascript_pnpm.js'; import Yarn from '../src/providers/javascript_yarn.js'; @@ -18,6 +20,10 @@ const SBOM_CASES = [ { method: 'provideComponent', type: 'component' }, ]; +/** A valid SHA-512 SRI (is-number@7.0.0) reused across bun lockfile fixtures. */ +const BUN_SRI = 'sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=='; +const BUN_SRI_HEX = Buffer.from(BUN_SRI.slice('sha512-'.length), 'base64').toString('hex'); + function canonical(sbom) { const data = typeof sbom === 'string' ? JSON.parse(sbom) : structuredClone(sbom); data.components.sort((a, b) => a.purl.localeCompare(b.purl)); @@ -961,3 +967,584 @@ suite('pnpm lockfile specifics', () => { expect(() => stack(manifestPath)).to.throw(/link protocol does not provide package dependency metadata/); }); }); + +suite('bun lockfile-only analysis (TC-7014)', () => { + let root; + setup(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'bun-lockfile-')); }); + teardown(() => { fs.rmSync(root, { recursive: true, force: true }); }); + + /** Writes a package.json, a bun.lock and any extra manifests, returning the manifest path. */ + function project(manifest, lock, { manifestPath = 'package.json', extra = {} } = {}) { + for (const [rel, content] of Object.entries(extra)) { + const p = path.join(root, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(content)); + } + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify(manifest)); + fs.writeFileSync(path.join(root, 'bun.lock'), typeof lock === 'string' ? lock : JSON.stringify(lock)); + return path.join(root, manifestPath); + } + const stack = mp => JSON.parse(new Bun().provideStack(mp).content); + const component = mp => JSON.parse(new Bun().provideComponent(mp).content); + const edge = (sbom, from, to) => sbom.dependencies.find(dep => dep.ref === `pkg:npm/${from}`)?.dependsOn.includes(`pkg:npm/${to}`); + + for (const method of ['provideStack', 'provideComponent']) { + /// Verifies bun analysis reads the graph without any subprocess or file writes (mirrors TC-6762). + test(`bun ${method} makes no subprocess calls and changes no project files`, async () => { + // Given a project with a bun.lock and a stale node_modules copy + const lock = { lockfileVersion: 2, configVersion: 1, + workspaces: { '': { name: 'app', dependencies: { alpha: '1.0.0' } } }, + packages: { alpha: ['alpha@1.0.0', '', { dependencies: { shared: '^1.0.0' } }, BUN_SRI], shared: ['shared@1.0.0', '', {}, BUN_SRI] } }; + const mp = project({ name: 'app', version: '1.0.0', dependencies: { alpha: '1.0.0' } }, lock); + fs.mkdirSync(path.join(root, 'node_modules', 'alpha'), { recursive: true }); + fs.writeFileSync(path.join(root, 'node_modules', 'alpha', 'package.json'), '{"version":"99.0.0"}'); + const before = snapshot(root); + + // When analyzing with every subprocess path wired to throw + const fail = () => { throw new Error('Unexpected subprocess invocation'); }; + const Provider = await esmock('../src/providers/javascript_bun.js', {}, { + '../src/tools.js': { invokeCommand: fail }, + child_process: { execFileSync: fail, spawnSync: fail }, + }); + const provided = new Provider()[method](mp); + + // Then the SBOM comes from the lockfile and the project tree is untouched + expect(JSON.parse(provided.content).components.some(c => c.purl === 'pkg:npm/alpha@1.0.0')).to.be.true; + expect(provided.ecosystem).to.equal('npm'); + expect(snapshot(root)).to.deep.equal(before); + }); + } + + /// Regression: aliases must report the npm-target identity, not the alias name. + test('reports the npm-target identity and hash for aliases, not the alias name', () => { + const mp = project({ name: 'app', version: '1.0.0', dependencies: { myalias: 'npm:is-number@7.0.0' } }, + { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { myalias: 'npm:is-number@7.0.0' } } }, + packages: { myalias: ['is-number@7.0.0', '', {}, BUN_SRI] } }); + for (const read of [stack, component]) { + const sbom = read(mp); + expect(sbom.components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@7.0.0']); + expect(sbom.components[0].hashes).to.deep.equal([{ alg: 'SHA-512', content: BUN_SRI_HEX }]); + } + }); + + /// Regression: one malformed/unreachable packages entry must not wipe all component-SBOM hashes. + test('keeps a valid dependency hash when another packages entry is malformed/unreachable', () => { + // Given a lockfile with a valid direct dependency plus an unreachable entry whose id has no `@` + const mp = project({ name: 'app', version: '1.0.0', dependencies: { leaf: '1.0.0' } }, + { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { leaf: '1.0.0' } } }, + packages: { leaf: ['leaf@1.0.0', '', {}, BUN_SRI], junknoatsign: ['junknoatsign', '', {}, BUN_SRI] } }); + + // When building both SBOMs + const componentSbom = component(mp); + const stackSbom = stack(mp); + const leafHashes = sbom => sbom.components.find(c => c.purl === 'pkg:npm/leaf@1.0.0')?.hashes; + + // Then the component path still reports leaf's hash and agrees with the stack path + expect(leafHashes(componentSbom)).to.deep.equal([{ alg: 'SHA-512', content: BUN_SRI_HEX }]); + expect(leafHashes(componentSbom)).to.deep.equal(leafHashes(stackSbom)); + }); + + /// Regression: a nested resolution must keep its own version instead of collapsing onto the root. + test('resolves nested versions distinctly instead of collapsing onto the top-level entry', () => { + // Given two parents that need conflicting versions of a shared dependency + const mp = project({ name: 'app', version: '1.0.0', dependencies: { alpha: '1.0.0', beta: '1.0.0' } }, + { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { alpha: '1.0.0', beta: '1.0.0' } } }, + packages: { + alpha: ['alpha@1.0.0', '', { dependencies: { shared: '^1.0.0' } }, BUN_SRI], + beta: ['beta@1.0.0', '', { dependencies: { shared: '^2.0.0' } }, BUN_SRI], + shared: ['shared@1.0.0', '', {}, BUN_SRI], + 'beta/shared': ['shared@2.0.0', '', {}, BUN_SRI], + } }); + + // When building the SBOM + const sbom = stack(mp); + + // Then each parent keeps its own resolved version of the shared dependency + expect(edge(sbom, 'alpha@1.0.0', 'shared@1.0.0')).to.be.true; + expect(edge(sbom, 'beta@1.0.0', 'shared@2.0.0')).to.be.true; + expect(sbom.components.filter(c => c.name === 'shared').map(c => c.version).sort()).to.deep.equal(['1.0.0', '2.0.0']); + }); + + for (const ancestor of ['', 'ancestor', '@outer/ancestor']) { + const prefix = ancestor ? `${ancestor}/` : ''; + const owner = `${prefix}@scope/parent`; + test(`keeps scoped package names intact when resolving dependencies of ${owner}`, () => { + const dependencies = ancestor ? { [ancestor]: '1.0.0' } : { '@scope/parent': 'npm:is-odd@3.0.1' }; + const packages = { + [owner]: ['is-odd@3.0.1', '', { dependencies: { 'is-number': '^6.0.0' } }, BUN_SRI], + [`${prefix}@scope/is-number`]: ['kind-of@6.0.0', '', {}, BUN_SRI], + [`${prefix}is-number`]: ['is-number@6.0.0', '', {}, BUN_SRI], + }; + if (ancestor) { + packages[ancestor] = [`${ancestor}@1.0.0`, '', { dependencies: { '@scope/parent': 'npm:is-odd@3.0.1' } }, BUN_SRI]; + packages['is-number'] = ['is-number@7.0.0', '', {}, BUN_SRI]; + } + const mp = project({ name: 'app', version: '1.0.0', dependencies }, + { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies } }, packages }); + + const sbom = stack(mp); + + expect(edge(sbom, 'is-odd@3.0.1', 'is-number@6.0.0')).to.be.true; + expect(sbom.components.some(c => c.name === 'kind-of')).to.be.false; + }); + } + + /// Regression: a workspace member reports its real version and dependencies, not a `workspace:` pseudo-version. + test('reports a workspace member\'s real identity and dependencies', () => { + const mp = project({ name: 'ws-root', private: true, workspaces: ['packages/member'] }, + { lockfileVersion: 2, configVersion: 1, + workspaces: { '': { name: 'ws-root' }, 'packages/member': { name: 'member', version: '2.0.0', dependencies: { alpha: '1.0.0' } } }, + packages: { alpha: ['alpha@1.0.0', '', {}, BUN_SRI], member: ['member@workspace:packages/member'] } }, + { manifestPath: 'packages/member/package.json', extra: { 'packages/member/package.json': { name: 'member', version: '2.0.0', dependencies: { alpha: '1.0.0' } } } }); + const sbom = stack(mp); + expect(sbom.metadata.component.purl).to.equal('pkg:npm/member@2.0.0'); + expect(edge(sbom, 'member@2.0.0', 'alpha@1.0.0')).to.be.true; + }); + + for (const [memberName, reference] of [['member', 'member'], ['@scope/member', '@scope/member'], ['member', 'alias']]) { + for (const nested of [true, false]) { + test(`resolves ${nested ? 'nested' : 'shared'} dependencies in workspace context for ${reference}`, () => { + const memberVersion = nested ? '7.0.0' : '6.0.0'; + const dependencies = { 'is-number': '6.0.0', [reference]: 'workspace:*' }; + const memberDependencies = { 'is-number': memberVersion }; + const packages = { + 'is-number': ['is-number@6.0.0', '', {}, BUN_SRI], + [memberName]: [`${memberName}@workspace:packages/member`], + }; + if (reference !== memberName) { packages[reference] = packages[memberName]; } + if (nested) { + packages[`${memberName}/is-number`] = ['is-number@7.0.0', '', {}, BUN_SRI]; + if (reference !== memberName) { packages[`${reference}/is-number`] = packages[`${memberName}/is-number`]; } + } + const mp = project({ name: 'app', version: '1.0.0', workspaces: ['packages/member'], dependencies }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies }, + 'packages/member': { name: memberName, version: '2.0.0', dependencies: memberDependencies } }, packages }, + { extra: { 'packages/member/package.json': { name: memberName, version: '2.0.0', dependencies: memberDependencies } } }); + const before = snapshot(root); + const memberId = `${memberName.replace('@', '%40')}@2.0.0`; + + const rootStack = stack(mp); + expect(edge(rootStack, 'app@1.0.0', 'is-number@6.0.0')).to.be.true; + expect(edge(rootStack, memberId, `is-number@${memberVersion}`)).to.be.true; + expect(component(mp).components.map(c => c.purl).sort()).to.deep.equal([`pkg:npm/${memberId}`, 'pkg:npm/is-number@6.0.0'].sort()); + for (const read of [stack, component]) { + const memberSbom = read(path.join(root, 'packages/member/package.json')); + expect(memberSbom.components.map(c => c.purl)).to.deep.equal([`pkg:npm/is-number@${memberVersion}`]); + } + expect(snapshot(root)).to.deep.equal(before); + }); + } + } + + for (const [range, version, compatible] of [ + ['^1.0.0', '2.0.0', false], + ['~1.0.0', '1.1.0', false], + ['1.0.0', '2.0.0', false], + ['^1.0.0', '1.1.0', true], + ['*', '2.0.0', true], + ['*', '2.0.0-beta.1', false], + ['workspace:^1.0.0', '2.0.0', false], + ['workspace:~1.0.0', '1.1.0', false], + ['workspace:1.0.0', '2.0.0', false], + ['workspace:^1.0.0', '1.1.0', true], + ['workspace:*', '2.0.0', true], + ['workspace:*', '2.0.0-beta.1', true], + ['workspace:*', undefined, true], + ['workspace:^', '2.0.0-beta.1', true], + ['workspace:~', '2.0.0-beta.1', true], + ]) { + test(`${range} ${compatible ? 'accepts' : 'rejects'} local version ${version ?? 'unspecified'}`, () => { + const dependencies = { local: range }; + const localDependencies = { alpha: '1.0.0' }; + const mp = project({ name: 'app', version: '1.0.0', workspaces: ['packages/local'], dependencies }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies }, + 'packages/local': { name: 'local', version: '1.0.0', dependencies: localDependencies } }, + packages: { local: ['local@workspace:packages/local'], alpha: ['alpha@1.0.0', '', {}, BUN_SRI] } }, + { extra: { 'packages/local/package.json': { name: 'local', version, dependencies: localDependencies } } }); + const before = snapshot(root); + for (const read of [stack, component]) { + if (compatible) { + expect(read(mp).components.find(c => c.name === 'local').version).to.equal(version ?? 'v0.0.0'); + } else { + expect(() => read(mp)).to.throw(`requires ${range} but resolves to ${version}`); + } + } + expect(snapshot(root)).to.deep.equal(before); + }); + } + + test('rejects stale workspace dependency declarations even with workspace:*', () => { + const dependencies = { local: 'workspace:*' }; + const mp = project({ name: 'app', version: '1.0.0', workspaces: ['packages/local'], dependencies }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies }, + 'packages/local': { name: 'local', version: '1.0.0', dependencies: { alpha: '1.0.0' } } }, + packages: { local: ['local@workspace:packages/local'], alpha: ['alpha@1.0.0', '', {}, BUN_SRI] } }, + { extra: { 'packages/local/package.json': { name: 'local', version: '2.0.0', dependencies: { alpha: '2.0.0' } } } }); + for (const read of [stack, component]) { + expect(() => read(mp)).to.throw(/out of date.*alpha.*dependencies declaration/); + } + }); + + for (const [fields, recordedFields, version] of [ + [['dependencies', 'optionalDependencies'], ['optionalDependencies'], '7.0.0'], + [['devDependencies', 'optionalDependencies'], ['optionalDependencies'], '7.0.0'], + [['dependencies', 'devDependencies', 'optionalDependencies'], ['devDependencies', 'optionalDependencies'], '6.0.0'], + [['peerDependencies', 'optionalDependencies'], ['peerDependencies', 'optionalDependencies'], '7.0.0'], + [['dependencies', 'peerDependencies', 'optionalDependencies'], ['peerDependencies', 'optionalDependencies'], '7.0.0'], + [['devDependencies', 'peerDependencies', 'optionalDependencies'], ['peerDependencies', 'optionalDependencies'], '7.0.0'], + [['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'], ['devDependencies', 'peerDependencies', 'optionalDependencies'], '6.0.0'], + [['dependencies', 'devDependencies'], ['dependencies', 'devDependencies'], '6.0.0'], + ]) { + test(`normalizes Bun optional precedence for ${fields.join(' + ')}`, () => { + const declarations = Object.fromEntries(fields.map(field => [field, { 'is-number': field === 'optionalDependencies' ? '7.0.0' : '6.0.0' }])); + const recorded = Object.fromEntries(recordedFields.map(field => [field, declarations[field]])); + const mp = project({ name: 'app', version: '1.0.0', ...declarations }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', ...recorded } }, + packages: { 'is-number': [`is-number@${version}`, '', {}, BUN_SRI] } }); + const before = snapshot(root); + for (const read of [stack, component]) { + expect(read(mp).components.map(c => c.purl)).to.deep.equal([`pkg:npm/is-number@${version}`]); + } + expect(snapshot(root)).to.deep.equal(before); + }); + } + + test('rejects a Bun resolution outside both retained production and development ranges', () => { + const declarations = { devDependencies: { 'is-number': '6.0.0' }, optionalDependencies: { 'is-number': '7.0.0' } }; + const mp = project({ name: 'app', version: '1.0.0', dependencies: { 'is-number': '6.0.0' }, ...declarations }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', ...declarations } }, + packages: { 'is-number': ['is-number@5.0.0', '', {}, BUN_SRI] } }); + for (const read of [stack, component]) { + expect(() => read(mp)).to.throw(/requires 7\.0\.0 \|\| 6\.0\.0 but resolves to 5\.0\.0/); + } + }); + + test('normalizes Bun optional precedence in workspace member snapshots', () => { + const declarations = { dependencies: { 'is-number': '6.0.0' }, optionalDependencies: { 'is-number': '7.0.0' } }; + const mp = project({ name: 'app', version: '1.0.0', workspaces: ['packages/member'] }, + { lockfileVersion: 2, workspaces: { '': { name: 'app' }, + 'packages/member': { name: 'member', version: '1.0.0', optionalDependencies: declarations.optionalDependencies } }, + packages: { member: ['member@workspace:packages/member'], 'is-number': ['is-number@7.0.0', '', {}, BUN_SRI] } }, + { manifestPath: 'packages/member/package.json', extra: { 'packages/member/package.json': { name: 'member', version: '1.0.0', ...declarations } } }); + for (const read of [stack, component]) { + expect(read(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@7.0.0']); + } + }); + + for (const [change, declarations] of [ + ['optional version', { optionalDependencies: { 'is-number': '6.0.0' } }], + ['optional removal', { optionalDependencies: undefined }], + ['dependency classification', { dependencies: { 'is-number': '7.0.0' }, optionalDependencies: undefined }], + ['retained development version', { devDependencies: { 'is-number': '7.0.0' } }], + ['peer version', { peerDependencies: { 'is-number': '7.0.0' } }], + ]) { + test(`rejects stale Bun dependency snapshots after changing ${change}`, () => { + const original = { dependencies: { 'is-number': '6.0.0' }, devDependencies: { 'is-number': '6.0.0' }, + peerDependencies: { 'is-number': '6.0.0' }, optionalDependencies: { 'is-number': '7.0.0' } }; + const mp = project({ name: 'app', version: '1.0.0', ...original, ...declarations }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', devDependencies: original.devDependencies, + peerDependencies: original.peerDependencies, optionalDependencies: original.optionalDependencies } }, + packages: { 'is-number': ['is-number@6.0.0', '', {}, BUN_SRI] } }); + const before = snapshot(root); + for (const read of [stack, component]) { + expect(() => read(mp)).to.throw(/out of date/); + } + expect(snapshot(root)).to.deep.equal(before); + }); + } + + test('accepts changes to a shadowed Bun production dependency declaration', () => { + const mp = project({ name: 'app', version: '1.0.0', dependencies: { 'is-number': '^6.0.0' }, optionalDependencies: { 'is-number': '7.0.0' } }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', optionalDependencies: { 'is-number': '7.0.0' } } }, + packages: { 'is-number': ['is-number@7.0.0', '', {}, BUN_SRI] } }); + expect(stack(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@7.0.0']); + }); + + test('rejects malformed Bun dependency maps even when an optional dependency overrides them', () => { + const mp = project({ name: 'app', version: '1.0.0', dependencies: { 'is-number': 6 }, optionalDependencies: { 'is-number': '7.0.0' } }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', optionalDependencies: { 'is-number': '7.0.0' } } }, + packages: { 'is-number': ['is-number@7.0.0', '', {}, BUN_SRI] } }); + expect(() => stack(mp)).to.throw(/Invalid dependencies/); + }); + + for (const field of ['dependencies', 'optionalDependencies']) { + for (const [label, value] of [ + ['array', ['beta']], ['string', 'beta'], ['number', 1], ['boolean', false], ['null', null], + ['non-string specification', { beta: 2 }], ['null specification', { beta: null }], ['empty specification', { beta: '' }], + ]) { + test(`rejects Bun transitive ${field} with ${label} metadata`, () => { + const dependencies = { alpha: '1.0.0' }; + const mp = project({ name: 'app', version: '1.0.0', dependencies }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, + packages: { + alpha: ['alpha@1.0.0', '', { [field]: value }, BUN_SRI], + beta: ['beta@2.0.0', '', {}, BUN_SRI], + } }); + const before = snapshot(root); + for (const read of [stack, component]) { + expect(() => read(mp)).to.throw(new RegExp(`Invalid ${field}.*bun\\.lock`)); + } + expect(snapshot(root)).to.deep.equal(before); + }); + } + } + + /// Preserves resolvable optional branches while skipping absent optional dependencies. + test('preserves resolvable optional branches and skips absent ones', () => { + const mp = project({ name: 'app', version: '1.0.0', dependencies: { host: '1.0.0' } }, + { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { host: '1.0.0' } } }, + packages: { + host: ['host@1.0.0', '', { optionalDependencies: { 'present-opt': '^1.0.0', 'absent-opt': '^1.0.0' } }, BUN_SRI], + 'present-opt': ['present-opt@1.0.0', '', {}, BUN_SRI], + } }); + const sbom = stack(mp); + expect(edge(sbom, 'host@1.0.0', 'present-opt@1.0.0')).to.be.true; + expect(sbom.components.some(c => c.name === 'absent-opt')).to.be.false; + }); + + /// Regression: a missing required resolution is an error, not a silent omission. + test('rejects a missing required resolution instead of silently dropping it', () => { + const mp = project({ name: 'app', version: '1.0.0', dependencies: { alpha: '1.0.0' } }, + { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { alpha: '1.0.0' } } }, + packages: { alpha: ['alpha@1.0.0', '', { dependencies: { missing: '^1.0.0' } }, BUN_SRI] } }); + expect(() => stack(mp)).to.throw(/out of date.*missing/); + }); + + for (const [field, key] of [['overrides', 'is-number'], ['resolutions', 'is-number'], ['resolutions', '**/is-number']]) { + test(`uses the effective Bun override from ${field}.${key}`, () => { + const dependencies = { 'is-number': '6.0.0' }; + const mp = project({ name: 'app', version: '1.0.0', dependencies, [field]: { [key]: '7.0.0' } }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, + overrides: { 'is-number': '7.0.0' }, packages: { 'is-number': ['is-number@7.0.0', '', {}, BUN_SRI] } }); + for (const read of [stack, component]) { + expect(read(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@7.0.0']); + } + }); + } + + for (const [change, declared, recorded] of [ + ['changed', { 'is-number': '7.0.0' }, { 'is-number': '6.0.0' }], + ['added', { 'is-number': '7.0.0' }, {}], + ['removed', {}, { 'is-number': '6.0.0' }], + ]) { + test(`rejects ${change} Bun overrides without changing project files`, () => { + const dependencies = { 'is-number': '*' }; + const mp = project({ name: 'app', version: '1.0.0', dependencies, overrides: declared }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, + overrides: recorded, packages: { 'is-number': ['is-number@6.0.0', '', {}, BUN_SRI] } }); + const before = snapshot(root); + for (const read of [stack, component]) { + expect(() => read(mp)).to.throw(/out of date.*overrides/); + } + expect(snapshot(root)).to.deep.equal(before); + }); + } + + test('applies Bun overrides to transitive dependencies and rejects an incompatible locked version', () => { + const dependencies = { 'is-odd': '3.0.1' }; + const lock = { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, + overrides: { 'is-number': '7.0.0' }, packages: { + 'is-odd': ['is-odd@3.0.1', '', { dependencies: { 'is-number': '^6.0.0' } }, BUN_SRI], + 'is-number': ['is-number@7.0.0', '', {}, BUN_SRI], + } }; + const mp = project({ name: 'app', version: '1.0.0', dependencies, overrides: lock.overrides }, lock); + expect(edge(stack(mp), 'is-odd@3.0.1', 'is-number@7.0.0')).to.be.true; + + lock.packages['is-number'][0] = 'is-number@6.0.0'; + fs.writeFileSync(path.join(root, 'bun.lock'), JSON.stringify(lock)); + expect(() => stack(mp)).to.throw(/requires 7\.0\.0 but resolves to 6\.0\.0/); + }); + + test('normalizes Bun override dependency references to their recorded specifications', () => { + const dependencies = { 'is-number': '6.0.0', pin: 'npm:is-number@7.0.0' }; + const mp = project({ name: 'app', version: '1.0.0', dependencies, overrides: { 'is-number': '$pin' } }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, + overrides: { 'is-number': 'npm:is-number@7.0.0' }, packages: { + 'is-number': ['is-number@7.0.0', '', {}, BUN_SRI], pin: ['is-number@7.0.0', '', {}, BUN_SRI], + } }); + expect(stack(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@7.0.0']); + }); + + test('preserves npm aliases when a Bun override names the alias', () => { + const dependencies = { pick: 'npm:is-number@7.0.0' }; + const mp = project({ name: 'app', version: '1.0.0', dependencies, overrides: { pick: '6.0.0' } }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, + overrides: { pick: '6.0.0' }, packages: { pick: ['is-number@7.0.0', '', {}, BUN_SRI] } }); + expect(stack(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@7.0.0']); + }); + + test('reports the replacement package identity for a Bun override using an npm alias', () => { + const dependencies = { 'is-number': '6.0.0' }; + const overrides = { 'is-number': 'npm:kind-of@6.0.0' }; + const mp = project({ name: 'app', version: '1.0.0', dependencies, overrides }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, overrides, + packages: { 'is-number': ['kind-of@6.0.0', '', {}, BUN_SRI] } }); + for (const read of [stack, component]) { + expect(read(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/kind-of@6.0.0']); + } + }); + + test('rejects a Bun override reference to an undeclared dependency', () => { + const dependencies = { 'is-number': '*' }; + const mp = project({ name: 'app', version: '1.0.0', dependencies, overrides: { 'is-number': '$missing' } }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, + packages: { 'is-number': ['is-number@6.0.0', '', {}, BUN_SRI] } }); + expect(() => stack(mp)).to.throw(/override reference has no declared dependency/); + }); + + test('uses root Bun overrides when analyzing a workspace member', () => { + const dependencies = { 'is-number': '6.0.0' }; + const overrides = { 'is-number': '7.0.0' }; + const mp = project({ name: 'ws-root', private: true, workspaces: ['packages/member'], overrides }, + { lockfileVersion: 2, workspaces: { '': { name: 'ws-root' }, 'packages/member': { name: 'member', version: '1.0.0', dependencies } }, + overrides, packages: { member: ['member@workspace:packages/member'], 'is-number': ['is-number@7.0.0', '', {}, BUN_SRI] } }, + { manifestPath: 'packages/member/package.json', extra: { + 'packages/member/package.json': { name: 'member', version: '1.0.0', dependencies, overrides: { 'is-number': '6.0.0' } }, + } }); + expect(stack(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@7.0.0']); + }); + + test('ignores Bun resolutions when an overrides field is present', () => { + const dependencies = { 'is-number': '6.0.0' }; + const mp = project({ name: 'app', version: '1.0.0', dependencies, overrides: {}, resolutions: { 'is-number': '7.0.0' } }, + { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, + packages: { 'is-number': ['is-number@6.0.0', '', {}, BUN_SRI] } }); + expect(stack(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@6.0.0']); + }); + + /// Rejects a changed manifest declaration now that bun no longer re-syncs the lockfile. + test('rejects a changed manifest declaration without writing files', () => { + const lock = { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { alpha: '1.0.0' } } }, + packages: { alpha: ['alpha@1.0.0', '', {}, BUN_SRI] } }; + const mp = project({ name: 'app', version: '1.0.0', dependencies: { alpha: '1.0.0' } }, lock); + fs.writeFileSync(mp, JSON.stringify({ name: 'app', version: '1.0.0', dependencies: { alpha: '^2.0.0' } })); + const before = snapshot(root); + expect(() => stack(mp)).to.throw(/out of date.*alpha/); + expect(snapshot(root)).to.deep.equal(before); + }); + + for (const version of [0, 1, 2]) { + /// Verifies every real lockfileVersion (0, 1, 2) is supported. + test(`supports lockfileVersion ${version}`, () => { + const mp = project({ name: 'app', version: '1.0.0', dependencies: { leaf: '1.0.0' } }, + { lockfileVersion: version, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { leaf: '1.0.0' } } }, + packages: { leaf: ['leaf@1.0.0', '', {}, BUN_SRI] } }); + expect(stack(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/leaf@1.0.0']); + }); + } + + for (const version of [null, false, true, '', '0', '1', '2', [], [2], {}, undefined]) { + test(`rejects invalid lockfileVersion ${JSON.stringify(version)}`, () => { + const mp = project({ name: 'app', version: '1.0.0' }, + { lockfileVersion: version, workspaces: { '': { name: 'app' } }, packages: {} }); + const before = snapshot(root); + for (const read of [stack, component]) { + expect(() => read(mp)).to.throw(/Unsupported bun lockfile version/); + } + expect(snapshot(root)).to.deep.equal(before); + }); + } + + /// Verifies an unsupported lockfileVersion fails closed with an actionable error. + test('rejects an unsupported lockfile version', () => { + const mp = project({ name: 'app', version: '1.0.0', dependencies: {} }, + { lockfileVersion: 3, configVersion: 1, workspaces: { '': { name: 'app' } }, packages: {} }); + expect(() => stack(mp)).to.throw(/Unsupported bun lockfile version 3/); + }); + + /// Verifies hosted 3-tuple entries, empty-registry entries and SHA-1 integrity are all handled. + test('handles hosted 3-tuple entries, empty-registry entries and SHA-1 integrity', () => { + const sha1 = `sha1-${Buffer.from('ab'.repeat(20), 'hex').toString('base64')}`; + const mp = project({ name: 'app', version: '1.0.0', dependencies: { hosted: 'github:o/r#abc', reg: '1.0.0' } }, + { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { hosted: 'github:o/r#abc', reg: '1.0.0' } } }, + packages: { + hosted: ['hosted@github:o/r#abc', {}, sha1], + reg: ['reg@1.0.0', '', {}, BUN_SRI], + } }); + const sbom = stack(mp); + expect(sbom.components.some(c => c.name === 'hosted')).to.be.true; + expect(sbom.components.find(c => c.name === 'hosted').hashes).to.deep.equal([{ alg: 'SHA-1', content: 'ab'.repeat(20) }]); + expect(sbom.components.find(c => c.name === 'reg').hashes).to.deep.equal([{ alg: 'SHA-512', content: BUN_SRI_HEX }]); + }); + + /// Verifies a malformed bun.lock is rejected rather than silently yielding an empty graph. + test('rejects a malformed bun.lock', () => { + const mp = project({ name: 'app', version: '1.0.0', dependencies: {} }, '{ invalid'); + expect(() => stack(mp)).to.throw(/Cannot read/); + }); + + /// Verifies analysis requires a text lockfile when none is present. + test('requires a bun.lock when none is present', () => { + const mp = path.join(root, 'package.json'); + fs.writeFileSync(mp, JSON.stringify({ name: 'app', version: '1.0.0', dependencies: {} })); + expect(() => stack(mp)).to.throw(/requires a lockfile/); + }); + + /// Verifies a binary bun.lockb reaches the migrate diagnostic through the public match() + /// path real callers use (stack/componentAnalysis), not only a direct provider call. + test('rejects a binary bun.lockb with a migrate diagnostic through match()', () => { + // Given a project whose only lockfile is a binary bun.lockb (no text bun.lock) + const mp = path.join(root, 'package.json'); + fs.writeFileSync(mp, JSON.stringify({ name: 'app', version: '1.0.0', dependencies: {} })); + fs.writeFileSync(path.join(root, 'bun.lockb'), Buffer.from([0, 1, 2, 3])); + + // When selecting a provider the way the public entry points do + const provider = match(mp, availableProviders); + + // Then match() picks Bun instead of failing with the generic "requires a lock file" + // error, and both analyses surface the actionable migrate diagnostic + expect(provider).to.be.instanceOf(Bun); + expect(() => provider.provideStack(mp)).to.throw(/bun\.lockb[\s\S]*bun\.lock/); + expect(() => provider.provideComponent(mp)).to.throw(/bun\.lockb[\s\S]*bun\.lock/); + }); + + /// Verifies the suggested migrate command matches the README so the fix is copy-pasteable. + test('suggests the README migrate command for a binary bun.lockb', () => { + const mp = path.join(root, 'package.json'); + fs.writeFileSync(mp, JSON.stringify({ name: 'app', version: '1.0.0', dependencies: {} })); + fs.writeFileSync(path.join(root, 'bun.lockb'), Buffer.from([0, 1, 2, 3])); + expect(() => match(mp, availableProviders).provideStack(mp)) + .to.throw('bun install --save-text-lockfile --frozen-lockfile --lockfile-only'); + }); + + /// Regression: the bun.lockb diagnostic must honor TRUSTIFY_DA_WORKSPACE_DIR (the old + /// #binaryLockFileDir walked from manifestDir and ignored opts entirely). + test('honors TRUSTIFY_DA_WORKSPACE_DIR for a binary bun.lockb at the workspace root', () => { + // Given a workspace member whose only lockfile is a binary bun.lockb at the workspace root + const memberDir = path.join(root, 'packages', 'module-a'); + fs.mkdirSync(memberDir, { recursive: true }); + const mp = path.join(memberDir, 'package.json'); + fs.writeFileSync(mp, JSON.stringify({ name: 'member', version: '1.0.0', dependencies: {} })); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'root', workspaces: ['packages/*'] })); + fs.writeFileSync(path.join(root, 'bun.lockb'), Buffer.from([0, 1, 2, 3])); + const opts = { TRUSTIFY_DA_WORKSPACE_DIR: root }; + + // When selecting and analyzing through match() with the workspace dir configured + const provider = match(mp, availableProviders, opts); + + // Then Bun is selected and the migrate diagnostic fires from the workspace root + expect(provider).to.be.instanceOf(Bun); + expect(() => provider.provideStack(mp, opts)).to.throw(/bun\.lockb[\s\S]*bun\.lock/); + }); +}); + +suite('bun complete SBOM reference comparisons (TC-7014)', () => { + let refClock; + suiteSetup(() => { refClock = useFakeTimers(new Date('2023-08-07T00:00:00.000Z')); }); + suiteTeardown(() => { refClock.restore(); }); + const base = 'test/providers/tst_manifests/bun'; + const fixtures = ['alias', 'nested', 'optional', 'workspace']; + for (const fixture of fixtures) { + for (const { method, type } of SBOM_CASES) { + test(`bun ${fixture} ${type} matches every SBOM field`, () => { + const dir = `${base}/${fixture}`; + const manifest = `${dir}/${fixture === 'workspace' ? 'packages/member/package.json' : 'package.json'}`; + const actual = new Bun()[method](manifest); + const expected = JSON.parse(fs.readFileSync(`${dir}/expected_${type}_sbom.json`, 'utf8')); + expected.metadata.tools.components[0].version = getPackageVersion(); + expect(actual.contentType).to.equal('application/vnd.cyclonedx+json'); + expect(canonical(actual.content)).to.deep.equal(canonical(expected)); + }); + } + } +}); diff --git a/test/providers/javascript.test.js b/test/providers/javascript.test.js index 732a8477..a72f37e9 100644 --- a/test/providers/javascript.test.js +++ b/test/providers/javascript.test.js @@ -7,7 +7,6 @@ import { expect } from 'chai' import esmock from 'esmock'; import { useFakeTimers } from "sinon"; -import { getPackageVersion } from '../../src/package_version.js'; import { availableProviders, match } from '../../src/provider.js'; import { sriToHash } from '../../src/providers/base_javascript.js'; import Bun from '../../src/providers/javascript_bun.js'; @@ -16,27 +15,6 @@ import { compareSboms } from '../utils/sbom_utils.js'; let clock -/** A valid SHA-512 SRI (is-number@7.0.0) reused across bun lockfile fixtures. */ -const BUN_SRI = 'sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=='; -const BUN_SRI_HEX = Buffer.from(BUN_SRI.slice('sha512-'.length), 'base64').toString('hex'); - -/** Sorts components and dependency edges so two SBOMs compare order-independently. */ -function canonical(sbom) { - const data = typeof sbom === 'string' ? JSON.parse(sbom) : structuredClone(sbom); - data.components.sort((a, b) => a.purl.localeCompare(b.purl)); - data.dependencies?.forEach(dep => dep.dependsOn.sort()); - data.dependencies?.sort((a, b) => a.ref.localeCompare(b.ref)); - return data; -} - -/** Captures a directory's file contents so a run can be shown to leave it untouched. */ -function snapshot(dir) { - return fs.readdirSync(dir, { recursive: true }).sort().map(file => { - const full = path.join(dir, file); - return [file, fs.statSync(full).isDirectory() ? null : fs.readFileSync(full).toString('base64')]; - }); -} - async function mockProvider(providerName, listingOutput, version) { if (providerName === 'npm' || providerName === 'yarn') { return (await import(`../../src/providers/javascript_${providerName}.js`)).default; @@ -426,584 +404,3 @@ suite('lock-file hash extraction (TC-5548)', () => { }); }); }); - -suite('bun lockfile-only analysis (TC-7014)', () => { - let root; - setup(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'bun-lockfile-')); }); - teardown(() => { fs.rmSync(root, { recursive: true, force: true }); }); - - /** Writes a package.json, a bun.lock and any extra manifests, returning the manifest path. */ - function project(manifest, lock, { manifestPath = 'package.json', extra = {} } = {}) { - for (const [rel, content] of Object.entries(extra)) { - const p = path.join(root, rel); - fs.mkdirSync(path.dirname(p), { recursive: true }); - fs.writeFileSync(p, JSON.stringify(content)); - } - fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify(manifest)); - fs.writeFileSync(path.join(root, 'bun.lock'), typeof lock === 'string' ? lock : JSON.stringify(lock)); - return path.join(root, manifestPath); - } - const stack = mp => JSON.parse(new Bun().provideStack(mp).content); - const component = mp => JSON.parse(new Bun().provideComponent(mp).content); - const edge = (sbom, from, to) => sbom.dependencies.find(dep => dep.ref === `pkg:npm/${from}`)?.dependsOn.includes(`pkg:npm/${to}`); - - for (const method of ['provideStack', 'provideComponent']) { - /// Verifies bun analysis reads the graph without any subprocess or file writes (mirrors TC-6762). - test(`bun ${method} makes no subprocess calls and changes no project files`, async () => { - // Given a project with a bun.lock and a stale node_modules copy - const lock = { lockfileVersion: 2, configVersion: 1, - workspaces: { '': { name: 'app', dependencies: { alpha: '1.0.0' } } }, - packages: { alpha: ['alpha@1.0.0', '', { dependencies: { shared: '^1.0.0' } }, BUN_SRI], shared: ['shared@1.0.0', '', {}, BUN_SRI] } }; - const mp = project({ name: 'app', version: '1.0.0', dependencies: { alpha: '1.0.0' } }, lock); - fs.mkdirSync(path.join(root, 'node_modules', 'alpha'), { recursive: true }); - fs.writeFileSync(path.join(root, 'node_modules', 'alpha', 'package.json'), '{"version":"99.0.0"}'); - const before = snapshot(root); - - // When analyzing with every subprocess path wired to throw - const fail = () => { throw new Error('Unexpected subprocess invocation'); }; - const Provider = await esmock('../../src/providers/javascript_bun.js', {}, { - '../../src/tools.js': { invokeCommand: fail }, - child_process: { execFileSync: fail, spawnSync: fail }, - }); - const provided = new Provider()[method](mp); - - // Then the SBOM comes from the lockfile and the project tree is untouched - expect(JSON.parse(provided.content).components.some(c => c.purl === 'pkg:npm/alpha@1.0.0')).to.be.true; - expect(provided.ecosystem).to.equal('npm'); - expect(snapshot(root)).to.deep.equal(before); - }); - } - - /// Regression: aliases must report the npm-target identity, not the alias name. - test('reports the npm-target identity and hash for aliases, not the alias name', () => { - const mp = project({ name: 'app', version: '1.0.0', dependencies: { myalias: 'npm:is-number@7.0.0' } }, - { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { myalias: 'npm:is-number@7.0.0' } } }, - packages: { myalias: ['is-number@7.0.0', '', {}, BUN_SRI] } }); - for (const read of [stack, component]) { - const sbom = read(mp); - expect(sbom.components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@7.0.0']); - expect(sbom.components[0].hashes).to.deep.equal([{ alg: 'SHA-512', content: BUN_SRI_HEX }]); - } - }); - - /// Regression: one malformed/unreachable packages entry must not wipe all component-SBOM hashes. - test('keeps a valid dependency hash when another packages entry is malformed/unreachable', () => { - // Given a lockfile with a valid direct dependency plus an unreachable entry whose id has no `@` - const mp = project({ name: 'app', version: '1.0.0', dependencies: { leaf: '1.0.0' } }, - { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { leaf: '1.0.0' } } }, - packages: { leaf: ['leaf@1.0.0', '', {}, BUN_SRI], junknoatsign: ['junknoatsign', '', {}, BUN_SRI] } }); - - // When building both SBOMs - const componentSbom = component(mp); - const stackSbom = stack(mp); - const leafHashes = sbom => sbom.components.find(c => c.purl === 'pkg:npm/leaf@1.0.0')?.hashes; - - // Then the component path still reports leaf's hash and agrees with the stack path - expect(leafHashes(componentSbom)).to.deep.equal([{ alg: 'SHA-512', content: BUN_SRI_HEX }]); - expect(leafHashes(componentSbom)).to.deep.equal(leafHashes(stackSbom)); - }); - - /// Regression: a nested resolution must keep its own version instead of collapsing onto the root. - test('resolves nested versions distinctly instead of collapsing onto the top-level entry', () => { - // Given two parents that need conflicting versions of a shared dependency - const mp = project({ name: 'app', version: '1.0.0', dependencies: { alpha: '1.0.0', beta: '1.0.0' } }, - { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { alpha: '1.0.0', beta: '1.0.0' } } }, - packages: { - alpha: ['alpha@1.0.0', '', { dependencies: { shared: '^1.0.0' } }, BUN_SRI], - beta: ['beta@1.0.0', '', { dependencies: { shared: '^2.0.0' } }, BUN_SRI], - shared: ['shared@1.0.0', '', {}, BUN_SRI], - 'beta/shared': ['shared@2.0.0', '', {}, BUN_SRI], - } }); - - // When building the SBOM - const sbom = stack(mp); - - // Then each parent keeps its own resolved version of the shared dependency - expect(edge(sbom, 'alpha@1.0.0', 'shared@1.0.0')).to.be.true; - expect(edge(sbom, 'beta@1.0.0', 'shared@2.0.0')).to.be.true; - expect(sbom.components.filter(c => c.name === 'shared').map(c => c.version).sort()).to.deep.equal(['1.0.0', '2.0.0']); - }); - - for (const ancestor of ['', 'ancestor', '@outer/ancestor']) { - const prefix = ancestor ? `${ancestor}/` : ''; - const owner = `${prefix}@scope/parent`; - test(`keeps scoped package names intact when resolving dependencies of ${owner}`, () => { - const dependencies = ancestor ? { [ancestor]: '1.0.0' } : { '@scope/parent': 'npm:is-odd@3.0.1' }; - const packages = { - [owner]: ['is-odd@3.0.1', '', { dependencies: { 'is-number': '^6.0.0' } }, BUN_SRI], - [`${prefix}@scope/is-number`]: ['kind-of@6.0.0', '', {}, BUN_SRI], - [`${prefix}is-number`]: ['is-number@6.0.0', '', {}, BUN_SRI], - }; - if (ancestor) { - packages[ancestor] = [`${ancestor}@1.0.0`, '', { dependencies: { '@scope/parent': 'npm:is-odd@3.0.1' } }, BUN_SRI]; - packages['is-number'] = ['is-number@7.0.0', '', {}, BUN_SRI]; - } - const mp = project({ name: 'app', version: '1.0.0', dependencies }, - { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies } }, packages }); - - const sbom = stack(mp); - - expect(edge(sbom, 'is-odd@3.0.1', 'is-number@6.0.0')).to.be.true; - expect(sbom.components.some(c => c.name === 'kind-of')).to.be.false; - }); - } - - /// Regression: a workspace member reports its real version and dependencies, not a `workspace:` pseudo-version. - test('reports a workspace member\'s real identity and dependencies', () => { - const mp = project({ name: 'ws-root', private: true, workspaces: ['packages/member'] }, - { lockfileVersion: 2, configVersion: 1, - workspaces: { '': { name: 'ws-root' }, 'packages/member': { name: 'member', version: '2.0.0', dependencies: { alpha: '1.0.0' } } }, - packages: { alpha: ['alpha@1.0.0', '', {}, BUN_SRI], member: ['member@workspace:packages/member'] } }, - { manifestPath: 'packages/member/package.json', extra: { 'packages/member/package.json': { name: 'member', version: '2.0.0', dependencies: { alpha: '1.0.0' } } } }); - const sbom = stack(mp); - expect(sbom.metadata.component.purl).to.equal('pkg:npm/member@2.0.0'); - expect(edge(sbom, 'member@2.0.0', 'alpha@1.0.0')).to.be.true; - }); - - for (const [memberName, reference] of [['member', 'member'], ['@scope/member', '@scope/member'], ['member', 'alias']]) { - for (const nested of [true, false]) { - test(`resolves ${nested ? 'nested' : 'shared'} dependencies in workspace context for ${reference}`, () => { - const memberVersion = nested ? '7.0.0' : '6.0.0'; - const dependencies = { 'is-number': '6.0.0', [reference]: 'workspace:*' }; - const memberDependencies = { 'is-number': memberVersion }; - const packages = { - 'is-number': ['is-number@6.0.0', '', {}, BUN_SRI], - [memberName]: [`${memberName}@workspace:packages/member`], - }; - if (reference !== memberName) { packages[reference] = packages[memberName]; } - if (nested) { - packages[`${memberName}/is-number`] = ['is-number@7.0.0', '', {}, BUN_SRI]; - if (reference !== memberName) { packages[`${reference}/is-number`] = packages[`${memberName}/is-number`]; } - } - const mp = project({ name: 'app', version: '1.0.0', workspaces: ['packages/member'], dependencies }, - { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies }, - 'packages/member': { name: memberName, version: '2.0.0', dependencies: memberDependencies } }, packages }, - { extra: { 'packages/member/package.json': { name: memberName, version: '2.0.0', dependencies: memberDependencies } } }); - const before = snapshot(root); - const memberId = `${memberName.replace('@', '%40')}@2.0.0`; - - const rootStack = stack(mp); - expect(edge(rootStack, 'app@1.0.0', 'is-number@6.0.0')).to.be.true; - expect(edge(rootStack, memberId, `is-number@${memberVersion}`)).to.be.true; - expect(component(mp).components.map(c => c.purl).sort()).to.deep.equal([`pkg:npm/${memberId}`, 'pkg:npm/is-number@6.0.0'].sort()); - for (const read of [stack, component]) { - const memberSbom = read(path.join(root, 'packages/member/package.json')); - expect(memberSbom.components.map(c => c.purl)).to.deep.equal([`pkg:npm/is-number@${memberVersion}`]); - } - expect(snapshot(root)).to.deep.equal(before); - }); - } - } - - for (const [range, version, compatible] of [ - ['^1.0.0', '2.0.0', false], - ['~1.0.0', '1.1.0', false], - ['1.0.0', '2.0.0', false], - ['^1.0.0', '1.1.0', true], - ['*', '2.0.0', true], - ['*', '2.0.0-beta.1', false], - ['workspace:^1.0.0', '2.0.0', false], - ['workspace:~1.0.0', '1.1.0', false], - ['workspace:1.0.0', '2.0.0', false], - ['workspace:^1.0.0', '1.1.0', true], - ['workspace:*', '2.0.0', true], - ['workspace:*', '2.0.0-beta.1', true], - ['workspace:*', undefined, true], - ['workspace:^', '2.0.0-beta.1', true], - ['workspace:~', '2.0.0-beta.1', true], - ]) { - test(`${range} ${compatible ? 'accepts' : 'rejects'} local version ${version ?? 'unspecified'}`, () => { - const dependencies = { local: range }; - const localDependencies = { alpha: '1.0.0' }; - const mp = project({ name: 'app', version: '1.0.0', workspaces: ['packages/local'], dependencies }, - { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies }, - 'packages/local': { name: 'local', version: '1.0.0', dependencies: localDependencies } }, - packages: { local: ['local@workspace:packages/local'], alpha: ['alpha@1.0.0', '', {}, BUN_SRI] } }, - { extra: { 'packages/local/package.json': { name: 'local', version, dependencies: localDependencies } } }); - const before = snapshot(root); - for (const read of [stack, component]) { - if (compatible) { - expect(read(mp).components.find(c => c.name === 'local').version).to.equal(version ?? 'v0.0.0'); - } else { - expect(() => read(mp)).to.throw(`requires ${range} but resolves to ${version}`); - } - } - expect(snapshot(root)).to.deep.equal(before); - }); - } - - test('rejects stale workspace dependency declarations even with workspace:*', () => { - const dependencies = { local: 'workspace:*' }; - const mp = project({ name: 'app', version: '1.0.0', workspaces: ['packages/local'], dependencies }, - { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies }, - 'packages/local': { name: 'local', version: '1.0.0', dependencies: { alpha: '1.0.0' } } }, - packages: { local: ['local@workspace:packages/local'], alpha: ['alpha@1.0.0', '', {}, BUN_SRI] } }, - { extra: { 'packages/local/package.json': { name: 'local', version: '2.0.0', dependencies: { alpha: '2.0.0' } } } }); - for (const read of [stack, component]) { - expect(() => read(mp)).to.throw(/out of date.*alpha.*dependencies declaration/); - } - }); - - for (const [fields, recordedFields, version] of [ - [['dependencies', 'optionalDependencies'], ['optionalDependencies'], '7.0.0'], - [['devDependencies', 'optionalDependencies'], ['optionalDependencies'], '7.0.0'], - [['dependencies', 'devDependencies', 'optionalDependencies'], ['devDependencies', 'optionalDependencies'], '6.0.0'], - [['peerDependencies', 'optionalDependencies'], ['peerDependencies', 'optionalDependencies'], '7.0.0'], - [['dependencies', 'peerDependencies', 'optionalDependencies'], ['peerDependencies', 'optionalDependencies'], '7.0.0'], - [['devDependencies', 'peerDependencies', 'optionalDependencies'], ['peerDependencies', 'optionalDependencies'], '7.0.0'], - [['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'], ['devDependencies', 'peerDependencies', 'optionalDependencies'], '6.0.0'], - [['dependencies', 'devDependencies'], ['dependencies', 'devDependencies'], '6.0.0'], - ]) { - test(`normalizes Bun optional precedence for ${fields.join(' + ')}`, () => { - const declarations = Object.fromEntries(fields.map(field => [field, { 'is-number': field === 'optionalDependencies' ? '7.0.0' : '6.0.0' }])); - const recorded = Object.fromEntries(recordedFields.map(field => [field, declarations[field]])); - const mp = project({ name: 'app', version: '1.0.0', ...declarations }, - { lockfileVersion: 2, workspaces: { '': { name: 'app', ...recorded } }, - packages: { 'is-number': [`is-number@${version}`, '', {}, BUN_SRI] } }); - const before = snapshot(root); - for (const read of [stack, component]) { - expect(read(mp).components.map(c => c.purl)).to.deep.equal([`pkg:npm/is-number@${version}`]); - } - expect(snapshot(root)).to.deep.equal(before); - }); - } - - test('rejects a Bun resolution outside both retained production and development ranges', () => { - const declarations = { devDependencies: { 'is-number': '6.0.0' }, optionalDependencies: { 'is-number': '7.0.0' } }; - const mp = project({ name: 'app', version: '1.0.0', dependencies: { 'is-number': '6.0.0' }, ...declarations }, - { lockfileVersion: 2, workspaces: { '': { name: 'app', ...declarations } }, - packages: { 'is-number': ['is-number@5.0.0', '', {}, BUN_SRI] } }); - for (const read of [stack, component]) { - expect(() => read(mp)).to.throw(/requires 7\.0\.0 \|\| 6\.0\.0 but resolves to 5\.0\.0/); - } - }); - - test('normalizes Bun optional precedence in workspace member snapshots', () => { - const declarations = { dependencies: { 'is-number': '6.0.0' }, optionalDependencies: { 'is-number': '7.0.0' } }; - const mp = project({ name: 'app', version: '1.0.0', workspaces: ['packages/member'] }, - { lockfileVersion: 2, workspaces: { '': { name: 'app' }, - 'packages/member': { name: 'member', version: '1.0.0', optionalDependencies: declarations.optionalDependencies } }, - packages: { member: ['member@workspace:packages/member'], 'is-number': ['is-number@7.0.0', '', {}, BUN_SRI] } }, - { manifestPath: 'packages/member/package.json', extra: { 'packages/member/package.json': { name: 'member', version: '1.0.0', ...declarations } } }); - for (const read of [stack, component]) { - expect(read(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@7.0.0']); - } - }); - - for (const [change, declarations] of [ - ['optional version', { optionalDependencies: { 'is-number': '6.0.0' } }], - ['optional removal', { optionalDependencies: undefined }], - ['dependency classification', { dependencies: { 'is-number': '7.0.0' }, optionalDependencies: undefined }], - ['retained development version', { devDependencies: { 'is-number': '7.0.0' } }], - ['peer version', { peerDependencies: { 'is-number': '7.0.0' } }], - ]) { - test(`rejects stale Bun dependency snapshots after changing ${change}`, () => { - const original = { dependencies: { 'is-number': '6.0.0' }, devDependencies: { 'is-number': '6.0.0' }, - peerDependencies: { 'is-number': '6.0.0' }, optionalDependencies: { 'is-number': '7.0.0' } }; - const mp = project({ name: 'app', version: '1.0.0', ...original, ...declarations }, - { lockfileVersion: 2, workspaces: { '': { name: 'app', devDependencies: original.devDependencies, - peerDependencies: original.peerDependencies, optionalDependencies: original.optionalDependencies } }, - packages: { 'is-number': ['is-number@6.0.0', '', {}, BUN_SRI] } }); - const before = snapshot(root); - for (const read of [stack, component]) { - expect(() => read(mp)).to.throw(/out of date/); - } - expect(snapshot(root)).to.deep.equal(before); - }); - } - - test('accepts changes to a shadowed Bun production dependency declaration', () => { - const mp = project({ name: 'app', version: '1.0.0', dependencies: { 'is-number': '^6.0.0' }, optionalDependencies: { 'is-number': '7.0.0' } }, - { lockfileVersion: 2, workspaces: { '': { name: 'app', optionalDependencies: { 'is-number': '7.0.0' } } }, - packages: { 'is-number': ['is-number@7.0.0', '', {}, BUN_SRI] } }); - expect(stack(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@7.0.0']); - }); - - test('rejects malformed Bun dependency maps even when an optional dependency overrides them', () => { - const mp = project({ name: 'app', version: '1.0.0', dependencies: { 'is-number': 6 }, optionalDependencies: { 'is-number': '7.0.0' } }, - { lockfileVersion: 2, workspaces: { '': { name: 'app', optionalDependencies: { 'is-number': '7.0.0' } } }, - packages: { 'is-number': ['is-number@7.0.0', '', {}, BUN_SRI] } }); - expect(() => stack(mp)).to.throw(/Invalid dependencies/); - }); - - for (const field of ['dependencies', 'optionalDependencies']) { - for (const [label, value] of [ - ['array', ['beta']], ['string', 'beta'], ['number', 1], ['boolean', false], ['null', null], - ['non-string specification', { beta: 2 }], ['null specification', { beta: null }], ['empty specification', { beta: '' }], - ]) { - test(`rejects Bun transitive ${field} with ${label} metadata`, () => { - const dependencies = { alpha: '1.0.0' }; - const mp = project({ name: 'app', version: '1.0.0', dependencies }, - { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, - packages: { - alpha: ['alpha@1.0.0', '', { [field]: value }, BUN_SRI], - beta: ['beta@2.0.0', '', {}, BUN_SRI], - } }); - const before = snapshot(root); - for (const read of [stack, component]) { - expect(() => read(mp)).to.throw(new RegExp(`Invalid ${field}.*bun\\.lock`)); - } - expect(snapshot(root)).to.deep.equal(before); - }); - } - } - - /// Preserves resolvable optional branches while skipping absent optional dependencies. - test('preserves resolvable optional branches and skips absent ones', () => { - const mp = project({ name: 'app', version: '1.0.0', dependencies: { host: '1.0.0' } }, - { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { host: '1.0.0' } } }, - packages: { - host: ['host@1.0.0', '', { optionalDependencies: { 'present-opt': '^1.0.0', 'absent-opt': '^1.0.0' } }, BUN_SRI], - 'present-opt': ['present-opt@1.0.0', '', {}, BUN_SRI], - } }); - const sbom = stack(mp); - expect(edge(sbom, 'host@1.0.0', 'present-opt@1.0.0')).to.be.true; - expect(sbom.components.some(c => c.name === 'absent-opt')).to.be.false; - }); - - /// Regression: a missing required resolution is an error, not a silent omission. - test('rejects a missing required resolution instead of silently dropping it', () => { - const mp = project({ name: 'app', version: '1.0.0', dependencies: { alpha: '1.0.0' } }, - { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { alpha: '1.0.0' } } }, - packages: { alpha: ['alpha@1.0.0', '', { dependencies: { missing: '^1.0.0' } }, BUN_SRI] } }); - expect(() => stack(mp)).to.throw(/out of date.*missing/); - }); - - for (const [field, key] of [['overrides', 'is-number'], ['resolutions', 'is-number'], ['resolutions', '**/is-number']]) { - test(`uses the effective Bun override from ${field}.${key}`, () => { - const dependencies = { 'is-number': '6.0.0' }; - const mp = project({ name: 'app', version: '1.0.0', dependencies, [field]: { [key]: '7.0.0' } }, - { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, - overrides: { 'is-number': '7.0.0' }, packages: { 'is-number': ['is-number@7.0.0', '', {}, BUN_SRI] } }); - for (const read of [stack, component]) { - expect(read(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@7.0.0']); - } - }); - } - - for (const [change, declared, recorded] of [ - ['changed', { 'is-number': '7.0.0' }, { 'is-number': '6.0.0' }], - ['added', { 'is-number': '7.0.0' }, {}], - ['removed', {}, { 'is-number': '6.0.0' }], - ]) { - test(`rejects ${change} Bun overrides without changing project files`, () => { - const dependencies = { 'is-number': '*' }; - const mp = project({ name: 'app', version: '1.0.0', dependencies, overrides: declared }, - { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, - overrides: recorded, packages: { 'is-number': ['is-number@6.0.0', '', {}, BUN_SRI] } }); - const before = snapshot(root); - for (const read of [stack, component]) { - expect(() => read(mp)).to.throw(/out of date.*overrides/); - } - expect(snapshot(root)).to.deep.equal(before); - }); - } - - test('applies Bun overrides to transitive dependencies and rejects an incompatible locked version', () => { - const dependencies = { 'is-odd': '3.0.1' }; - const lock = { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, - overrides: { 'is-number': '7.0.0' }, packages: { - 'is-odd': ['is-odd@3.0.1', '', { dependencies: { 'is-number': '^6.0.0' } }, BUN_SRI], - 'is-number': ['is-number@7.0.0', '', {}, BUN_SRI], - } }; - const mp = project({ name: 'app', version: '1.0.0', dependencies, overrides: lock.overrides }, lock); - expect(edge(stack(mp), 'is-odd@3.0.1', 'is-number@7.0.0')).to.be.true; - - lock.packages['is-number'][0] = 'is-number@6.0.0'; - fs.writeFileSync(path.join(root, 'bun.lock'), JSON.stringify(lock)); - expect(() => stack(mp)).to.throw(/requires 7\.0\.0 but resolves to 6\.0\.0/); - }); - - test('normalizes Bun override dependency references to their recorded specifications', () => { - const dependencies = { 'is-number': '6.0.0', pin: 'npm:is-number@7.0.0' }; - const mp = project({ name: 'app', version: '1.0.0', dependencies, overrides: { 'is-number': '$pin' } }, - { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, - overrides: { 'is-number': 'npm:is-number@7.0.0' }, packages: { - 'is-number': ['is-number@7.0.0', '', {}, BUN_SRI], pin: ['is-number@7.0.0', '', {}, BUN_SRI], - } }); - expect(stack(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@7.0.0']); - }); - - test('preserves npm aliases when a Bun override names the alias', () => { - const dependencies = { pick: 'npm:is-number@7.0.0' }; - const mp = project({ name: 'app', version: '1.0.0', dependencies, overrides: { pick: '6.0.0' } }, - { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, - overrides: { pick: '6.0.0' }, packages: { pick: ['is-number@7.0.0', '', {}, BUN_SRI] } }); - expect(stack(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@7.0.0']); - }); - - test('reports the replacement package identity for a Bun override using an npm alias', () => { - const dependencies = { 'is-number': '6.0.0' }; - const overrides = { 'is-number': 'npm:kind-of@6.0.0' }; - const mp = project({ name: 'app', version: '1.0.0', dependencies, overrides }, - { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, overrides, - packages: { 'is-number': ['kind-of@6.0.0', '', {}, BUN_SRI] } }); - for (const read of [stack, component]) { - expect(read(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/kind-of@6.0.0']); - } - }); - - test('rejects a Bun override reference to an undeclared dependency', () => { - const dependencies = { 'is-number': '*' }; - const mp = project({ name: 'app', version: '1.0.0', dependencies, overrides: { 'is-number': '$missing' } }, - { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, - packages: { 'is-number': ['is-number@6.0.0', '', {}, BUN_SRI] } }); - expect(() => stack(mp)).to.throw(/override reference has no declared dependency/); - }); - - test('uses root Bun overrides when analyzing a workspace member', () => { - const dependencies = { 'is-number': '6.0.0' }; - const overrides = { 'is-number': '7.0.0' }; - const mp = project({ name: 'ws-root', private: true, workspaces: ['packages/member'], overrides }, - { lockfileVersion: 2, workspaces: { '': { name: 'ws-root' }, 'packages/member': { name: 'member', version: '1.0.0', dependencies } }, - overrides, packages: { member: ['member@workspace:packages/member'], 'is-number': ['is-number@7.0.0', '', {}, BUN_SRI] } }, - { manifestPath: 'packages/member/package.json', extra: { - 'packages/member/package.json': { name: 'member', version: '1.0.0', dependencies, overrides: { 'is-number': '6.0.0' } }, - } }); - expect(stack(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@7.0.0']); - }); - - test('ignores Bun resolutions when an overrides field is present', () => { - const dependencies = { 'is-number': '6.0.0' }; - const mp = project({ name: 'app', version: '1.0.0', dependencies, overrides: {}, resolutions: { 'is-number': '7.0.0' } }, - { lockfileVersion: 2, workspaces: { '': { name: 'app', dependencies } }, - packages: { 'is-number': ['is-number@6.0.0', '', {}, BUN_SRI] } }); - expect(stack(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/is-number@6.0.0']); - }); - - /// Rejects a changed manifest declaration now that bun no longer re-syncs the lockfile. - test('rejects a changed manifest declaration without writing files', () => { - const lock = { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { alpha: '1.0.0' } } }, - packages: { alpha: ['alpha@1.0.0', '', {}, BUN_SRI] } }; - const mp = project({ name: 'app', version: '1.0.0', dependencies: { alpha: '1.0.0' } }, lock); - fs.writeFileSync(mp, JSON.stringify({ name: 'app', version: '1.0.0', dependencies: { alpha: '^2.0.0' } })); - const before = snapshot(root); - expect(() => stack(mp)).to.throw(/out of date.*alpha/); - expect(snapshot(root)).to.deep.equal(before); - }); - - for (const version of [0, 1, 2]) { - /// Verifies every real lockfileVersion (0, 1, 2) is supported. - test(`supports lockfileVersion ${version}`, () => { - const mp = project({ name: 'app', version: '1.0.0', dependencies: { leaf: '1.0.0' } }, - { lockfileVersion: version, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { leaf: '1.0.0' } } }, - packages: { leaf: ['leaf@1.0.0', '', {}, BUN_SRI] } }); - expect(stack(mp).components.map(c => c.purl)).to.deep.equal(['pkg:npm/leaf@1.0.0']); - }); - } - - for (const version of [null, false, true, '', '0', '1', '2', [], [2], {}, undefined]) { - test(`rejects invalid lockfileVersion ${JSON.stringify(version)}`, () => { - const mp = project({ name: 'app', version: '1.0.0' }, - { lockfileVersion: version, workspaces: { '': { name: 'app' } }, packages: {} }); - const before = snapshot(root); - for (const read of [stack, component]) { - expect(() => read(mp)).to.throw(/Unsupported bun lockfile version/); - } - expect(snapshot(root)).to.deep.equal(before); - }); - } - - /// Verifies an unsupported lockfileVersion fails closed with an actionable error. - test('rejects an unsupported lockfile version', () => { - const mp = project({ name: 'app', version: '1.0.0', dependencies: {} }, - { lockfileVersion: 3, configVersion: 1, workspaces: { '': { name: 'app' } }, packages: {} }); - expect(() => stack(mp)).to.throw(/Unsupported bun lockfile version 3/); - }); - - /// Verifies hosted 3-tuple entries, empty-registry entries and SHA-1 integrity are all handled. - test('handles hosted 3-tuple entries, empty-registry entries and SHA-1 integrity', () => { - const sha1 = `sha1-${Buffer.from('ab'.repeat(20), 'hex').toString('base64')}`; - const mp = project({ name: 'app', version: '1.0.0', dependencies: { hosted: 'github:o/r#abc', reg: '1.0.0' } }, - { lockfileVersion: 2, configVersion: 1, workspaces: { '': { name: 'app', dependencies: { hosted: 'github:o/r#abc', reg: '1.0.0' } } }, - packages: { - hosted: ['hosted@github:o/r#abc', {}, sha1], - reg: ['reg@1.0.0', '', {}, BUN_SRI], - } }); - const sbom = stack(mp); - expect(sbom.components.some(c => c.name === 'hosted')).to.be.true; - expect(sbom.components.find(c => c.name === 'hosted').hashes).to.deep.equal([{ alg: 'SHA-1', content: 'ab'.repeat(20) }]); - expect(sbom.components.find(c => c.name === 'reg').hashes).to.deep.equal([{ alg: 'SHA-512', content: BUN_SRI_HEX }]); - }); - - /// Verifies a malformed bun.lock is rejected rather than silently yielding an empty graph. - test('rejects a malformed bun.lock', () => { - const mp = project({ name: 'app', version: '1.0.0', dependencies: {} }, '{ invalid'); - expect(() => stack(mp)).to.throw(/Cannot read/); - }); - - /// Verifies analysis requires a text lockfile when none is present. - test('requires a bun.lock when none is present', () => { - const mp = path.join(root, 'package.json'); - fs.writeFileSync(mp, JSON.stringify({ name: 'app', version: '1.0.0', dependencies: {} })); - expect(() => stack(mp)).to.throw(/requires a lockfile/); - }); - - /// Verifies a binary bun.lockb reaches the migrate diagnostic through the public match() - /// path real callers use (stack/componentAnalysis), not only a direct provider call. - test('rejects a binary bun.lockb with a migrate diagnostic through match()', () => { - // Given a project whose only lockfile is a binary bun.lockb (no text bun.lock) - const mp = path.join(root, 'package.json'); - fs.writeFileSync(mp, JSON.stringify({ name: 'app', version: '1.0.0', dependencies: {} })); - fs.writeFileSync(path.join(root, 'bun.lockb'), Buffer.from([0, 1, 2, 3])); - - // When selecting a provider the way the public entry points do - const provider = match(mp, availableProviders); - - // Then match() picks Bun instead of failing with the generic "requires a lock file" - // error, and both analyses surface the actionable migrate diagnostic - expect(provider).to.be.instanceOf(Bun); - expect(() => provider.provideStack(mp)).to.throw(/bun\.lockb[\s\S]*bun\.lock/); - expect(() => provider.provideComponent(mp)).to.throw(/bun\.lockb[\s\S]*bun\.lock/); - }); - - /// Verifies the suggested migrate command matches the README so the fix is copy-pasteable. - test('suggests the README migrate command for a binary bun.lockb', () => { - const mp = path.join(root, 'package.json'); - fs.writeFileSync(mp, JSON.stringify({ name: 'app', version: '1.0.0', dependencies: {} })); - fs.writeFileSync(path.join(root, 'bun.lockb'), Buffer.from([0, 1, 2, 3])); - expect(() => match(mp, availableProviders).provideStack(mp)) - .to.throw('bun install --save-text-lockfile --frozen-lockfile --lockfile-only'); - }); - - /// Regression: the bun.lockb diagnostic must honor TRUSTIFY_DA_WORKSPACE_DIR (the old - /// #binaryLockFileDir walked from manifestDir and ignored opts entirely). - test('honors TRUSTIFY_DA_WORKSPACE_DIR for a binary bun.lockb at the workspace root', () => { - // Given a workspace member whose only lockfile is a binary bun.lockb at the workspace root - const memberDir = path.join(root, 'packages', 'module-a'); - fs.mkdirSync(memberDir, { recursive: true }); - const mp = path.join(memberDir, 'package.json'); - fs.writeFileSync(mp, JSON.stringify({ name: 'member', version: '1.0.0', dependencies: {} })); - fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'root', workspaces: ['packages/*'] })); - fs.writeFileSync(path.join(root, 'bun.lockb'), Buffer.from([0, 1, 2, 3])); - const opts = { TRUSTIFY_DA_WORKSPACE_DIR: root }; - - // When selecting and analyzing through match() with the workspace dir configured - const provider = match(mp, availableProviders, opts); - - // Then Bun is selected and the migrate diagnostic fires from the workspace root - expect(provider).to.be.instanceOf(Bun); - expect(() => provider.provideStack(mp, opts)).to.throw(/bun\.lockb[\s\S]*bun\.lock/); - }); -}); - -suite('bun complete SBOM reference comparisons (TC-7014)', () => { - let refClock; - suiteSetup(() => { refClock = useFakeTimers(new Date('2023-08-07T00:00:00.000Z')); }); - suiteTeardown(() => { refClock.restore(); }); - const base = 'test/providers/tst_manifests/bun'; - const fixtures = ['alias', 'nested', 'optional', 'workspace']; - for (const fixture of fixtures) { - for (const { method, type } of [{ method: 'provideStack', type: 'stack' }, { method: 'provideComponent', type: 'component' }]) { - test(`bun ${fixture} ${type} matches every SBOM field`, () => { - const dir = `${base}/${fixture}`; - const manifest = `${dir}/${fixture === 'workspace' ? 'packages/member/package.json' : 'package.json'}`; - const actual = new Bun()[method](manifest); - const expected = JSON.parse(fs.readFileSync(`${dir}/expected_${type}_sbom.json`, 'utf8')); - expected.metadata.tools.components[0].version = getPackageVersion(); - expect(actual.contentType).to.equal('application/vnd.cyclonedx+json'); - expect(canonical(actual.content)).to.deep.equal(canonical(expected)); - }); - } - } -}); From 4bd74162f493c902a77953f8ae00acf68f1cd4ea Mon Sep 17 00:00:00 2001 From: Adva Oren Date: Mon, 26 Oct 2026 15:46:00 +0200 Subject: [PATCH 8/8] style(lockfile): wrap added lines to <=100 columns Wrap lines added under src/ that exceeded the documented 100-column limit (CONVENTIONS.md Code Style). Pure formatting plus semantics-preserving helper extractions; no behavior change. Implements TC-7201 Co-Authored-By: Claude Opus 4.8 Assisted-by: Claude Code Signed-off-by: Adva Oren --- src/providers/javascript_lockfile.js | 95 +++++++++++++++++++++------- 1 file changed, 72 insertions(+), 23 deletions(-) diff --git a/src/providers/javascript_lockfile.js b/src/providers/javascript_lockfile.js index b8243970..6ffe79a7 100644 --- a/src/providers/javascript_lockfile.js +++ b/src/providers/javascript_lockfile.js @@ -782,7 +782,8 @@ export function readBunLock(file) { * The info object is located positionally and the integrity is the trailing * string, so all shapes parse through one code path. * @param {Array} tuple - A `bun.lock` packages entry - * @returns {{id: string, name: string, range: string, version: string, info: Object, integrity: (string|undefined), workspace: boolean}} + * @returns {{id: string, name: string, range: string, version: string, + * info: Object, integrity: (string|undefined), workspace: boolean}} */ export function parseBunEntry(tuple) { const id = tuple[0]; @@ -813,7 +814,10 @@ export function parseBunEntry(tuple) { export function readBunGraph(root, manifestPath, manifest, lock) { const file = path.join(root, 'bun.lock'); if (![0, 1, 2].includes(lock.lockfileVersion)) { - throw new Error(`Unsupported bun lockfile version ${lock.lockfileVersion} in ${file}. Regenerate a version 0, 1, or 2 bun.lock before scanning.`); + throw new Error( + `Unsupported bun lockfile version ${lock.lockfileVersion} in ${file}. ` + + `Regenerate a version 0, 1, or 2 bun.lock before scanning.` + ); } if (!object(lock.workspaces)) { unsupported(file, 'workspaces', 'missing workspaces table'); } const workspaces = lock.workspaces; @@ -823,22 +827,31 @@ export function readBunGraph(root, manifestPath, manifest, lock) { const rootFile = path.join(root, 'package.json'); const rootData = manifests.get(rootFile); const overrideField = Object.hasOwn(rootData, 'overrides') ? 'overrides' : 'resolutions'; - const overrides = Object.fromEntries(Object.entries(dependencyMap(rootData, overrideField, rootFile)).map(([name, spec]) => { + const overrideEntries = Object.entries(dependencyMap(rootData, overrideField, rootFile)); + const overrides = Object.fromEntries(overrideEntries.map(([name, spec]) => { if (overrideField === 'resolutions') { name = name.replace(/^\*\*\//, ''); } if (spec.startsWith('$')) { - const dependencies = { ...dependencyMap(rootData, 'peerDependencies', rootFile), ...manifestDependencies(rootData, rootFile, true) }; + const dependencies = { + ...dependencyMap(rootData, 'peerDependencies', rootFile), + ...manifestDependencies(rootData, rootFile, true), + }; spec = dependencies[spec.slice(1)]; - if (typeof spec !== 'string' || !spec) { unsupported(rootFile, name, 'override reference has no declared dependency'); } + if (typeof spec !== 'string' || !spec) { + unsupported(rootFile, name, 'override reference has no declared dependency'); + } } return [name, spec]; })); validateSnapshot(rootFile, { overrides }, lock, ['overrides']); const locals = new Map(); for (const [manifestFile, data] of manifests) { - const location = path.relative(root, path.dirname(manifestFile)).split(path.sep).join('/') || '.'; + const location = path.relative(root, path.dirname(manifestFile)) + .split(path.sep).join('/') || '.'; const entry = workspaces[location === '.' ? '' : location]; if (!object(entry)) { stale(manifestFile, data.name, 'is missing from the lockfile'); } - if (entry.name != null && data.name !== entry.name) { stale(manifestFile, data.name, 'has a different locked identity'); } + if (entry.name != null && data.name !== entry.name) { + stale(manifestFile, data.name, 'has a different locked identity'); + } const snapshot = { dependencies: { ...dependencyMap(data, 'dependencies', manifestFile) }, devDependencies: { ...dependencyMap(data, 'devDependencies', manifestFile) }, @@ -850,11 +863,19 @@ export function readBunGraph(root, manifestPath, manifest, lock) { if (Object.hasOwn(snapshot.dependencies, name)) { delete snapshot.dependencies[name]; } else { delete snapshot.devDependencies[name]; } } - validateSnapshot(manifestFile, snapshot, entry, ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']); - locals.set(location, { data, file: manifestFile, packageKey: location === '.' ? '' : data.name, devDependencies: snapshot.devDependencies }); + validateSnapshot(manifestFile, snapshot, entry, [ + 'dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies', + ]); + locals.set(location, { + data, file: manifestFile, packageKey: location === '.' ? '' : data.name, + devDependencies: snapshot.devDependencies, + }); + } + const rootLocation = path.relative(root, path.dirname(path.resolve(manifestPath))) + .split(path.sep).join('/') || '.'; + if (!locals.has(rootLocation)) { + unsupported(file, manifest.name, 'manifest is not in the workspace'); } - const rootLocation = path.relative(root, path.dirname(path.resolve(manifestPath))).split(path.sep).join('/') || '.'; - if (!locals.has(rootLocation)) { unsupported(file, manifest.name, 'manifest is not in the workspace'); } // Resolves a dependency to its `packages` key using Bun's node_modules // hoisting: a `parent/child` nested entry wins over a hoisted top-level one, @@ -883,20 +904,26 @@ export function readBunGraph(root, manifestPath, manifest, lock) { stale(source, name, 'has no locked resolution'); } const entry = packages[key]; - if (!Array.isArray(entry) || typeof entry[0] !== 'string') { unsupported(file, key, 'malformed package entry'); } + if (!Array.isArray(entry) || typeof entry[0] !== 'string') { + unsupported(file, key, 'malformed package entry'); + } const parsed = parseBunEntry(entry); if (parsed.workspace) { const location = parsed.range.slice('workspace:'.length) || '.'; const local = locals.get(location); if (!local) { unsupported(file, key, 'workspace package missing from the lockfile'); } if (spec != null && !['workspace:*', 'workspace:^', 'workspace:~'].includes(spec)) { - validateRange(source, name, spec, { name: local.data.name, version: local.data.version ?? 'v0.0.0' }); + validateRange(source, name, spec, { + name: local.data.name, version: local.data.version ?? 'v0.0.0', + }); } return `ws:${location}`; } const aliased = spec?.startsWith('npm:') && !semver.validRange(spec.slice(4)); const effective = !aliased && Object.hasOwn(overrides, name) ? overrides[name] : spec; - if (effective != null) { validateRange(source, name, effective, { name: parsed.name, version: parsed.version }); } + if (effective != null) { + validateRange(source, name, effective, { name: parsed.name, version: parsed.version }); + } return key; } @@ -909,30 +936,52 @@ export function readBunGraph(root, manifestPath, manifest, lock) { const children = []; if (id.startsWith('ws:')) { const local = locals.get(id.slice(3)); - const node = { name: local.data.name ?? path.basename(path.dirname(local.file)), version: local.data.version ?? 'v0.0.0', dependencies: new Map(), hashes: undefined }; + const node = { + name: local.data.name ?? path.basename(path.dirname(local.file)), + version: local.data.version ?? 'v0.0.0', + dependencies: new Map(), + hashes: undefined, + }; graph.nodes.set(id, node); const optionalDeps = local.data.optionalDependencies ?? {}; - for (const [name, spec] of Object.entries(manifestDependencies(local.data, local.file))) { + for (const [name, spec] of Object.entries( + manifestDependencies(local.data, local.file) + )) { const devSpec = local.devDependencies[name]; - // Bun may lock either range when it retains both declarations for the same name. - const effective = typeof devSpec === 'string' && semver.validRange(spec) && semver.validRange(devSpec) ? `${spec} || ${devSpec}` : spec; - const cid = childId(local.packageKey, name, effective, local.file, Object.hasOwn(optionalDeps, name)); + // Bun may lock either range when it retains both declarations for + // the same name. + const bothRanges = typeof devSpec === 'string' + && semver.validRange(spec) && semver.validRange(devSpec); + const effective = bothRanges ? `${spec} || ${devSpec}` : spec; + const cid = childId(local.packageKey, name, effective, local.file, + Object.hasOwn(optionalDeps, name)); if (cid == null) { continue; } node.dependencies.set(name, cid); children.push(cid); } } else { const parsed = parseBunEntry(packages[id]); - if (typeof parsed.name !== 'string' || !parsed.name || typeof parsed.version !== 'string' || !parsed.version) { unsupported(file, id, 'missing package name or version'); } - const node = { name: parsed.name, version: parsed.version, dependencies: new Map(), hashes: hashes({ integrity: parsed.integrity }) }; + const validId = typeof parsed.name === 'string' && parsed.name + && typeof parsed.version === 'string' && parsed.version; + if (!validId) { + unsupported(file, id, 'missing package name or version'); + } + const node = { + name: parsed.name, version: parsed.version, + dependencies: new Map(), hashes: hashes({ integrity: parsed.integrity }), + }; graph.nodes.set(id, node); for (const field of ['dependencies', 'optionalDependencies']) { if (Object.hasOwn(parsed.info, field) && !object(parsed.info[field])) { - throw new Error(`Invalid ${field} in ${file} for ${id}: expected a dependency mapping.`); + throw new Error( + `Invalid ${field} in ${file} for ${id}: ` + + `expected a dependency mapping.` + ); } } const required = dependencyMap(parsed.info, 'dependencies', `${file} for ${id}`); - const optionalDeps = dependencyMap(parsed.info, 'optionalDependencies', `${file} for ${id}`); + const optionalDeps = + dependencyMap(parsed.info, 'optionalDependencies', `${file} for ${id}`); // ponytail: transitive peerDependencies are omitted (as the prior // reader did); direct peers are supplemented by the SBOM builder. Add // peer-edge resolution here if a fixture ever needs transitive peers.
    TRUSTIFY_DA_YARN_PATH (retained for compatibility; dependency analysis does not invoke Yarn)
    BunbunTRUSTIFY_DA_BUN_PATH (retained for compatibility; dependency analysis does not invoke Bun)
    Go Modules go TRUSTIFY_DA_GO_PATH