1- use std:: { collections:: HashMap , env, fs, path:: PathBuf } ;
1+ use std:: { collections:: HashMap , env, ffi :: OsString , fs, path:: PathBuf } ;
22
33use cow_utils:: CowUtils ;
44use reqwest:: { RequestBuilder , Url } ;
55use vp_shared:: EnvConfig ;
6+ use vt_path:: AbsolutePath ;
67use vt_workspace:: find_workspace_root;
78
89const DEFAULT_NPM_REGISTRY : & str = "https://registry.npmjs.org" ;
@@ -16,11 +17,20 @@ pub(crate) struct NpmConfig {
1617
1718impl NpmConfig {
1819 pub ( crate ) fn load ( ) -> Self {
19- let project_root = vt_path:: current_dir ( )
20+ vt_path:: current_dir ( )
2021 . ok ( )
21- . and_then ( |cwd| find_workspace_root ( & cwd) . ok ( ) )
22- . map ( |( root, _) | root. path . as_path ( ) . to_path_buf ( ) ) ;
23- Self :: load_for_project ( project_root)
22+ . map_or_else ( || Self :: load_for_project ( None ) , |cwd| Self :: load_for_cwd ( & cwd) )
23+ }
24+
25+ pub ( crate ) fn load_for_cwd ( cwd : & AbsolutePath ) -> Self {
26+ find_workspace_root ( cwd) . map_or_else (
27+ |_| Self :: load_for_project ( None ) ,
28+ |( root, _) | Self :: load_for_project_root ( & root. path ) ,
29+ )
30+ }
31+
32+ pub ( crate ) fn load_for_project_root ( project_root : & AbsolutePath ) -> Self {
33+ Self :: load_for_project ( Some ( project_root. as_path ( ) . to_path_buf ( ) ) )
2434 }
2535
2636 fn load_for_project ( project_root : Option < PathBuf > ) -> Self {
@@ -40,12 +50,8 @@ impl NpmConfig {
4050 }
4151
4252 // npm_config_* is the highest-precedence npm config source available to vp.
43- for ( key, value) in env:: vars ( ) {
44- let Some ( raw_key) =
45- key. strip_prefix ( "npm_config_" ) . or_else ( || key. strip_prefix ( "NPM_CONFIG_" ) )
46- else {
47- continue ;
48- } ;
53+ for ( key, value) in npm_config_env ( ) {
54+ let raw_key = & key[ "npm_config_" . len ( ) ..] ;
4955 if value. is_empty ( ) {
5056 continue ;
5157 }
@@ -60,15 +66,34 @@ impl NpmConfig {
6066 Self { values }
6167 }
6268
63- fn registry_for_package ( & self , package : & str ) -> String {
69+ pub ( crate ) fn registry_for_package ( & self , package : & str ) -> String {
6470 let scoped = package
6571 . strip_prefix ( '@' )
6672 . and_then ( |rest| rest. split_once ( '/' ) )
67- . and_then ( |( scope, _) | self . values . get ( vt_str:: format!( "@{scope}:registry" ) . as_str ( ) ) ) ;
68- scoped. or_else ( || self . values . get ( "registry" ) ) . map_or_else (
69- || DEFAULT_NPM_REGISTRY . to_string ( ) ,
70- |value| value. trim_end_matches ( '/' ) . to_string ( ) ,
71- )
73+ . and_then ( |( scope, _) | self . values . get ( vt_str:: format!( "@{scope}:registry" ) . as_str ( ) ) )
74+ . filter ( |value| !value. is_empty ( ) ) ;
75+ scoped
76+ . or_else ( || self . values . get ( "registry" ) . filter ( |value| !value. is_empty ( ) ) )
77+ . map_or_else (
78+ || DEFAULT_NPM_REGISTRY . to_string ( ) ,
79+ |value| value. trim_end_matches ( '/' ) . to_string ( ) ,
80+ )
81+ }
82+
83+ pub ( crate ) fn package_tgz_url ( & self , name : & str , version : & str ) -> vt_str:: Str {
84+ let registry = self . registry_for_package ( name) ;
85+ let filename = name. split ( '/' ) . next_back ( ) . unwrap_or ( name) ;
86+ vt_str:: format!( "{registry}/{name}/-/{filename}-{version}.tgz" )
87+ }
88+
89+ pub ( crate ) fn package_version_url ( & self , name : & str , version_or_tag : & str ) -> vt_str:: Str {
90+ let registry = self . registry_for_package ( name) ;
91+ vt_str:: format!( "{registry}/{name}/{version_or_tag}" )
92+ }
93+
94+ pub ( crate ) fn package_metadata_url ( & self , name : & str ) -> vt_str:: Str {
95+ let registry = self . registry_for_package ( name) ;
96+ vt_str:: format!( "{registry}/{name}" )
7297 }
7398
7499 pub ( crate ) fn apply_auth ( & self , request : RequestBuilder , url : & str ) -> RequestBuilder {
@@ -95,10 +120,13 @@ impl NpmConfig {
95120 for prefix in [ prefix. as_str ( ) , prefix. trim_end_matches ( '/' ) ] {
96121 if let Some ( token) =
97122 self . values . get ( vt_str:: format!( "{prefix}:_authtoken" ) . as_str ( ) )
123+ && !token. is_empty ( )
98124 {
99125 return request. bearer_auth ( token) ;
100126 }
101- if let Some ( auth) = self . values . get ( vt_str:: format!( "{prefix}:_auth" ) . as_str ( ) ) {
127+ if let Some ( auth) = self . values . get ( vt_str:: format!( "{prefix}:_auth" ) . as_str ( ) )
128+ && !auth. is_empty ( )
129+ {
102130 return request. header (
103131 reqwest:: header:: AUTHORIZATION ,
104132 vt_str:: format!( "Basic {auth}" ) . as_str ( ) ,
@@ -107,6 +135,8 @@ impl NpmConfig {
107135 let username = self . values . get ( vt_str:: format!( "{prefix}:username" ) . as_str ( ) ) ;
108136 let password = self . values . get ( vt_str:: format!( "{prefix}:_password" ) . as_str ( ) ) ;
109137 if let ( Some ( username) , Some ( password) ) = ( username, password)
138+ && !username. is_empty ( )
139+ && !password. is_empty ( )
110140 && let Ok ( decoded) = base64_simd:: STANDARD . decode_to_vec ( password)
111141 {
112142 return request
@@ -119,11 +149,25 @@ impl NpmConfig {
119149}
120150
121151fn env_value ( name : & str ) -> Option < String > {
122- env:: vars ( ) . find_map ( |( key, value) | {
123- key. strip_prefix ( "npm_config_" )
124- . or_else ( || key. strip_prefix ( "NPM_CONFIG_" ) )
125- . filter ( |key| key. eq_ignore_ascii_case ( name) )
126- . map ( |_| value)
152+ npm_config_env ( ) . find_map ( |( key, value) | {
153+ key[ "npm_config_" . len ( ) ..]
154+ . eq_ignore_ascii_case ( name)
155+ . then_some ( value)
156+ . filter ( |value| !value. is_empty ( ) )
157+ } )
158+ }
159+
160+ fn npm_config_env ( ) -> impl Iterator < Item = ( String , String ) > {
161+ npm_config_env_from ( env:: vars_os ( ) )
162+ }
163+
164+ fn npm_config_env_from (
165+ vars : impl Iterator < Item = ( OsString , OsString ) > ,
166+ ) -> impl Iterator < Item = ( String , String ) > {
167+ vars. filter_map ( |( key, value) | {
168+ let key = key. into_string ( ) . ok ( ) ?;
169+ let value = value. into_string ( ) . ok ( ) ?;
170+ key. get ( .."npm_config_" . len ( ) ) ?. eq_ignore_ascii_case ( "npm_config_" ) . then_some ( ( key, value) )
127171 } )
128172}
129173
@@ -183,38 +227,51 @@ fn load_npmrc(path: PathBuf, values: &mut HashMap<String, String>) {
183227 let Some ( ( key, value) ) = line. split_once ( '=' ) else { continue } ;
184228 let key = normalize_key ( key) ;
185229 if !key. is_empty ( ) {
186- values. insert ( key, expand_value ( value) ) ;
230+ values. insert ( key, expand_value ( & parse_npmrc_value ( value) ) ) ;
187231 }
188232 }
189233}
190234
191- /// Get the configured default NPM registry URL.
192- #[ must_use]
193- pub fn npm_registry ( ) -> String {
194- NpmConfig :: load ( ) . registry_for_package ( "" )
195- }
196-
197- fn npm_registry_for_package ( name : & str ) -> String {
198- NpmConfig :: load ( ) . registry_for_package ( name)
199- }
200-
201- #[ must_use]
202- pub ( crate ) fn get_npm_package_tgz_url ( name : & str , version : & str ) -> vt_str:: Str {
203- let registry = npm_registry_for_package ( name) ;
204- let filename = name. split ( '/' ) . next_back ( ) . unwrap_or ( name) ;
205- vt_str:: format!( "{registry}/{name}/-/{filename}-{version}.tgz" )
206- }
235+ fn parse_npmrc_value ( value : & str ) -> String {
236+ let value = value. trim ( ) ;
237+ if value. len ( ) >= 2 && value. starts_with ( '"' ) && value. ends_with ( '"' ) {
238+ return serde_json:: from_str ( value)
239+ . unwrap_or_else ( |_| value[ 1 ..value. len ( ) - 1 ] . to_string ( ) ) ;
240+ }
241+ if value. len ( ) >= 2 && value. starts_with ( '\'' ) && value. ends_with ( '\'' ) {
242+ return value[ 1 ..value. len ( ) - 1 ] . to_string ( ) ;
243+ }
207244
208- #[ must_use]
209- pub ( crate ) fn get_npm_package_version_url ( name : & str , version_or_tag : & str ) -> vt_str:: Str {
210- let registry = npm_registry_for_package ( name) ;
211- vt_str:: format!( "{registry}/{name}/{version_or_tag}" )
245+ let mut parsed = String :: with_capacity ( value. len ( ) ) ;
246+ let mut escaped = false ;
247+ for character in value. chars ( ) {
248+ if escaped {
249+ if !matches ! ( character, '\\' | '#' | ';' ) {
250+ parsed. push ( '\\' ) ;
251+ }
252+ parsed. push ( character) ;
253+ escaped = false ;
254+ continue ;
255+ }
256+ if character == '\\' {
257+ escaped = true ;
258+ continue ;
259+ }
260+ if matches ! ( character, '#' | ';' ) {
261+ break ;
262+ }
263+ parsed. push ( character) ;
264+ }
265+ if escaped {
266+ parsed. push ( '\\' ) ;
267+ }
268+ parsed. trim_end ( ) . to_string ( )
212269}
213270
271+ /// Get the configured default NPM registry URL.
214272#[ must_use]
215- pub ( crate ) fn get_npm_package_metadata_url ( name : & str ) -> vt_str:: Str {
216- let registry = npm_registry_for_package ( name) ;
217- vt_str:: format!( "{registry}/{name}" )
273+ pub fn npm_registry ( ) -> String {
274+ NpmConfig :: load ( ) . registry_for_package ( "" )
218275}
219276
220277#[ cfg( test) ]
@@ -248,6 +305,68 @@ mod tests {
248305 } ) ;
249306 }
250307
308+ #[ test]
309+ fn loads_registry_from_caller_provided_workspace ( ) {
310+ let project = project_with_npmrc ( "registry=https://target.example\n " ) ;
311+ let cwd = AbsolutePath :: new ( project. path ( ) ) . unwrap ( ) ;
312+ EnvConfig :: with_vars ( std:: iter:: empty :: < ( & str , & str ) > ( ) , |_| {
313+ let config = NpmConfig :: load_for_cwd ( cwd) ;
314+ assert_eq ! ( config. registry_for_package( "pnpm" ) , "https://target.example" ) ;
315+ } ) ;
316+ }
317+
318+ #[ test]
319+ fn empty_registry_values_fall_back ( ) {
320+ let config = NpmConfig {
321+ values : HashMap :: from ( [
322+ ( "@yarnpkg:registry" . to_string ( ) , String :: new ( ) ) ,
323+ ( "registry" . to_string ( ) , "https://default.example/" . to_string ( ) ) ,
324+ ] ) ,
325+ } ;
326+ assert_eq ! ( config. registry_for_package( "@yarnpkg/cli-dist" ) , "https://default.example" ) ;
327+
328+ let config = NpmConfig { values : HashMap :: from ( [ ( "registry" . to_string ( ) , String :: new ( ) ) ] ) } ;
329+ assert_eq ! ( config. registry_for_package( "pnpm" ) , DEFAULT_NPM_REGISTRY ) ;
330+ }
331+
332+ #[ test]
333+ fn empty_userconfig_environment_value_is_ignored ( ) {
334+ EnvConfig :: with_vars ( [ ( "NPM_CONFIG_USERCONFIG" , "" ) ] , |_| {
335+ assert_eq ! ( env_value( "userconfig" ) , None ) ;
336+ } ) ;
337+ }
338+
339+ #[ test]
340+ fn npm_config_environment_prefix_is_case_insensitive ( ) {
341+ let values = npm_config_env_from (
342+ [ ( OsString :: from ( "Npm_Config_Registry" ) , OsString :: from ( "https://example.test" ) ) ]
343+ . into_iter ( ) ,
344+ )
345+ . collect :: < Vec < _ > > ( ) ;
346+ assert_eq ! (
347+ values,
348+ vec![ ( "Npm_Config_Registry" . to_string( ) , "https://example.test" . to_string( ) ) ]
349+ ) ;
350+ }
351+
352+ #[ cfg( unix) ]
353+ #[ test]
354+ fn non_unicode_environment_entries_are_skipped ( ) {
355+ use std:: os:: unix:: ffi:: OsStringExt ;
356+
357+ let values = npm_config_env_from (
358+ [
359+ ( OsString :: from_vec ( vec ! [ 0xff ] ) , OsString :: from ( "ignored" ) ) ,
360+ ( OsString :: from ( "NPM_CONFIG_REGISTRY" ) , OsString :: from_vec ( vec ! [ 0xff ] ) ) ,
361+ ( OsString :: from ( "NPM_CONFIG_REGISTRY" ) , OsString :: from ( "https://example.test" ) ) ,
362+ ]
363+ . into_iter ( ) ,
364+ )
365+ . collect :: < Vec < _ > > ( ) ;
366+ assert_eq ! ( values. len( ) , 1 ) ;
367+ assert_eq ! ( values[ 0 ] . 1 , "https://example.test" ) ;
368+ }
369+
251370 #[ test]
252371 fn environment_registry_overrides_project ( ) {
253372 let project = project_with_npmrc ( "registry=https://project.example\n " ) ;
@@ -274,6 +393,41 @@ mod tests {
274393 } ) ;
275394 }
276395
396+ #[ test]
397+ fn empty_credentials_fall_back_to_parent_auth_path ( ) {
398+ let config = NpmConfig {
399+ values : HashMap :: from ( [
400+ ( "//registry.example/team/:_authtoken" . to_string ( ) , String :: new ( ) ) ,
401+ ( "//registry.example/:_authtoken" . to_string ( ) , "HOST" . to_string ( ) ) ,
402+ ] ) ,
403+ } ;
404+ let request = config
405+ . apply_auth (
406+ http_client ( ) . get ( "https://registry.example/team/pkg" ) ,
407+ "https://registry.example/team/pkg" ,
408+ )
409+ . build ( )
410+ . unwrap ( ) ;
411+ assert_eq ! ( request. headers( ) [ reqwest:: header:: AUTHORIZATION ] , "Bearer HOST" ) ;
412+ }
413+
414+ #[ test]
415+ fn parses_inline_comments_and_escapes_in_npmrc_values ( ) {
416+ let project = project_with_npmrc (
417+ "registry=https://registry.example/ ; mirror\n \
418+ //registry.example/:_authToken=SECRET # CI\n \
419+ quoted=\" value # retained\" \n \
420+ fragment=https://example.test/\\ #retained\n \
421+ semicolon=left\\ ;right ; removed\n ",
422+ ) ;
423+ let config = NpmConfig :: load_for_project ( Some ( project. path ( ) . to_path_buf ( ) ) ) ;
424+ assert_eq ! ( config. registry_for_package( "pnpm" ) , "https://registry.example" ) ;
425+ assert_eq ! ( config. values[ "//registry.example/:_authtoken" ] , "SECRET" ) ;
426+ assert_eq ! ( config. values[ "quoted" ] , "value # retained" ) ;
427+ assert_eq ! ( config. values[ "fragment" ] , "https://example.test/#retained" ) ;
428+ assert_eq ! ( config. values[ "semicolon" ] , "left;right" ) ;
429+ }
430+
277431 #[ test]
278432 fn does_not_send_auth_to_another_host ( ) {
279433 let config = NpmConfig {
0 commit comments