Skip to content

Commit

Permalink
feat: Add support for multiple/nested workspace traversal under new r…
Browse files Browse the repository at this point in the history
…ecursive flag

Signed-off-by: MLSTRM <>
  • Loading branch information
MLSTRM committed Mar 18, 2024
1 parent a70f74e commit d508ca1
Show file tree
Hide file tree
Showing 4 changed files with 78 additions and 65 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ $ yarn CycloneDX make-sbom
(choices: "application", "framework", "library", "container", "platform", "device-driver", default: "application")
--reproducible Whether to go the extra mile and make the output reproducible.
This might result in loss of time- and random-based values.
--recursive Scan all nested workspaces within the current project, rather than just the one in the current working directory.
━━━ Details ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Expand Down
19 changes: 14 additions & 5 deletions sources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import {
Configuration,
type Plugin,
Project,
ThrowReport
ThrowReport,
type Workspace
} from '@yarnpkg/core'
import { type PortablePath, ppath } from '@yarnpkg/fslib'
import { Command, Option } from 'clipanion'
Expand Down Expand Up @@ -73,6 +74,10 @@ class SBOMCommand extends BaseCommand {
description: 'Whether to go the extra mile and make the output reproducible.\nThis might result in loss of time- and random-based values.'
})

recursive = Option.Boolean('--recursive', false, {
description: 'Resolve dependencies from all nested workspaces within the current one.'
})

async execute (): Promise<void> {
const configuration = await Configuration.find(
this.context.cwd,
Expand All @@ -86,6 +91,9 @@ class SBOMCommand extends BaseCommand {

if (this.production) {
workspace.manifest.devDependencies.clear()
if (this.recursive) {
project.workspaces.forEach((w: Workspace) => { w.manifest.devDependencies.clear() })
}
const cache = await Cache.find(project.configuration)
await project.resolveEverything({ report: new ThrowReport(), cache })
} else {
Expand All @@ -97,14 +105,15 @@ class SBOMCommand extends BaseCommand {
outputFormat: parseOutputFormat(this.outputFormat),
outputFile: parseOutputFile(workspace.cwd, this.outputFile),
componentType: parseComponenttype(this.componentType),
reproducible: this.reproducible
reproducible: this.reproducible,
recursive: this.recursive
})
}
}

function parseSpecVersion (
specVersion: string | undefined
): OutputOptions['specVersion'] {
): OutputOptions[ 'specVersion' ] {
if (specVersion === undefined) {
return CDX.Spec.Version.v1dot5
}
Expand All @@ -119,7 +128,7 @@ function parseSpecVersion (

function parseOutputFormat (
outputFormat: string | undefined
): OutputOptions['outputFormat'] {
): OutputOptions[ 'outputFormat' ] {
if (outputFormat === undefined) {
return CDX.Spec.Format.JSON
}
Expand All @@ -136,7 +145,7 @@ function parseOutputFormat (
function parseOutputFile (
cwd: PortablePath,
outputFile: string | undefined
): OutputOptions['outputFile'] {
): OutputOptions[ 'outputFile' ] {
if (outputFile === undefined || outputFile === '-') {
return stdOutOutput
} else {
Expand Down
15 changes: 8 additions & 7 deletions sources/sbom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { PackageURL } from 'packageurl-js'
import {
type BuildtimeDependencies,
type PackageInfo,
traverseWorkspace
traverseWorkspaces
} from './traverseUtils'

const licenseFactory = new CDX.Factories.LicenseFactory()
Expand All @@ -55,6 +55,7 @@ export interface OutputOptions {
outputFile: PortablePath | typeof stdOutOutput
componentType: CDX.Enums.ComponentType
reproducible: boolean
recursive: boolean
}

export async function generateSBOM (
Expand All @@ -74,9 +75,9 @@ export async function generateSBOM (
bom.metadata.timestamp = new Date()
}

const allDependencies = await traverseWorkspace(
const allDependencies = await traverseWorkspaces(
project,
workspace,
outputOptions.recursive ? project.workspaces : [workspace],
config
)
const componentModels = new Map<LocatorHash, CDX.Models.Component>()
Expand Down Expand Up @@ -153,9 +154,9 @@ async function addMetadataTools (bom: CDX.Models.Bom): Promise<void> {
*/
function serialize (
bom: CDX.Models.Bom,
specVersion: OutputOptions['specVersion'],
outputFormat: OutputOptions['outputFormat'],
reproducible: OutputOptions['reproducible']
specVersion: OutputOptions[ 'specVersion' ],
outputFormat: OutputOptions[ 'outputFormat' ],
reproducible: OutputOptions[ 'reproducible' ]
): string {
const spec = CDX.Spec.SpecVersionDict[specVersion]
if (spec === undefined) { throw new RangeError('undefined specVersion') }
Expand Down Expand Up @@ -218,7 +219,7 @@ function getAuthorName (manifestRawAuthor: unknown): string | undefined {
*/
function packageInfoToCycloneComponent (
pkgInfo: PackageInfo,
reproducible: OutputOptions['reproducible']
reproducible: OutputOptions[ 'reproducible' ]
): CDX.Models.Component {
const manifest = pkgInfo.manifest
const component = componentBuilder.makeComponent(
Expand Down
108 changes: 55 additions & 53 deletions sources/traverseUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,12 @@ export interface PackageInfo {

// Modelled after traverseWorkspace in https://github.com/yarnpkg/berry/blob/master/packages/plugin-essentials/sources/commands/info.ts#L88
/**
* Recursively traveses workspace and its transitive dependencies.
* Recursively traverses workspaces and their transitive dependencies.
* @returns Packages and their resolved dependencies.
*/
export async function traverseWorkspace (
export async function traverseWorkspaces (
project: Project,
workspace: Workspace,
workspaces: Workspace[],
config: Configuration
): Promise<Set<PackageInfo>> {
// Instantiate fetcher to be able to retrieve package manifest. Conversion to CycloneDX model needs this later.
Expand All @@ -67,62 +67,64 @@ export async function traverseWorkspace (
cacheOptions: { skipIntegrityCheck: true }
}

const workspaceHash = workspace.anchoredLocator.locatorHash

/** Packages that have been added to allPackages. */
const seen = new Set<LocatorHash>()
const allPackages = new Set<PackageInfo>()
/** Resolved dependencies that still need processing to find their dependencies. */
const pending = [workspaceHash]

while (true) {
// pop to take most recently added job which traverses packages in depth-first style.
// Doing probably results in smaller 'pending' array which makes includes-search cheaper below.
const hash = pending.pop()
if (hash === undefined) {
// Nothing left to do as undefined value means no more item was in 'pending' array.
break
}

const pkg = project.storedPackages.get(hash)
if (pkg === undefined) {
throw new Error(
'All package locator hashes should be resovable for consistent lockfiles.'
)
}
for (const workspace of workspaces) {
const workspaceHash = workspace.anchoredLocator.locatorHash

/** Packages that have been added to allPackages. */
const seen = new Set<LocatorHash>()
/** Resolved dependencies that still need processing to find their dependencies. */
const pending = [workspaceHash]

while (true) {
// pop to take most recently added job which traverses packages in depth-first style.
// Doing probably results in smaller 'pending' array which makes includes-search cheaper below.
const hash = pending.pop()
if (hash === undefined) {
// Nothing left to do as undefined value means no more item was in 'pending' array.
break
}

const fetchResult = await fetcher.fetch(pkg, fetcherOptions)
let manifest: Manifest
try {
manifest = await Manifest.find(fetchResult.prefixPath, {
baseFs: fetchResult.packageFs
})
} finally {
fetchResult.releaseFs?.()
}
const packageInfo: PackageInfo = {
package: pkg,
manifest,
dependencies: new Set()
}
seen.add(hash)
allPackages.add(packageInfo)

// pkg.dependencies has dependencies+peerDependencies for transitive dependencies but not their devDependencies.
for (const dependency of pkg.dependencies.values()) {
const resolution = project.storedResolutions.get(
dependency.descriptorHash
)
if (typeof resolution === 'undefined') {
throw new Error('All package descriptor hashes should be resolvable for consistent lockfiles.')
const pkg = project.storedPackages.get(hash)
if (pkg === undefined) {
throw new Error(
'All package locator hashes should be resovable for consistent lockfiles.'
)
}
packageInfo.dependencies.add(resolution)

if (!seen.has(resolution) && !pending.includes(resolution)) {
pending.push(resolution)
const fetchResult = await fetcher.fetch(pkg, fetcherOptions)
let manifest: Manifest
try {
manifest = await Manifest.find(fetchResult.prefixPath, {
baseFs: fetchResult.packageFs
})
} finally {
fetchResult.releaseFs?.()
}
const packageInfo: PackageInfo = {
package: pkg,
manifest,
dependencies: new Set()
}
seen.add(hash)
allPackages.add(packageInfo)

// pkg.dependencies has dependencies+peerDependencies for transitive dependencies but not their devDependencies.
for (const dependency of pkg.dependencies.values()) {
const resolution = project.storedResolutions.get(
dependency.descriptorHash
)
if (typeof resolution === 'undefined') {
throw new Error('All package descriptor hashes should be resolvable for consistent lockfiles.')
}
packageInfo.dependencies.add(resolution)

if (!seen.has(resolution) && !pending.includes(resolution)) {
pending.push(resolution)
}
}
}
}

return allPackages
}
};

0 comments on commit d508ca1

Please sign in to comment.