Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 35 additions & 8 deletions src/commands/manifest/scripts/assemble.mts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ type MergedNode = {
direct: boolean
// projectKey when this node is a build project, else empty.
project: string
targets: Set<string>
// One per resolution root that recorded files or failed to fetch them.
resolutions: Array<{ targets: string[]; unfetched: boolean }>
}

type SubprojectGraph = {
Expand All @@ -52,6 +53,7 @@ type RootNode = {
direct: boolean
project: string
targets: string[]
unfetched: boolean
}

export function assembleFacts(
Expand Down Expand Up @@ -116,10 +118,12 @@ function buildPerRoot(parsed: ParsedRecords): Map<string, PerRoot> {
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) {
Expand Down Expand Up @@ -152,7 +156,7 @@ function mergeById(perRoot: Map<string, PerRoot>): Map<string, MergedNode> {
prod: false,
direct: false,
project: node.project,
targets: new Set(),
resolutions: [],
}
finalNodes.set(coordId, fn)
}
Expand All @@ -162,8 +166,11 @@ function mergeById(perRoot: Map<string, PerRoot>): Map<string, MergedNode> {
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,
})
}
}
}
Expand Down Expand Up @@ -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<string, boolean>()
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
Expand Down
75 changes: 75 additions & 0 deletions src/commands/manifest/scripts/assemble.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions src/commands/manifest/scripts/facts.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { sources: string[]; targets: string[] }>
pathsById: Map<string, { sources: string[]; targets?: string[] | undefined }>
// Component ids on each project's resolved classpath (union over its
// configurations), keyed by project id.
classpathByProject: Map<string, string[]>
Expand Down
10 changes: 10 additions & 0 deletions src/commands/manifest/scripts/records.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -45,6 +46,7 @@ export type RawNode = {
project: string
// --with-files only.
targets: string[]
unfetched: boolean
}

export type RawRoot = {
Expand Down Expand Up @@ -215,6 +217,7 @@ export function parseRecords(text: string): ParsedRecords {
direct: bool(f[8]),
project: f[9] ?? '',
targets: [],
unfetched: false,
})
break
}
Expand All @@ -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]) {
Expand Down
8 changes: 5 additions & 3 deletions src/commands/manifest/scripts/sidecar.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends { id: string }>(
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 ?? [])],
}
}
Expand Down
Loading