From c3515335b70c301aed4660a997acc284bf96eac8 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Fri, 23 Oct 2026 13:32:54 +0200 Subject: [PATCH 1/3] fix(manifest): leave a component with a missing artifact file without targets Reachability reads a sidecar component with no targets as not installed, and targets: [] as resolved with no artifact. A component whose files were partly gone from disk got the surviving subset, and one with none got an empty list, so it looked resolved. Producers can now mark an artifact they could not fetch with an unfetched record, and any such or missing file leaves the component without targets. --- CHANGELOG.md | 1 + src/commands/manifest/scripts/assemble.mts | 17 +++++-- .../manifest/scripts/assemble.test.mts | 48 +++++++++++++++++++ src/commands/manifest/scripts/facts.mts | 5 +- src/commands/manifest/scripts/records.mts | 10 ++++ src/commands/manifest/scripts/sidecar.mts | 8 ++-- 6 files changed, 80 insertions(+), 9 deletions(-) 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..defb44500 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -33,6 +33,7 @@ type MergedNode = { // projectKey when this node is a build project, else empty. project: string targets: Set + 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) { @@ -153,6 +157,7 @@ function mergeById(perRoot: Map): Map { direct: false, project: node.project, targets: new Set(), + unfetched: false, } finalNodes.set(coordId, fn) } @@ -162,6 +167,9 @@ function mergeById(perRoot: Map): Map { if (node.direct) { fn.direct = true } + if (node.unfetched) { + fn.unfetched = true + } for (const t of node.targets) { fn.targets.add(t) } @@ -408,10 +416,11 @@ function buildArtifactPaths( const pathsById: ResolvedArtifactPaths['pathsById'] = new Map() for (const [id, fn] of finalNodes) { if (!fn.project) { - pathsById.set(id, { - sources: [], - targets: [...fn.targets].filter(fileExists).sort(), - }) + const targets = [...fn.targets].filter(fileExists).sort() + // An artifact with any file that could not be fetched, or is gone from + // disk, has no targets: a partial list would claim it resolved whole. + const unresolved = fn.unfetched || targets.length < fn.targets.size + pathsById.set(id, unresolved ? { sources: [] } : { sources: [], targets }) } } // 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..908f13693 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -115,6 +115,54 @@ 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 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 ?? [])], } } From 3b542306fea4e2fd221cad40f0974319ba4f9404 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Fri, 23 Oct 2026 17:33:45 +0200 Subject: [PATCH 2/3] fix(manifest): keep an artifact's targets when another root fetched it An unfetched or missing file in one resolution root dropped the targets of the coordinate for every root, so a jar one module fetched looked uninstalled when another module failed it. A coordinate now keeps the files of every root that fetched all of them, and is left without targets only when no root did. Co-Authored-By: Claude Opus 5.5 --- src/commands/manifest/scripts/assemble.mts | 36 +++++++++++-------- .../manifest/scripts/assemble.test.mts | 27 ++++++++++++++ 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index defb44500..41ab7acf7 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -32,8 +32,8 @@ type MergedNode = { direct: boolean // projectKey when this node is a build project, else empty. project: string - targets: Set - unfetched: boolean + // One per resolution root that recorded files or failed to fetch them. + resolutions: Array<{ targets: string[]; unfetched: boolean }> } type SubprojectGraph = { @@ -156,8 +156,7 @@ function mergeById(perRoot: Map): Map { prod: false, direct: false, project: node.project, - targets: new Set(), - unfetched: false, + resolutions: [], } finalNodes.set(coordId, fn) } @@ -167,11 +166,11 @@ function mergeById(perRoot: Map): Map { if (node.direct) { fn.direct = true } - if (node.unfetched) { - fn.unfetched = 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, + }) } } } @@ -416,11 +415,20 @@ function buildArtifactPaths( const pathsById: ResolvedArtifactPaths['pathsById'] = new Map() for (const [id, fn] of finalNodes) { if (!fn.project) { - const targets = [...fn.targets].filter(fileExists).sort() - // An artifact with any file that could not be fetched, or is gone from - // disk, has no targets: a partial list would claim it resolved whole. - const unresolved = fn.unfetched || targets.length < fn.targets.size - pathsById.set(id, unresolved ? { sources: [] } : { sources: [], targets }) + // 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(fileExists), + ) + 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 908f13693..e58ad23c5 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -163,6 +163,33 @@ describe('records → assemble → sidecar', () => { 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. From 9701546e1e22a19f29ebe167fdfedc7737d94ace Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Fri, 23 Oct 2026 18:23:03 +0200 Subject: [PATCH 3/3] perf(manifest): check each artifact path once across resolution roots Co-Authored-By: Claude Opus 5.5 --- src/commands/manifest/scripts/assemble.mts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index 41ab7acf7..2dbd3b352 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -413,12 +413,22 @@ 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) { // 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(fileExists), + r => !r.unfetched && r.targets.every(exists), ) pathsById.set( id,