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
16 changes: 11 additions & 5 deletions src/commands/manifest/handle-manifest-dynamic-sbom-inference.mts
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,22 @@ export async function handleManifestDynamicSbomInference({
verbose,
})

const result: CResult<RecursiveManifestOutcome[]> = outcomes.some(
o => o.status === 'failed',
)
const result: CResult<RecursiveManifestOutcome[]> = !outcomes.length
? {
ok: false,
code: 1,
message: 'One or more build roots failed to generate Socket facts.',
message:
'No Gradle, sbt, or Maven build root was found beneath the given directory.',
data: outcomes,
}
: { ok: true, data: outcomes }
: outcomes.some(o => o.status === 'failed')
? {
ok: false,
code: 1,
message: 'One or more build roots failed to generate Socket facts.',
data: outcomes,
}
: { ok: true, data: outcomes }

await outputManifestDynamicSbomInference(result, outputKind)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect, it, vi } from 'vitest'

const {
mockGenerateRecursiveManifests,
mockOutputManifestDynamicSbomInference,
} = vi.hoisted(() => ({
mockGenerateRecursiveManifests: vi.fn(),
mockOutputManifestDynamicSbomInference: vi.fn(),
}))

vi.mock('./generate-recursive-manifests.mts', () => ({
generateRecursiveManifests: mockGenerateRecursiveManifests,
}))

vi.mock('./output-manifest-dynamic-sbom-inference.mts', () => ({
outputManifestDynamicSbomInference: mockOutputManifestDynamicSbomInference,
}))

import { handleManifestDynamicSbomInference } from './handle-manifest-dynamic-sbom-inference.mts'

describe('handleManifestDynamicSbomInference', () => {
it('fails closed when no build root is found anywhere', async () => {
mockGenerateRecursiveManifests.mockResolvedValue([])

await handleManifestDynamicSbomInference({
cwd: '/repo',
excludePaths: [],
outputKind: 'text',
verbose: false,
})

expect(mockOutputManifestDynamicSbomInference).toHaveBeenCalledWith(
{
ok: false,
code: 1,
message:
'No Gradle, sbt, or Maven build root was found beneath the given directory.',
data: [],
},
'text',
)
})

it('fails when one or more discovered build roots failed to generate facts', async () => {
const outcomes = [
{
dir: '/repo/service-a',
ecosystem: 'maven',
factsPath: '/repo/service-a/.socket.facts.json',
status: 'generated',
},
{ dir: '/repo/service-b', ecosystem: 'maven', status: 'failed' },
]
mockGenerateRecursiveManifests.mockResolvedValue(outcomes)

await handleManifestDynamicSbomInference({
cwd: '/repo',
excludePaths: [],
outputKind: 'text',
verbose: false,
})

expect(mockOutputManifestDynamicSbomInference).toHaveBeenCalledWith(
{
ok: false,
code: 1,
message: 'One or more build roots failed to generate Socket facts.',
data: outcomes,
},
'text',
)
})

it('succeeds when every discovered build root generated facts', async () => {
const outcomes = [
{
dir: '/repo/service-a',
ecosystem: 'maven',
factsPath: '/repo/service-a/.socket.facts.json',
status: 'generated',
},
]
mockGenerateRecursiveManifests.mockResolvedValue(outcomes)

await handleManifestDynamicSbomInference({
cwd: '/repo',
excludePaths: [],
outputKind: 'json',
verbose: false,
})

expect(mockOutputManifestDynamicSbomInference).toHaveBeenCalledWith(
{ ok: true, data: outcomes },
'json',
)
})
})
14 changes: 14 additions & 0 deletions src/commands/manifest/setup-recursive-manifest-config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,20 @@ export async function setupRecursiveManifestConfig(
)
} else {
logger.log(`No gradle/maven/sbt build roots found beneath ${cwd}.`)
// Distinct from "found some, but --exclude-paths excluded them all" -
// that's a deliberate choice and setup still proceeds normally below.
// Zero build roots anywhere means --dynamic-sbom-inference doesn't apply
// to this target at all, so fail rather than silently do nothing.
const hasAnyBuildRoot = ROOT_ECOSYSTEMS.some(
ecosystem => (fullByTool.get(ecosystem)?.length ?? 0) > 0,
)
if (!hasAnyBuildRoot) {
return {
ok: false,
code: 1,
message: `No Gradle, sbt, or Maven build root was found beneath ${cwd}.`,
}
}
}
logger.log('')

Expand Down
21 changes: 19 additions & 2 deletions src/commands/manifest/setup-recursive-manifest-config.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -747,10 +747,14 @@ describe('setupRecursiveManifestConfig', () => {
vi.mocked(enumerateWorkspaces).mockResolvedValue({ projects: [] })
})

it('asks about nothing and finishes immediately when the scan finds no build roots anywhere', async () => {
it('fails closed when the scan finds no build roots anywhere', async () => {
const result = await setupRecursiveManifestConfig(cwd, false)

expect(result).toEqual({ ok: true, data: { canceled: false } })
expect(result).toEqual({
ok: false,
code: 1,
message: 'No Gradle, sbt, or Maven build root was found beneath /repo.',
})
expect(select).not.toHaveBeenCalled()
expect(setupGradle).not.toHaveBeenCalled()
expect(setupMaven).not.toHaveBeenCalled()
Expand All @@ -759,6 +763,19 @@ describe('setupRecursiveManifestConfig', () => {
expect(findBuildToolCandidates).toHaveBeenCalled()
})

it('still proceeds normally when build roots exist but --exclude-paths excludes all of them (a deliberate choice, not "none exist")', async () => {
vi.mocked(findBuildToolCandidates).mockImplementation(
async ({ excludePaths }) =>
excludePaths?.length
? new Map()
: new Map([['maven', [`${cwd}/service`]]]),
)

const result = await setupRecursiveManifestConfig(cwd, false, ['service'])

expect(result.ok).toBe(true)
})

it('only asks about ecosystems detected somewhere in the tree, with plain phrasing', async () => {
// Maven is detected (a candidate exists, elsewhere in the tree); gradle
// and sbt have none anywhere, so neither should ever be asked about.
Expand Down
21 changes: 16 additions & 5 deletions src/commands/scan/handle-create-new-scan.mts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,19 @@ export async function handleCreateNewScan({
verbose: false,
withFiles: reach.runReachabilityAnalysis,
})
// No candidates discovered at all (distinct from candidates that were
// found but produced no generated facts - empty/skippedDisabled are
// already warned about elsewhere and are not this kind of mistake).
if (!outcomes.length) {
throw new InputError(
[
'No Gradle, sbt, or Maven build root was found.',
'',
'- Remove --dynamic-sbom-inference; it only applies to these ecosystems.',
'- Make sure to run it from the correct dir (use --cwd to target another dir).',
].join('\n'),
)
}
// Fail loud rather than silently upload a partial multi-root scan:
// matches handleManifestDynamicSbomInference's own check.
if (outcomes.some(o => o.status === 'failed')) {
Expand All @@ -197,11 +210,9 @@ export async function handleCreateNewScan({
const generatedFactsPaths = outcomes
.filter(o => o.status === 'generated')
.map(o => o.factsPath!)
if (generatedFactsPaths.length) {
scanTargets = Array.from(
new Set([...scanTargets, ...generatedFactsPaths]),
)
}
scanTargets = Array.from(
new Set([...scanTargets, ...generatedFactsPaths]),
)
if (sidecarAcc && hasSidecarEntries(sidecarAcc)) {
resolvedPathsSidecar = serializeSidecar(sidecarAcc)
}
Expand Down
27 changes: 27 additions & 0 deletions src/commands/scan/handle-create-new-scan.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,33 @@ describe('handleCreateNewScan excludePaths', () => {
expect(mockFetchCreateOrgFullScan).not.toHaveBeenCalled()
})

it('aborts when --dynamic-sbom-inference finds no Gradle/sbt/Maven build root', async () => {
mockGenerateRecursiveManifests.mockResolvedValueOnce([])

const config = createConfig({ autoManifest: true, targets: ['/repo'] })
config.reach.dynamicSbomInference = true

await expect(handleCreateNewScan(config)).rejects.toThrow(
/No Gradle, sbt, or Maven build root was found/,
)
expect(mockGetPackageFilesForScan).not.toHaveBeenCalled()
expect(mockFetchCreateOrgFullScan).not.toHaveBeenCalled()
})

it('does not abort when build roots were found but none generated facts (empty/skippedDisabled), unlike genuinely finding none', async () => {
mockGenerateRecursiveManifests.mockResolvedValueOnce([
{ dir: '/repo/service-a', ecosystem: 'maven', status: 'empty' },
{ dir: '/repo/service-b', ecosystem: 'maven', status: 'skippedDisabled' },
])

const config = createConfig({ autoManifest: true, targets: ['/repo'] })
config.reach.dynamicSbomInference = true

await handleCreateNewScan(config)

expect(mockGetPackageFilesForScan).toHaveBeenCalled()
})

it('accumulates a sidecar across recursively discovered build roots and forwards it to reachability analysis', async () => {
mockGenerateRecursiveManifests.mockImplementationOnce(
async ({ sidecarAcc }) => {
Expand Down
14 changes: 9 additions & 5 deletions src/commands/scan/perform-reachability-analysis.mts
Original file line number Diff line number Diff line change
Expand Up @@ -185,20 +185,22 @@ export async function performReachabilityAnalysis(

const outputFilePath = outputPath || constants.DOT_SOCKET_DOT_FACTS_JSON

// Write the sidecar to a temp file for `--compute-artifacts-sidecar`; cleaned
// up in the finally below.
// Temp file for --compute-artifacts-sidecar, removed in the finally below.
// Written even when empty under dynamicSbomInference, since the
// --maven-use-only-socket-facts flag below requires one to be present.
let sidecarPath: string | undefined
if (
resolvedPathsSidecar &&
hasResolvedPathsSidecarEntries(resolvedPathsSidecar)
reachabilityOptions.dynamicSbomInference ||
(resolvedPathsSidecar &&
hasResolvedPathsSidecarEntries(resolvedPathsSidecar))
) {
sidecarPath = path.join(
tmpdir(),
`socket-compute-artifacts-sidecar-${randomUUID()}.json`,
)
await fs.writeFile(
sidecarPath,
JSON.stringify(resolvedPathsSidecar),
JSON.stringify(resolvedPathsSidecar ?? {}),
'utf8',
)
}
Expand Down Expand Up @@ -256,6 +258,8 @@ export async function performReachabilityAnalysis(
...(reachabilityOptions.reachExcludePaths.length
? ['--exclude-dirs', ...reachabilityOptions.reachExcludePaths]
: []),
// sidecarPath is always set above when this is true - Coana rejects this
// flag without one.
...(reachabilityOptions.dynamicSbomInference
? ['--maven-use-only-socket-facts']
: []),
Expand Down
87 changes: 86 additions & 1 deletion src/commands/scan/perform-reachability-analysis.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* - utils/coana.mts (extractTier1ReachabilityScanId — exercised for real)
*/

import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'

Expand Down Expand Up @@ -224,6 +224,91 @@ describe('performReachabilityAnalysis timeout/memory forwarding', () => {
})
})

describe('performReachabilityAnalysis --maven-use-only-socket-facts gating', () => {
let scanCwd: string

beforeEach(() => {
vi.clearAllMocks()
mockFetchOrganization.mockResolvedValue({
ok: true,
data: { organizations: {} },
})
mockHasEnterpriseOrgPlan.mockReturnValue(true)
mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: '' })
scanCwd = mkdtempSync(path.join(tmpdir(), 'socket-reamvomsf-'))
})

afterEach(() => {
rmSync(scanCwd, { force: true, recursive: true })
})

it('writes an empty sidecar and still passes both flags when dynamicSbomInference is on but no build root produced one (e.g. none found, or all empty/disabled)', async () => {
let sidecarContentAtSpawnTime: unknown
mockSpawnCoanaDlx.mockImplementationOnce(async (args: string[]) => {
const sidecarPath = args[args.indexOf('--compute-artifacts-sidecar') + 1]!
sidecarContentAtSpawnTime = JSON.parse(readFileSync(sidecarPath, 'utf8'))
return { ok: true, data: '' }
})

await performReachabilityAnalysis({
cwd: scanCwd,
reachabilityOptions: {
...makeReachabilityOptions(),
dynamicSbomInference: true,
},
resolvedPathsSidecar: undefined,
target: scanCwd,
})

const args = mockSpawnCoanaDlx.mock.calls[0]![0] as string[]
expect(args).toContain('--maven-use-only-socket-facts')
expect(args).toContain('--compute-artifacts-sidecar')
expect(sidecarContentAtSpawnTime).toEqual({})
})

it('passes --maven-use-only-socket-facts alongside --compute-artifacts-sidecar when dynamicSbomInference is on and a sidecar was generated', async () => {
await performReachabilityAnalysis({
cwd: scanCwd,
reachabilityOptions: {
...makeReachabilityOptions(),
dynamicSbomInference: true,
},
resolvedPathsSidecar: {
'/repo/reactor/.socket.facts.json': {
components: [],
projects: [],
},
},
target: scanCwd,
})

const args = mockSpawnCoanaDlx.mock.calls[0]![0] as string[]
expect(args).toContain('--maven-use-only-socket-facts')
expect(args).toContain('--compute-artifacts-sidecar')
})

it('never passes --maven-use-only-socket-facts when dynamicSbomInference is off, even if a sidecar happens to be present', async () => {
await performReachabilityAnalysis({
cwd: scanCwd,
reachabilityOptions: {
...makeReachabilityOptions(),
dynamicSbomInference: false,
},
resolvedPathsSidecar: {
'/repo/reactor/.socket.facts.json': {
components: [],
projects: [],
},
},
target: scanCwd,
})

const args = mockSpawnCoanaDlx.mock.calls[0]![0] as string[]
expect(args).not.toContain('--maven-use-only-socket-facts')
expect(args).toContain('--compute-artifacts-sidecar')
})
})

describe('performReachabilityAnalysis stdio routing by output kind', () => {
let scanCwd: string

Expand Down