@@ -65,10 +65,51 @@ pub fn get_purl(
6565}
6666
6767/// Converts the `cargo metadata`'s `source` field to a valid PURL `vcs_url`.
68+ ///
69+ /// The `vcs_url` qualifier is specified to use the SPDX Package Download Location format:
70+ /// `<vcs_tool>+<transport>://<host_name>[/<path_to_repository>][@<revision_tag_or_branch>][#<sub_path>]`
71+ ///
72+ /// Cargo metadata uses a different format:
73+ /// `git+<url>[?branch=<branch>|?tag=<tag>|?rev=<rev>]#<commit_hash>`
74+ ///
75+ /// This function strips the query parameters (since the commit hash already identifies the code)
76+ /// and converts the `#commit_hash` to `@commit_hash` per the SPDX format.
77+ ///
6878/// Assumes that the source kind is `git`, panics if it isn't.
6979fn source_to_vcs_url ( source : & cargo_metadata:: Source ) -> String {
7080 assert ! ( source. repr. starts_with( "git+" ) ) ;
71- source. repr . replace ( '#' , "@" )
81+ let url = & source. repr ;
82+ // Find where query parameters start (if any) and where the commit hash fragment starts
83+ let query_start = url. find ( '?' ) ;
84+ let fragment_start = url. find ( '#' ) ;
85+ match ( query_start, fragment_start) {
86+ // Has both query params and commit hash: strip query, keep commit as @
87+ ( Some ( q) , Some ( f) ) => {
88+ let base = & url[ ..q] ;
89+ let commit = & url[ f + 1 ..] ;
90+ format ! ( "{}@{}" , base, commit)
91+ }
92+ // No query params, has commit hash: just replace # with @
93+ ( None , Some ( _) ) => url. replace ( '#' , "@" ) ,
94+ // Has query params but no commit hash: extract the ref value as @
95+ ( Some ( q) , None ) => {
96+ let base = & url[ ..q] ;
97+ let query = & url[ q + 1 ..] ;
98+ // Extract the value from branch=X, tag=X, or rev=X
99+ let ref_value = query
100+ . split ( '&' )
101+ . find_map ( |param| {
102+ param
103+ . strip_prefix ( "branch=" )
104+ . or_else ( || param. strip_prefix ( "tag=" ) )
105+ . or_else ( || param. strip_prefix ( "rev=" ) )
106+ } )
107+ . unwrap_or ( query) ;
108+ format ! ( "{}@{}" , base, ref_value)
109+ }
110+ // No query params, no commit hash: return as-is
111+ ( None , None ) => url. to_string ( ) ,
112+ }
72113}
73114
74115/// Converts a relative path to PURL subpath
@@ -91,6 +132,8 @@ mod tests {
91132
92133 const CRATES_IO_PACKAGE_JSON : & str = include_str ! ( "../tests/fixtures/crates_io_package.json" ) ;
93134 const GIT_PACKAGE_JSON : & str = include_str ! ( "../tests/fixtures/git_package.json" ) ;
135+ const GIT_PACKAGE_WITH_BRANCH_JSON : & str =
136+ include_str ! ( "../tests/fixtures/git_package_with_branch.json" ) ;
94137 const ROOT_PACKAGE_JSON : & str = include_str ! ( "../tests/fixtures/root_package.json" ) ;
95138 const WORKSPACE_PACKAGE_JSON : & str = include_str ! ( "../tests/fixtures/workspace_package.json" ) ;
96139
@@ -129,6 +172,32 @@ mod tests {
129172 assert ! ( parsed_purl. namespace( ) . is_none( ) ) ;
130173 }
131174
175+ #[ test]
176+ fn git_purl_with_branch ( ) {
177+ let git_package: Package = serde_json:: from_str ( GIT_PACKAGE_WITH_BRANCH_JSON ) . unwrap ( ) ;
178+ let purl = get_purl ( & git_package, & git_package, Utf8Path :: new ( "/foo/bar" ) , None ) . unwrap ( ) ;
179+ // Validate that data roundtripped correctly
180+ let parsed_purl = Purl :: from_str ( purl. as_ref ( ) ) . unwrap ( ) ;
181+ assert_eq ! ( parsed_purl. name( ) , "rav1d" ) ;
182+ assert_eq ! ( parsed_purl. version( ) , Some ( "1.1.0" ) ) ;
183+ assert_eq ! ( parsed_purl. qualifiers( ) . len( ) , 1 ) ;
184+ let ( qualifier, value) = parsed_purl. qualifiers ( ) . iter ( ) . next ( ) . unwrap ( ) ;
185+ assert_eq ! ( qualifier. as_str( ) , "vcs_url" ) ;
186+ // The ?branch= query param must be stripped; only the commit hash remains after @
187+ let decoded_value = percent_decode ( value. as_bytes ( ) )
188+ . decode_utf8 ( )
189+ . unwrap ( )
190+ . to_string ( ) ;
191+ assert_eq ! (
192+ decoded_value,
193+ "git+https://github.com/leo030303/rav1d.git@3a50834ce3743bc580f340ba3bfbdbf6a46ab783"
194+ ) ;
195+ // Ensure ?branch= is NOT present in the vcs_url
196+ assert ! ( !decoded_value. contains( "?branch=" ) ) ;
197+ assert ! ( parsed_purl. subpath( ) . is_none( ) ) ;
198+ assert ! ( parsed_purl. namespace( ) . is_none( ) ) ;
199+ }
200+
132201 #[ test]
133202 fn toplevel_package_purl ( ) {
134203 let root_package: Package = serde_json:: from_str ( ROOT_PACKAGE_JSON ) . unwrap ( ) ;
0 commit comments