diff --git a/CHANGELOG.md b/CHANGELOG.md index 8594a501a..618ea0165 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - With `--dynamic-sbom-inference`, an sbt build that fails before writing its results is reported as failed instead of reusing another build's results. - `socket scan create --reach` keeps the private libraries and build modules of Socket facts files in the stored SBOM, with their dependencies under them. +- JVM dependencies whose jars cannot be fetched, or are gone from disk, no longer reach reachability analysis looking resolved without code. ## [1.10.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.10.0) - 2026-10-22 diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index cb53d3d9e..2dbd3b352 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -32,7 +32,8 @@ type MergedNode = { direct: boolean // projectKey when this node is a build project, else empty. project: string - targets: Set + // One per resolution root that recorded files or failed to fetch them. + resolutions: Array<{ targets: string[]; unfetched: boolean }> } type SubprojectGraph = { @@ -52,6 +53,7 @@ type RootNode = { direct: boolean project: string targets: string[] + unfetched: boolean } export function assembleFacts( @@ -116,10 +118,12 @@ function buildPerRoot(parsed: ParsedRecords): Map { direct: false, project: n.project, targets: [], + unfetched: false, } nodes.set(id, node) } node.direct ||= n.direct + node.unfetched ||= n.unfetched node.targets.push(...n.targets) } for (const [p, c] of r.edges) { @@ -152,7 +156,7 @@ function mergeById(perRoot: Map): Map { prod: false, direct: false, project: node.project, - targets: new Set(), + resolutions: [], } finalNodes.set(coordId, fn) } @@ -162,8 +166,11 @@ function mergeById(perRoot: Map): Map { if (node.direct) { fn.direct = true } - for (const t of node.targets) { - fn.targets.add(t) + if (node.unfetched || node.targets.length) { + fn.resolutions.push({ + targets: node.targets, + unfetched: node.unfetched, + }) } } } @@ -406,12 +413,32 @@ function buildArtifactPaths( fileExists: (path: string) => boolean, ): ResolvedArtifactPaths { const pathsById: ResolvedArtifactPaths['pathsById'] = new Map() + // Every root that fetched an artifact lists its file, so check each path once. + const existing = new Map() + const exists = (p: string) => { + let e = existing.get(p) + if (e === undefined) { + e = fileExists(p) + existing.set(p, e) + } + return e + } for (const [id, fn] of finalNodes) { if (!fn.project) { - pathsById.set(id, { - sources: [], - targets: [...fn.targets].filter(fileExists).sort(), - }) + // Only a root that fetched every file of the artifact, all still on + // disk, resolved it: a partial list would claim it resolved whole. + const complete = fn.resolutions.filter( + r => !r.unfetched && r.targets.every(exists), + ) + pathsById.set( + id, + fn.resolutions.length && !complete.length + ? { sources: [] } + : { + sources: [], + targets: [...new Set(complete.flatMap(r => r.targets))].sort(), + }, + ) } } // A project's own component shares its id, so this also covers dependency diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index 2167321a9..e58ad23c5 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -115,6 +115,81 @@ describe('records → assemble → sidecar', () => { expect(bom?.targets).toEqual([]) expect(bom?.sources).toEqual([]) }) + it('states no targets for an artifact that failed to resolve or is gone from disk', () => { + const records = [ + 'meta\tmaven\t3.9.6\t17', + 'project\tg:app:1\tg\tapp\t1\t.', + 'root\t0\tg:app:1\tcompile\t1', + 'node\t0\tg:ok:jar:1\tg\tok\t1\tjar\t\t1', + 'node\t0\tg:nojar:jar:1\tg\tnojar\t1\tjar\t\t1', + 'node\t0\tg:nojar:test-jar:tests:1\tg\tnojar\t1\ttest-jar\ttests\t1', + 'node\t0\tg:gone:jar:1\tg\tgone\t1\tjar\t\t1', + 'node\t0\tg:bom:pom:1\tg\tbom\t1\tpom\t\t1', + 'node\t0\tg:half:jar:1\tg\thalf\t1\tjar\t\t1', + 'node\t0\tg:halfgone:jar:1\tg\thalfgone\t1\tjar\t\t1', + 'file\t0\tg:ok:jar:1\t/abs/ok.jar', + 'file\t0\tg:half:jar:1\t/abs/half-a.jar', + 'unfetched\t0\tg:half:jar:1', + 'file\t0\tg:halfgone:jar:1\t/abs/halfgone-a.jar', + 'file\t0\tg:halfgone:jar:1\t/abs/gone.jar', + 'file\t0\tg:gone:jar:1\t/abs/gone.jar', + 'file\t0\tg:nojar:test-jar:tests:1\t/abs/nojar-tests.jar', + 'unfetched\t0\tg:nojar:jar:1', + ].join('\n') + const { artifactPaths, facts } = assembleFacts(parseRecords(records), { + fileExists: p => p !== '/abs/gone.jar', + }) + const acc: SidecarAccumulator = new Map() + accumulateSidecar( + acc, + facts, + artifactPaths, + '/abs/pom.xml.socket.facts.json', + ) + const byId = new Map( + serializeSidecar(acc)['/abs/pom.xml.socket.facts.json']!.components.map( + c => [c.id, c], + ), + ) + + expect(byId.get('g:ok:jar:1')?.targets).toEqual(['/abs/ok.jar']) + expect(byId.get('g:nojar:jar:1')).not.toHaveProperty('targets') + expect(byId.get('g:nojar:test-jar:tests:1')?.targets).toEqual([ + '/abs/nojar-tests.jar', + ]) + expect(byId.get('g:gone:jar:1')).not.toHaveProperty('targets') + expect(byId.get('g:bom:pom:1')?.targets).toEqual([]) + // One of several files missing leaves the artifact incomplete. + expect(byId.get('g:half:jar:1')).not.toHaveProperty('targets') + expect(byId.get('g:halfgone:jar:1')).not.toHaveProperty('targets') + }) + it('keeps the targets of an artifact that another root fetched', () => { + const records = [ + 'meta\tmaven\t3.9.6\t17', + 'project\tg:a:1\tg\ta\t1\ta', + 'project\tg:b:1\tg\tb\t1\tb', + 'root\t0\tg:a:1\tcompile\t1', + 'root\t1\tg:b:1\tcompile\t1', + 'node\t0\tg:lib:jar:1\tg\tlib\t1\tjar\t\t1', + 'node\t1\tg:lib:jar:1\tg\tlib\t1\tjar\t\t1', + 'node\t0\tg:moved:jar:1\tg\tmoved\t1\tjar\t\t1', + 'node\t1\tg:moved:jar:1\tg\tmoved\t1\tjar\t\t1', + 'unfetched\t0\tg:lib:jar:1', + 'file\t1\tg:lib:jar:1\t/abs/lib.jar', + 'file\t0\tg:moved:jar:1\t/abs/gone.jar', + 'file\t1\tg:moved:jar:1\t/abs/moved.jar', + ].join('\n') + const { artifactPaths } = assembleFacts(parseRecords(records), { + fileExists: p => p !== '/abs/gone.jar', + }) + + expect(artifactPaths.pathsById.get('g:lib:jar:1')?.targets).toEqual([ + '/abs/lib.jar', + ]) + expect(artifactPaths.pathsById.get('g:moved:jar:1')?.targets).toEqual([ + '/abs/moved.jar', + ]) + }) it('keeps a coordinate with divergent subtrees as one component with a dependency entry per subtree', () => { // :a and :b both depend on `lib`, which pulls a different `dep` version in // each subproject. diff --git a/src/commands/manifest/scripts/facts.mts b/src/commands/manifest/scripts/facts.mts index ca13ac151..6c48d539c 100644 --- a/src/commands/manifest/scripts/facts.mts +++ b/src/commands/manifest/scripts/facts.mts @@ -60,9 +60,10 @@ export type SocketFactsSbomProject = AnyPURL & { // Resolved on-disk paths for a --with-files run, keyed by component or project // id (a project and its own component share one). `targets` = classpath -// entries (jars / module output dirs); `sources` = module source roots. +// entries (jars / module output dirs), absent when the component's artifact +// did not resolve; `sources` = module source roots. export type ResolvedArtifactPaths = { - pathsById: Map + pathsById: Map // Component ids on each project's resolved classpath (union over its // configurations), keyed by project id. classpathByProject: Map diff --git a/src/commands/manifest/scripts/records.mts b/src/commands/manifest/scripts/records.mts index 428e614e3..312a7613e 100644 --- a/src/commands/manifest/scripts/records.mts +++ b/src/commands/manifest/scripts/records.mts @@ -17,6 +17,7 @@ import type { ResolutionFailure, Unscannable } from './resolution-report.mts' // node rootId coordId group name version ext classifier direct(0|1) project // edge rootId parentCoordId childCoordId // file rootId coordId path (--with-files only) +// unfetched rootId coordId (--with-files only; requested artifact not fetched) // scanned config // failure coord detail config projectKey (config empty: no configuration) // unscannable config detail projectKey (config empty: whole project) @@ -45,6 +46,7 @@ export type RawNode = { project: string // --with-files only. targets: string[] + unfetched: boolean } export type RawRoot = { @@ -215,6 +217,7 @@ export function parseRecords(text: string): ParsedRecords { direct: bool(f[8]), project: f[9] ?? '', targets: [], + unfetched: false, }) break } @@ -226,6 +229,13 @@ export function parseRecords(text: string): ParsedRecords { } break } + case 'unfetched': { + const node = root(f[1] ?? '').nodes.get(f[2] ?? '') + if (node) { + node.unfetched = true + } + break + } case 'file': { const node = root(f[1] ?? '').nodes.get(f[2] ?? '') if (node && f[3]) { diff --git a/src/commands/manifest/scripts/sidecar.mts b/src/commands/manifest/scripts/sidecar.mts index fb821d8c1..a19b54212 100644 --- a/src/commands/manifest/scripts/sidecar.mts +++ b/src/commands/manifest/scripts/sidecar.mts @@ -53,15 +53,17 @@ export type SidecarAccumulator = Map< { projects: SidecarProjectEntry[]; components: SidecarComponentEntry[] } > -// `[]` means resolved and found nothing (e.g. a pom/BOM with no artifact). +// `[]` means resolved and found nothing (e.g. a pom/BOM with no artifact); no +// `targets` means the artifact did not resolve. function attachPaths( entry: T, artifactPaths: ResolvedArtifactPaths, -): T & { targets: string[]; sources: string[] } { +): T & { targets?: string[]; sources: string[] } { const paths = artifactPaths.pathsById.get(entry.id) + const targets = paths ? paths.targets : [] return { ...entry, - targets: [...(paths?.targets ?? [])], + ...(targets ? { targets: [...targets] } : {}), sources: [...(paths?.sources ?? [])], } }