diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1978c40c..16abf26b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -182,9 +182,14 @@ 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/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/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:

    -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. + + + + + 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 3816cf69..1fecc944 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,25 @@ 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"; + } + + /** 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 +41,27 @@ 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') { + if (!Array.isArray(entry) || typeof entry[0] !== 'string') { continue; } - const id = entry[0]; - const at = id.lastIndexOf('@'); - if (at <= 0) { + // 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 = id.slice(0, at); - const version = id.slice(at + 1); - const hash = sriToHash(entry[3]); + const { name, version, integrity } = parsed; + const hash = integrity ? sriToHash(integrity) : null; if (name && version && hash) { map.set(`${name}@${version}`, [hash]); } @@ -51,113 +69,32 @@ 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; - } - } - return workspaces[''] || {}; - } - - #resolvePackage(depName, parentKey, packages) { - if (parentKey) { - const scopedKey = `${parentKey}/${depName}`; - if (packages[scopedKey]) { - return packages[scopedKey]; - } - } - 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; - } - 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); - } + /** + * 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._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.'); } - - 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'; + const lock = readBunLock(path.join(lockDir, this._lockFileName())); + const graph = readBunGraph(lockDir, manifest.manifestPath, manifest.content, lock); + this._loadHashes(lockDir); + return graph; } } diff --git a/src/providers/javascript_lockfile.js b/src/providers/javascript_lockfile.js index 99f6b4be..6ffe79a7 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,257 @@ 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(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 rootFile = path.join(root, 'package.json'); + const rootData = manifests.get(rootFile); + const overrideField = Object.hasOwn(rootData, 'overrides') ? 'overrides' : 'resolutions'; + 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), + }; + 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'); + } + 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'); + } + + // 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) { + const parts = ownerPath ? ownerPath.split('/') : []; + while (true) { + const candidate = [...parts, name].join('/'); + if (packages[candidate] !== undefined) { return candidate; } + 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(); } + } + } + + 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) || '.'; + 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}`; + } + 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; + } + + // 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 devSpec = local.devDependencies[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]); + 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.` + ); + } + } + 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. + 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/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 03d5bc4c..a72f37e9 100644 --- a/test/providers/javascript.test.js +++ b/test/providers/javascript.test.js @@ -9,6 +9,7 @@ import { useFakeTimers } from "sinon"; 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'; @@ -52,8 +53,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'); } } 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/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" + } +}
    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