diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index c9e1dc47a..a478931ec 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -193,15 +193,24 @@ function buildManifestFilesById( ): Map { const buildFilesByCoord = new Map>() for (const [rootId, ids] of directByRoot) { - const projectKey = perRoot.get(rootId)?.projectKey ?? '' - const buildFiles = parsed.projects.get(projectKey)?.buildFiles ?? [] + const root = perRoot.get(rootId) + const project = parsed.projects.get(root?.projectKey ?? '') for (const id of ids) { let set = buildFilesByCoord.get(id) if (!set) { set = new Set() buildFilesByCoord.set(id, set) } - for (const f of buildFiles) { + const coord = root?.nodes.get(id)?.coord + // Without a known declaring script, the project's build file, even one + // absent on disk: its presence marks the dependency direct here. + const declared = + coord && project?.declaredIn.get(`${coord.group}:${coord.name}`) + const files = declared ?? [ + ...(project?.buildFiles ?? []), + ...(project?.missingBuildFiles ?? []), + ] + for (const f of files) { set.add(f) } } @@ -288,8 +297,16 @@ function buildProjects( subprojectDir: p.dir, dependencies: [...(directByProject.get(p.projectKey) ?? [])].sort(), } - if (p.buildFiles.length) { - entry.manifestFiles = [...p.buildFiles].sort().map(file => ({ file })) + // A project without a build file of its own is defined by the scripts + // declaring its dependencies, e.g. the root build script. + const declared = [...new Set([...p.declaredIn.values()].flat())] + const files = p.buildFiles.length + ? p.buildFiles + : declared.length + ? declared + : p.missingBuildFiles + if (files.length) { + entry.manifestFiles = [...files].sort().map(file => ({ file })) } return entry }) diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index f00630685..2d802d959 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -263,6 +263,68 @@ describe('records → assemble → sidecar', () => { 'g:mod-b:1': [{ file: 'mod/b.xml' }], }) }) + it('attributes Gradle direct dependencies to the script declaring them', () => { + const records = [ + 'meta\tgradle\t9.2.1\t21', + 'buildRoot\t/repo', + 'project\t:\tg\troot\t1\t.', + 'projectBuild\t:\tbuild.gradle.kts', + 'project\t:a\tg\ta\t1\ta', + 'declared\t:a\tx\tfrom-root\tbuild.gradle.kts', + 'project\t:b\tg\tb\t1\tb', + 'projectBuild\t:b\tb/build.gradle.kts', + 'declared\t:b\tx\tfrom-root\tbuild.gradle.kts', + 'declared\t:b\tx\town\tb/build.gradle.kts', + 'root\tr1\t:a\truntimeClasspath\t1', + 'node\tr1\tx:from-root:jar:1\tx\tfrom-root\t1\tjar\t\t1', + 'root\tr2\t:b\truntimeClasspath\t1', + 'node\tr2\tx:from-root:jar:1\tx\tfrom-root\t1\tjar\t\t1', + 'node\tr2\tx:own:jar:1\tx\town\t1\tjar\t\t1', + 'node\tr2\tx:by-plugin:jar:1\tx\tby-plugin\t1\tjar\t\t1', + ].join('\n') + const { facts } = assembleFacts(parseRecords(records)) + + expect( + Object.fromEntries( + facts.components.map(c => [ + c.id, + c.manifestFiles?.map(m => m.file).slice(1), + ]), + ), + ).toEqual({ + 'x:by-plugin:jar:1': ['b/build.gradle.kts'], + 'x:from-root:jar:1': ['build.gradle.kts'], + 'x:own:jar:1': ['b/build.gradle.kts'], + }) + expect( + Object.fromEntries( + facts.projects!.map(p => [p.id, p.manifestFiles?.map(m => m.file)]), + ), + ).toEqual({ + ':': ['build.gradle.kts'], + ':a': ['build.gradle.kts'], + ':b': ['b/build.gradle.kts'], + }) + }) + it('falls back to the configured Gradle build file even when absent', () => { + const records = [ + 'meta\tgradle\t9.2.1\t21', + 'buildRoot\t/repo', + 'project\t:a\tg\ta\t1\ta', + 'projectBuild\t:a\ta/build.gradle.kts\tmissing', + 'root\tr1\t:a\truntimeClasspath\t1', + 'node\tr1\tx:undeclared:jar:1\tx\tundeclared\t1\tjar\t\t1', + ].join('\n') + const { facts } = assembleFacts(parseRecords(records)) + + expect(facts.components[0]?.manifestFiles).toEqual([ + { file: '.socket.facts.json' }, + { file: 'a/build.gradle.kts' }, + ]) + expect(facts.projects![0]?.manifestFiles).toEqual([ + { file: 'a/build.gradle.kts' }, + ]) + }) }) describe('parseRecords', () => { diff --git a/src/commands/manifest/scripts/facts.mts b/src/commands/manifest/scripts/facts.mts index 011ee4c43..54825bf65 100644 --- a/src/commands/manifest/scripts/facts.mts +++ b/src/commands/manifest/scripts/facts.mts @@ -30,8 +30,9 @@ export type SocketFactsSbomComponent = AnyPURL & { // A module of the scanned build itself (same GAV as a projects[] entry). firstParty?: true | undefined dependencies?: string[] | undefined - // Direct dependencies only: the facts file plus the build files of the subprojects - // pulling it in directly, which need not declare it (e.g. a parent POM does). + // Direct dependencies only: the facts file plus, per subproject pulling it in + // directly, the Gradle script declaring it, else the subproject's build file + // (which need not declare it, e.g. a parent POM does, nor exist on disk). manifestFiles?: SocketFactsManifestReference[] | undefined } @@ -46,7 +47,9 @@ export type SocketFactsSbomProject = AnyPURL & { id: string subprojectDir: string dependencies: string[] - // The module's own build files, e.g. a POM other than `/pom.xml`. + // The module's own build files, e.g. a POM other than `/pom.xml`; + // for a Gradle project without one, the scripts declaring its dependencies, + // else its configured build file, which may not exist on disk. manifestFiles?: SocketFactsManifestReference[] | undefined } diff --git a/src/commands/manifest/scripts/records.mts b/src/commands/manifest/scripts/records.mts index f757d4bc6..59f3fa84e 100644 --- a/src/commands/manifest/scripts/records.mts +++ b/src/commands/manifest/scripts/records.mts @@ -14,7 +14,8 @@ import type { // project projectKey group name version dir // projectSrc projectKey path (--with-files only) // projectTgt projectKey path (--with-files only) -// projectBuild projectKey path (build-root-relative) +// projectBuild projectKey path [missing] (build-root-relative; `missing`: configured, absent on disk) +// declared projectKey group name path (script declaring the dependency; build-root-relative) // root rootId projectKey config prod(0|1) // node rootId coordId group name version ext classifier direct(0|1) project // edge rootId parentCoordId childCoordId @@ -68,6 +69,10 @@ export type RawProject = { targets: string[] // The project's own build files, build-root-relative. buildFiles: string[] + // Build files the tool configures for the project but absent on disk. + missingBuildFiles: string[] + // "group:name" -> the scripts that declared that dependency on this project. + declaredIn: Map } export type ParsedRecords = { @@ -145,6 +150,8 @@ export function parseRecords(text: string): ParsedRecords { sources: [], targets: [], buildFiles: [], + missingBuildFiles: [], + declaredIn: new Map(), } result.projects.set(key, p) } @@ -185,7 +192,15 @@ export function parseRecords(text: string): ParsedRecords { break case 'projectBuild': if (f[2]) { - project(f[1] ?? '').buildFiles.push(f[2]) + const p = project(f[1] ?? '') + ;(f[3] === 'missing' ? p.missingBuildFiles : p.buildFiles).push(f[2]) + } + break + case 'declared': + if (f[4]) { + const { declaredIn } = project(f[1] ?? '') + const ga = `${f[2] ?? ''}:${f[3] ?? ''}` + declaredIn.set(ga, [...(declaredIn.get(ga) ?? []), f[4]]) } break case 'root': { diff --git a/src/commands/manifest/scripts/socket-facts.init.gradle b/src/commands/manifest/scripts/socket-facts.init.gradle index 1d2e86ed0..90b3437d0 100644 --- a/src/commands/manifest/scripts/socket-facts.init.gradle +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -63,8 +63,65 @@ gradle.ext.socketFactsState = [ projectArtifactExt : Collections.synchronizedMap([:]), // Project path -> "group:name", to tell this build's projects from an included build's. projectGaByPath : Collections.synchronizedMap([:]), + // Project path -> "group:name" -> build-root-relative scripts that declared that dependency. + declaredIn : Collections.synchronizedMap([:]), ] +// Kotlin DSL frames carry only the script's base name; its path is read from the script class +// loader's internal scope id (`kotlin-dsl::`), which needs StackWalker (Java 9+). +gradle.ext.socketInitScript = initscript.sourceFile?.canonicalFile +gradle.ext.socketDeclaringScript = { File rootDir -> + def rootPath = rootDir.canonicalFile.toPath() + def frames = [] + try { + StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE).forEach { f -> + frames << [f.fileName, f.fileName?.endsWith('.kts') ? f.declaringClass.classLoader?.toString() : null] + } + } catch (Throwable ignore) { + frames = new Throwable().stackTrace.collect { f -> [f.fileName, null] } + } + for (fr in frames) { + def path = fr[0] + if (path == null) continue + if (fr[1] != null) { + def m = (fr[1] =~ ('kotlin-dsl:(.+?[\\\\/]' + java.util.regex.Pattern.quote(path) + '):')) + path = m.find() ? m.group(1) : null + if (path == null) continue + } + def f = new File(path) + if (!f.isAbsolute() || !f.isFile()) continue + def c = f.canonicalFile + if (c == gradle.ext.socketInitScript || !c.toPath().startsWith(rootPath)) continue + return rootPath.relativize(c.toPath()).toString().replace(File.separator, '/') + } + null +} + +allprojects { p -> + p.configurations.all { c -> + c.dependencies.whenObjectAdded { d -> + if (d.group == null || d.name == null) return + def script = gradle.ext.socketDeclaringScript.call(p.rootDir) + if (script == null) return + def byGa + synchronized (gradle.socketFactsState.declaredIn) { + byGa = gradle.socketFactsState.declaredIn.get(p.path) + if (byGa == null) { + byGa = [:] + gradle.socketFactsState.declaredIn.put(p.path, byGa) + } + def ga = "${d.group}:${d.name}".toString() + def set = byGa.get(ga) + if (set == null) { + set = [] as Set + byGa.put(ga, set) + } + set << script + } + } + } +} + // Capture every project's (group:name) before collectors run so they can filter intra-project // deps without an ordering dependency on other subprojects. gradle.projectsEvaluated { g -> @@ -117,12 +174,12 @@ gradle.projectsEvaluated { g -> } } } catch (Exception ignore) {} - // `buildFile` is the configured script even when absent on disk (a project configured from the - // root or a convention plugin); only a script that exists is the project's own build file. + // `buildFile` is the configured script, absent on disk for a project configured from the root or + // a convention plugin. Still emitted: a direct dependency's mark must name its subproject. def buildFiles = [] try { def bf = p.buildFile - if (bf != null && bf.isFile()) buildFiles << rel(bf) + if (bf != null) buildFiles << [rel(bf), bf.isFile()] } catch (Exception ignore) {} g.socketFactsState.projectsInfo.add([ path : p.path, @@ -518,7 +575,13 @@ rootProject { rp -> synchronized (state.projectsInfo) { projectsInfo = new ArrayList(state.projectsInfo) } projectsInfo.each { pi -> rec(['project', pi.path, pi.group ?: '', pi.name, pi.version ?: '', pi.dir]) - pi.buildFiles.each { f -> rec(['projectBuild', pi.path, f]) } + pi.buildFiles.each { f -> rec(['projectBuild', pi.path, f[0]] + (f[1] ? [] : ['missing'])) } + def byGa + synchronized (state.declaredIn) { byGa = state.declaredIn.get(pi.path)?.collectEntries { k, v -> [k, new ArrayList(v)] } } + byGa?.keySet()?.sort()?.each { ga -> + def parts = ga.split(':', 2) + byGa[ga].sort().each { f -> rec(['declared', pi.path, parts[0], parts[1], f]) } + } if (withFilesProjects) { pi.sources.each { s -> rec(['projectSrc', pi.path, s]) } pi.targets.each { t -> rec(['projectTgt', pi.path, t]) }