11import { XMLParser } from 'fast-xml-parser'
22
3+ import { resolveEndpoints } from '../../../nuget/client'
34import { ExtractorResult , ProvenanceEntry , RawContact } from '../../types'
45import { extractEmails , fetchJson , fetchText , registryHeaders } from '../http'
56
7+ import { toHandleCandidates } from './handles'
68import { ParsedPurl } from './purl'
79
810const SOURCE = 'nuget-nuspec'
11+ const SEARCH_SOURCE = 'nuget-search'
912const BASE = 'https://api.nuget.org/v3-flatcontainer'
1013const parser = new XMLParser ( { ignoreAttributes : true } )
1114
@@ -45,6 +48,44 @@ export function mapNuspec(xml: string, sourceUrl: string, fetchedAt: string): Ra
4548 return contacts
4649}
4750
51+ // NuGet.org accounts are independent of GitHub, so an owner username matching a GitHub login is
52+ // only a guess — emitted as candidates that must pass repo-contributor corroboration before use.
53+ export function mapNugetOwnerHandles (
54+ doc : unknown ,
55+ packageId : string ,
56+ sourceUrl : string ,
57+ fetchedAt : string ,
58+ ) : RawContact [ ] {
59+ const data = ( doc as any ) ?. data
60+ if ( ! Array . isArray ( data ) ) return [ ]
61+ // The exact package is not guaranteed to be the first result — scan for the id match,
62+ // same as fetchSearch in src/nuget/client.ts.
63+ const lowerId = packageId . toLowerCase ( )
64+ const entry = data . find ( ( e ) => typeof e ?. id === 'string' && e . id . toLowerCase ( ) === lowerId ) as
65+ | Record < string , unknown >
66+ | undefined
67+ if ( ! entry || ! Array . isArray ( entry . owners ) ) return [ ]
68+ return toHandleCandidates ( entry . owners , SEARCH_SOURCE , sourceUrl , fetchedAt )
69+ }
70+
71+ // Owner usernames are only exposed by the search service, not the flatcontainer API; the
72+ // search host is resolved from the V3 service index (it is regional and can rotate).
73+ async function fetchOwnerCandidates (
74+ id : string ,
75+ timeoutMs : number ,
76+ userAgent : string ,
77+ fetchedAt : string ,
78+ ) : Promise < RawContact [ ] > {
79+ try {
80+ const { searchBaseUrl } = await resolveEndpoints ( )
81+ const searchUrl = `${ searchBaseUrl } ?q=packageid:${ encodeURIComponent ( id ) } &prerelease=true&semVerLevel=2.0.0`
82+ const { json } = await fetchJson ( searchUrl , timeoutMs , registryHeaders ( userAgent ) )
83+ return mapNugetOwnerHandles ( json , id , searchUrl , fetchedAt )
84+ } catch {
85+ return [ ]
86+ }
87+ }
88+
4889async function latestStableVersion (
4990 id : string ,
5091 timeoutMs : number ,
@@ -67,11 +108,17 @@ export async function fetchNuget(
67108 userAgent : string ,
68109) : Promise < ExtractorResult > {
69110 const id = parsed . name . toLowerCase ( )
70- const version = await latestStableVersion ( id , timeoutMs , userAgent )
71- if ( ! version ) return { contacts : [ ] , policies : { } }
111+ const fetchedAt = new Date ( ) . toISOString ( )
112+
113+ const [ version , handleCandidates ] = await Promise . all ( [
114+ latestStableVersion ( id , timeoutMs , userAgent ) ,
115+ fetchOwnerCandidates ( id , timeoutMs , userAgent , fetchedAt ) ,
116+ ] )
117+
118+ if ( ! version ) return { contacts : [ ] , policies : { } , handleCandidates }
72119
73120 const url = `${ BASE } /${ id } /${ version } /${ id } .nuspec`
74121 const { text } = await fetchText ( url , timeoutMs , registryHeaders ( userAgent ) )
75- if ( ! text ) return { contacts : [ ] , policies : { } }
76- return { contacts : mapNuspec ( text , url , new Date ( ) . toISOString ( ) ) , policies : { } }
122+ if ( ! text ) return { contacts : [ ] , policies : { } , handleCandidates }
123+ return { contacts : mapNuspec ( text , url , fetchedAt ) , policies : { } , handleCandidates }
77124}
0 commit comments