@@ -3,11 +3,23 @@ import { XMLParser } from 'fast-xml-parser'
33import { ExtractorResult , ProvenanceEntry , RawContact } from '../../types'
44import { fetchText , isEmail , registryHeaders } from '../http'
55
6+ import { toHandleCandidates } from './handles'
67import { ParsedPurl } from './purl'
78
89const SOURCE = 'maven-pom'
910const BASE = 'https://repo1.maven.org/maven2'
10- const parser = new XMLParser ( { ignoreAttributes : true } )
11+ // parseTagValue: false — the default coerces version strings to numbers ("3.0" -> 3,
12+ // "1.10" -> 1.1), which builds wrong POM URLs for any artifact with a trailing-zero version.
13+ const parser = new XMLParser ( { ignoreAttributes : true , parseTagValue : false } )
14+
15+ // Developer emails often live only in a shared parent POM (jackson-bom, eclipse-ee4j:project,
16+ // commons-parent, …), so when a leaf yields none the parent chain is walked. Generic convenience
17+ // parents (sonatype oss-parent, spring-boot-starter-parent) would donate maintainers unrelated
18+ // to the project, so each level must prove relatedness before its contacts are accepted: shared
19+ // groupId prefix (Central groups are verified publisher namespaces) or scm/url pointing at the
20+ // target repo's owner. An unrelated level stops the walk — anything above it is even less related.
21+ const MAX_PARENT_DEPTH = 5
22+ const PARENT_CACHE_MAX = 5000
1123
1224/* eslint-disable @typescript-eslint/no-explicit-any */
1325
@@ -16,31 +28,45 @@ function asArray<T>(x: T | T[] | undefined): T[] {
1628 return Array . isArray ( x ) ? x : [ x ]
1729}
1830
19- export function mapMavenPom ( xml : string , sourceUrl : string , fetchedAt : string ) : RawContact [ ] {
31+ function parseProject ( xml : string ) : any | null {
2032 let doc : any
2133 try {
2234 doc = parser . parse ( xml )
2335 } catch {
24- return [ ]
36+ return null
2537 }
26- const project = doc ?. project
27- if ( ! project ) return [ ]
38+ return doc ?. project ?? null
39+ }
2840
41+ // Apache POMs conventionally obfuscate developer emails ("ggregory at apache.org",
42+ // "mikl at apache dot org", "x (a) gmail.com"). A rewrite is only accepted when the result is a
43+ // valid address, so free-form prose in the email field cannot produce a contact.
44+ export function deobfuscateEmail ( value : string ) : string | null {
45+ const trimmed = value . trim ( )
46+ if ( isEmail ( trimmed ) ) return trimmed
47+ let v = trimmed
48+ v = v . replace ( / \s * [ ( [ ] (?: a | a t ) [ ) \] ] \s * | \s + a t \s + / gi, '@' )
49+ v = v . replace ( / \s * [ ( [ ] (?: d | d o t ) [ ) \] ] \s * | \s + d o t \s + / gi, '.' )
50+ return isEmail ( v ) ? v : null
51+ }
52+
53+ function mapProjectDevelopers ( project : any , sourceUrl : string , fetchedAt : string ) : RawContact [ ] {
2954 const prov = ( ) : ProvenanceEntry [ ] => [
3055 { source : SOURCE , sourceTier : 'B' , path : sourceUrl , fetchedAt } ,
3156 ]
3257 const contacts : RawContact [ ] = [ ]
3358 const seen = new Set < string > ( )
3459 for ( const dev of asArray ( project . developers ?. developer ) ) {
35- const email = dev ?. email
36- if ( typeof email !== 'string' || ! isEmail ( email ) ) continue
60+ const raw = ( dev as any ) ?. email
61+ const email = typeof raw === 'string' ? deobfuscateEmail ( raw ) : null
62+ if ( ! email ) continue
3763 const key = email . toLowerCase ( )
3864 if ( seen . has ( key ) ) continue
3965 seen . add ( key )
4066 contacts . push ( {
4167 channel : 'email' ,
4268 value : email ,
43- name : typeof dev . name === 'string' ? dev . name : undefined ,
69+ name : typeof ( dev as any ) . name === 'string' ? ( dev as any ) . name : undefined ,
4470 role : 'maintainer' ,
4571 tier : 'B' ,
4672 provenance : prov ( ) ,
@@ -49,6 +75,43 @@ export function mapMavenPom(xml: string, sourceUrl: string, fetchedAt: string):
4975 return contacts
5076}
5177
78+ // Developer <id>s are frequently GitHub logins but Central does not guarantee it (Apache ids
79+ // are LDAP usernames) — emitted as candidates for repo-contributor corroboration, and only for
80+ // developers that did not already yield an email contact.
81+ function mapProjectDeveloperHandles (
82+ project : any ,
83+ sourceUrl : string ,
84+ fetchedAt : string ,
85+ ) : RawContact [ ] {
86+ const handles : unknown [ ] = [ ]
87+ for ( const dev of asArray ( project . developers ?. developer ) ) {
88+ const raw = ( dev as any ) ?. email
89+ if ( typeof raw === 'string' && deobfuscateEmail ( raw ) ) continue
90+ handles . push ( ( dev as any ) ?. id )
91+ }
92+ return toHandleCandidates ( handles , SOURCE , sourceUrl , fetchedAt )
93+ }
94+
95+ export function mapMavenPom ( xml : string , sourceUrl : string , fetchedAt : string ) : RawContact [ ] {
96+ const project = parseProject ( xml )
97+ if ( ! project ) return [ ]
98+ return mapProjectDevelopers ( project , sourceUrl , fetchedAt )
99+ }
100+
101+ export function pickVersion ( metadataXml : string ) : string | null {
102+ let doc : any
103+ try {
104+ doc = parser . parse ( metadataXml )
105+ } catch {
106+ return null
107+ }
108+ const versioning = doc ?. metadata ?. versioning
109+ const release = versioning ?. release ?? versioning ?. latest
110+ if ( typeof release === 'string' && release . length > 0 ) return release
111+ const versions = asArray ( versioning ?. versions ?. version )
112+ return versions . length ? String ( versions [ versions . length - 1 ] ) : null
113+ }
114+
52115async function resolveVersion (
53116 groupPath : string ,
54117 artifact : string ,
@@ -58,23 +121,153 @@ async function resolveVersion(
58121 const url = `${ BASE } /${ groupPath } /${ artifact } /maven-metadata.xml`
59122 const { text } = await fetchText ( url , timeoutMs , registryHeaders ( userAgent ) )
60123 if ( ! text ) return null
61- let doc : any
62- try {
63- doc = parser . parse ( text )
64- } catch {
124+ return pickVersion ( text )
125+ }
126+
127+ interface ParentRef {
128+ group : string
129+ artifact : string
130+ version : string
131+ }
132+
133+ function parentRef ( project : any ) : ParentRef | null {
134+ const p = project ?. parent
135+ if (
136+ typeof p ?. groupId !== 'string' ||
137+ typeof p ?. artifactId !== 'string' ||
138+ typeof p ?. version !== 'string'
139+ ) {
65140 return null
66141 }
67- const versioning = doc ?. metadata ?. versioning
68- const release = versioning ?. release ?? versioning ?. latest
69- if ( typeof release === 'string' ) return release
70- const versions = asArray ( versioning ?. versions ?. version )
71- return versions . length ? String ( versions [ versions . length - 1 ] ) : null
142+ // unflattened CI-friendly versions (${revision}) cannot be resolved to a registry path
143+ if ( p . version . includes ( '${' ) ) return null
144+ return { group : p . groupId , artifact : p . artifactId , version : p . version }
145+ }
146+
147+ function groupRelated ( leafGroup : string , parentGroup : string ) : boolean {
148+ return (
149+ parentGroup === leafGroup ||
150+ leafGroup . startsWith ( `${ parentGroup } .` ) ||
151+ parentGroup . startsWith ( `${ leafGroup } .` )
152+ )
153+ }
154+
155+ // Owner segment of a forge URL; Apache's svn/gitbox/git-wip hosts all map to the "apache" owner
156+ // so github.com/apache scm entries match repos hosted on Apache infrastructure. The host must
157+ // start at a boundary so lookalikes (notgithub.com, fake-apache.org) cannot pass the gate.
158+ function repoOwnerOf ( url : string ) : string | null {
159+ const m = / (?: ^ | [ / @ . ] ) (?: g i t h u b \. c o m | g i t l a b \. c o m | b i t b u c k e t \. o r g ) [: / ] + ( [ ^ / : ] + ) [ / : ] / i. exec ( url )
160+ if ( m ) return m [ 1 ] . toLowerCase ( )
161+ if ( / (?: ^ | [ / @ . ] ) a p a c h e \. o r g (?: [: / ] | $ ) / i. test ( url ) ) return 'apache'
162+ return null
163+ }
164+
165+ function scmOwners ( project : any ) : string [ ] {
166+ const urls = [
167+ project ?. scm ?. url ,
168+ project ?. scm ?. connection ,
169+ project ?. scm ?. developerConnection ,
170+ project ?. url ,
171+ ]
172+ const owners = new Set < string > ( )
173+ for ( const u of urls ) {
174+ if ( typeof u !== 'string' ) continue
175+ const owner = repoOwnerOf ( u )
176+ if ( owner ) owners . add ( owner )
177+ }
178+ return [ ...owners ]
179+ }
180+
181+ interface ParentPom {
182+ group : string
183+ contacts : RawContact [ ]
184+ handles : RawContact [ ]
185+ owners : string [ ]
186+ parent : ParentRef | null
187+ }
188+
189+ // The same few parents sit above thousands of artifacts, so results are memoized per gav for
190+ // the worker's lifetime. Failed fetches are evicted so a transient error is not pinned.
191+ const parentPomCache = new Map < string , Promise < ParentPom | null > > ( )
192+
193+ async function fetchParentPom (
194+ ref : ParentRef ,
195+ timeoutMs : number ,
196+ userAgent : string ,
197+ ) : Promise < ParentPom | null > {
198+ const groupPath = ref . group . replace ( / \. / g, '/' )
199+ const url = `${ BASE } /${ groupPath } /${ ref . artifact } /${ ref . version } /${ ref . artifact } -${ ref . version } .pom`
200+ const { status, text } = await fetchText ( url , timeoutMs , registryHeaders ( userAgent ) )
201+ // null only for genuinely-absent statuses (immutable gav → safe to negative-cache); a 200
202+ // with an empty or unparseable body is treated as an error so the cache evicts it.
203+ if ( ! text ) {
204+ if ( status === 200 ) throw new Error ( `empty parent POM body: ${ url } ` )
205+ return null
206+ }
207+ const project = parseProject ( text )
208+ if ( ! project ) throw new Error ( `unparseable parent POM: ${ url } ` )
209+ const fetchedAt = new Date ( ) . toISOString ( )
210+ return {
211+ group : typeof project . groupId === 'string' ? project . groupId : ref . group ,
212+ contacts : mapProjectDevelopers ( project , url , fetchedAt ) ,
213+ handles : mapProjectDeveloperHandles ( project , url , fetchedAt ) ,
214+ owners : scmOwners ( project ) ,
215+ parent : parentRef ( project ) ,
216+ }
217+ }
218+
219+ function getParentPom (
220+ ref : ParentRef ,
221+ timeoutMs : number ,
222+ userAgent : string ,
223+ ) : Promise < ParentPom | null > {
224+ const key = `${ ref . group } :${ ref . artifact } :${ ref . version } `
225+ const cached = parentPomCache . get ( key )
226+ if ( cached ) return cached
227+ const pending = fetchParentPom ( ref , timeoutMs , userAgent ) . catch ( ( ) => {
228+ parentPomCache . delete ( key )
229+ return null
230+ } )
231+ if ( parentPomCache . size >= PARENT_CACHE_MAX ) {
232+ const oldest = parentPomCache . keys ( ) . next ( ) . value
233+ if ( oldest !== undefined ) parentPomCache . delete ( oldest )
234+ }
235+ parentPomCache . set ( key , pending )
236+ return pending
237+ }
238+
239+ async function traverseParents (
240+ leafProject : any ,
241+ leafGroup : string ,
242+ repoUrl : string | undefined ,
243+ timeoutMs : number ,
244+ userAgent : string ,
245+ ) : Promise < { contacts : RawContact [ ] ; handleCandidates : RawContact [ ] } > {
246+ const repoOwner = repoUrl ? repoOwnerOf ( repoUrl ) : null
247+ const handleCandidates : RawContact [ ] = [ ]
248+ const seen = new Set < string > ( )
249+ let ref = parentRef ( leafProject )
250+ for ( let depth = 0 ; ref !== null && depth < MAX_PARENT_DEPTH ; depth ++ ) {
251+ const key = `${ ref . group } :${ ref . artifact } :${ ref . version } `
252+ if ( seen . has ( key ) ) break
253+ seen . add ( key )
254+ const pom = await getParentPom ( ref , timeoutMs , userAgent )
255+ if ( ! pom ) break
256+ const related =
257+ groupRelated ( leafGroup , pom . group ) || ( repoOwner !== null && pom . owners . includes ( repoOwner ) )
258+ if ( ! related ) break
259+ handleCandidates . push ( ...pom . handles )
260+ if ( pom . contacts . length > 0 ) return { contacts : pom . contacts , handleCandidates }
261+ ref = pom . parent
262+ }
263+ return { contacts : [ ] , handleCandidates }
72264}
73265
74266export async function fetchMaven (
75267 parsed : ParsedPurl ,
76268 timeoutMs : number ,
77269 userAgent : string ,
270+ repoUrl ?: string ,
78271) : Promise < ExtractorResult > {
79272 if ( ! parsed . namespace ) return { contacts : [ ] , policies : { } }
80273 const groupPath = parsed . namespace . replace ( / \. / g, '/' )
@@ -86,5 +279,22 @@ export async function fetchMaven(
86279 const url = `${ BASE } /${ groupPath } /${ artifact } /${ version } /${ artifact } -${ version } .pom`
87280 const { text } = await fetchText ( url , timeoutMs , registryHeaders ( userAgent ) )
88281 if ( ! text ) return { contacts : [ ] , policies : { } }
89- return { contacts : mapMavenPom ( text , url , new Date ( ) . toISOString ( ) ) , policies : { } }
282+ const project = parseProject ( text )
283+ if ( ! project ) return { contacts : [ ] , policies : { } }
284+
285+ const fetchedAt = new Date ( ) . toISOString ( )
286+ let contacts = mapProjectDevelopers ( project , url , fetchedAt )
287+ const handleCandidates = mapProjectDeveloperHandles ( project , url , fetchedAt )
288+ if ( contacts . length === 0 ) {
289+ const inherited = await traverseParents (
290+ project ,
291+ parsed . namespace ,
292+ repoUrl ,
293+ timeoutMs ,
294+ userAgent ,
295+ )
296+ contacts = inherited . contacts
297+ handleCandidates . push ( ...inherited . handleCandidates )
298+ }
299+ return { contacts, policies : { } , handleCandidates }
90300}
0 commit comments