11import { ExtractorResult , ProvenanceEntry , RawContact } from '../../types'
2- import { extractEmails , fetchJson , registryHeaders } from '../http'
2+ import { extractEmails , fetchJson , isEmail , registryHeaders } from '../http'
33
44import { ParsedPurl } from './purl'
55
66const SOURCE = 'rubygems'
77
88/* eslint-disable @typescript-eslint/no-explicit-any */
99
10- // RubyGems hides author emails; we can only surface emails when an author string embeds one.
1110// bug_tracker_uri is an issue tracker, not a security contact, so it is intentionally skipped.
1211export function mapRubygems ( doc : unknown , sourceUrl : string , fetchedAt : string ) : RawContact [ ] {
1312 if ( ! doc || typeof doc !== 'object' ) return [ ]
@@ -34,13 +33,64 @@ export function mapRubygems(doc: unknown, sourceUrl: string, fetchedAt: string):
3433 return contacts
3534}
3635
36+ export function mapRubygemsOwners (
37+ owners : unknown ,
38+ sourceUrl : string ,
39+ fetchedAt : string ,
40+ ) : RawContact [ ] {
41+ if ( ! Array . isArray ( owners ) ) return [ ]
42+
43+ const prov = ( ) : ProvenanceEntry [ ] => [
44+ { source : SOURCE , sourceTier : 'B' , path : sourceUrl , fetchedAt } ,
45+ ]
46+ const contacts : RawContact [ ] = [ ]
47+ const seen = new Set < string > ( )
48+ for ( const owner of owners ) {
49+ const email = owner && typeof owner === 'object' ? ( owner as any ) . email : undefined
50+ if ( typeof email !== 'string' || ! isEmail ( email ) ) continue
51+ const key = email . toLowerCase ( )
52+ if ( seen . has ( key ) ) continue
53+ seen . add ( key )
54+ const handle = typeof ( owner as any ) . handle === 'string' ? ( owner as any ) . handle : undefined
55+ contacts . push ( {
56+ channel : 'email' ,
57+ value : email ,
58+ handle,
59+ role : 'maintainer' ,
60+ tier : 'B' ,
61+ provenance : prov ( ) ,
62+ } )
63+ }
64+ return contacts
65+ }
66+
3767export async function fetchRubygems (
3868 parsed : ParsedPurl ,
3969 timeoutMs : number ,
4070 userAgent : string ,
4171) : Promise < ExtractorResult > {
42- const url = `https://rubygems.org/api/v1/gems/${ encodeURIComponent ( parsed . name ) } .json`
43- const { json } = await fetchJson ( url , timeoutMs , registryHeaders ( userAgent ) )
44- if ( ! json ) return { contacts : [ ] , policies : { } }
45- return { contacts : mapRubygems ( json , url , new Date ( ) . toISOString ( ) ) , policies : { } }
72+ const gemUrl = `https://rubygems.org/api/v1/gems/${ encodeURIComponent ( parsed . name ) } .json`
73+ const ownersUrl = `https://rubygems.org/api/v1/gems/${ encodeURIComponent ( parsed . name ) } /owners.json`
74+ const fetchedAt = new Date ( ) . toISOString ( )
75+
76+ const [ gemResult , ownersResult ] = await Promise . all ( [
77+ fetchJson ( gemUrl , timeoutMs , registryHeaders ( userAgent ) ) ,
78+ fetchJson ( ownersUrl , timeoutMs , registryHeaders ( userAgent ) ) . catch ( ( ) => ( {
79+ status : 0 ,
80+ json : null ,
81+ } ) ) ,
82+ ] )
83+
84+ if ( ! gemResult . json ) return { contacts : [ ] , policies : { } }
85+
86+ const contacts = mapRubygems ( gemResult . json , gemUrl , fetchedAt )
87+ const seen = new Set ( contacts . map ( ( c ) => c . value . toLowerCase ( ) ) )
88+ for ( const ownerContact of mapRubygemsOwners ( ownersResult . json , ownersUrl , fetchedAt ) ) {
89+ const key = ownerContact . value . toLowerCase ( )
90+ if ( seen . has ( key ) ) continue
91+ seen . add ( key )
92+ contacts . push ( ownerContact )
93+ }
94+
95+ return { contacts, policies : { } }
4696}
0 commit comments