@@ -34,9 +34,8 @@ const SKIP_DIRS: &[&str] = &[
3434/// install the project at `start_path` into, which the workspace walk
3535/// cannot find by name (it only collects dirs literally named
3636/// `node_modules`, and prunes `temp`):
37- /// - yarn classic's `--modules-folder <dir>` from the nearest `.yarnrc`
38- /// (the project's or an ancestor's — yarn reads them all, nearest wins),
39- /// resolved against the project like yarn resolves it against its cwd;
37+ /// - yarn classic's effective modules folder from the `.yarnrc` files at
38+ /// or above the project (see [`yarnrc_modules_folder`]);
4039/// - Rush's `common/temp/node_modules` when `rush.json` is at the root:
4140/// rush runs pnpm there, so every transitive dep lives in its `.pnpm`
4241/// store and the projects' own `node_modules` hold only links to their
@@ -45,10 +44,7 @@ const SKIP_DIRS: &[&str] = &[
4544/// Only existing directories are returned.
4645pub ( super ) fn configured_install_roots ( start_path : & Path ) -> Vec < PathBuf > {
4746 let mut roots = Vec :: new ( ) ;
48- if let Some ( folder) = yarnrc_modules_folder ( start_path)
49- . as_deref ( )
50- . and_then ( normalize_modules_folder)
51- {
47+ if let Some ( folder) = yarnrc_modules_folder ( start_path) {
5248 roots. push ( start_path. join ( folder) ) ;
5349 }
5450 if start_path. join ( "rush.json" ) . is_file ( ) {
@@ -80,26 +76,64 @@ pub(super) fn merge_configured_install_roots(
8076 walked
8177}
8278
83- /// The `--modules-folder` value from the nearest `.yarnrc` at or above
84- /// `start_path`, if any `.yarnrc` sets it. Read with
85- /// [`crate::utils::fs::read_regular_to_string_sync`]: the file belongs to
79+ /// The modules folder yarn classic installs the project at `start_path`
80+ /// into, as a path relative to the project, from the `.yarnrc` files at or
81+ /// above it. Mirrors yarn 1.x's rc handling:
82+ /// - each key merges through the rc hierarchy on its own, the nearest
83+ /// `.yarnrc` defining it winning;
84+ /// - a path value is resolved against the directory of the `.yarnrc` that
85+ /// defines it, not the project (`/repo/.yarnrc` with
86+ /// `--modules-folder project/deps` installs `/repo/project` into
87+ /// `/repo/project/deps`);
88+ /// - `--install.modules-folder` wins over `--modules-folder` wherever
89+ /// either is defined, since yarn appends command-scoped args after the
90+ /// general ones.
91+ ///
92+ /// The resolved folder must lie strictly inside the project (see
93+ /// [`resolve_modules_folder`]), else `None`. Read with
94+ /// [`crate::utils::fs::read_regular_to_string_sync`]: the files belong to
8695/// the (untrusted) project, and a FIFO planted there would wedge a plain
8796/// read forever.
8897fn yarnrc_modules_folder ( start_path : & Path ) -> Option < String > {
89- start_path. ancestors ( ) . find_map ( |dir| {
90- let rc = crate :: utils:: fs:: read_regular_to_string_sync ( & dir. join ( ".yarnrc" ) ) . ok ( ) ?;
91- parse_yarnrc_modules_folder ( & rc)
92- } )
98+ let mut general: Option < ( & Path , String ) > = None ;
99+ let mut install: Option < ( & Path , String ) > = None ;
100+ for dir in start_path. ancestors ( ) {
101+ if general. is_some ( ) && install. is_some ( ) {
102+ break ;
103+ }
104+ let Ok ( rc) = crate :: utils:: fs:: read_regular_to_string_sync ( & dir. join ( ".yarnrc" ) ) else {
105+ continue ;
106+ } ;
107+ let found = parse_yarnrc_modules_folder ( & rc) ;
108+ if general. is_none ( ) {
109+ general = found. general . map ( |value| ( dir, value) ) ;
110+ }
111+ if install. is_none ( ) {
112+ install = found. install . map ( |value| ( dir, value) ) ;
113+ }
114+ }
115+ let ( rc_dir, value) = install. or ( general) ?;
116+ let project_in_rc_dir = start_path
117+ . strip_prefix ( rc_dir)
118+ . ok ( ) ?
119+ . components ( )
120+ . map ( |c| c. as_os_str ( ) . to_str ( ) . map ( str:: to_string) )
121+ . collect :: < Option < Vec < _ > > > ( ) ?;
122+ resolve_modules_folder ( & project_in_rc_dir, & value)
93123}
94124
95- /// Reduce a `--modules-folder` value to plain `a/b` segments under the
96- /// project, or `None`. The value comes from the project being scanned and
97- /// names a tree apply later WRITES patch content into, so (like composer's
98- /// `config.vendor-dir`) only a relative subpath is honored: `./deps` and
99- /// `lib/./deps` resolve, `..` is resolved lexically, and a value that is
100- /// absolute, drive-qualified, climbs above the project root or reduces to
101- /// it fails closed — the project then discovers nothing there, as before.
102- fn normalize_modules_folder ( raw : & str ) -> Option < String > {
125+ /// Resolve a `.yarnrc` modules-folder `raw` value against the directory of
126+ /// the `.yarnrc` that defines it, given the project's path below that
127+ /// directory (`project_in_rc_dir`, empty when the `.yarnrc` is the
128+ /// project's own), into plain `a/b` segments relative to the project, or
129+ /// `None`. The value comes from the project being scanned and names a
130+ /// tree apply later WRITES patch content into, so (like composer's
131+ /// `config.vendor-dir`) only a relative value resolving strictly inside
132+ /// the project is honored: `./deps` and `lib/./deps` resolve, `..` is
133+ /// resolved lexically, and a value that is absolute, drive-qualified, or
134+ /// resolves outside the project or to the project itself fails closed —
135+ /// the project then discovers nothing there, as before.
136+ fn resolve_modules_folder ( project_in_rc_dir : & [ String ] , raw : & str ) -> Option < String > {
103137 if raw. starts_with ( [ '/' , '\\' ] ) {
104138 return None ;
105139 }
@@ -113,16 +147,32 @@ fn normalize_modules_folder(raw: &str) -> Option<String> {
113147 other => segments. push ( other) ,
114148 }
115149 }
116- let joined = segments. join ( "/" ) ;
117- ( !segments. is_empty ( ) && path_safety:: is_safe_multi_segment ( & joined) ) . then_some ( joined)
150+ let inside = segments. get ( project_in_rc_dir. len ( ) ..) ?;
151+ let at_project = segments. iter ( ) . zip ( project_in_rc_dir) . all ( |( s, p) | s == p) ;
152+ if !at_project || inside. is_empty ( ) {
153+ return None ;
154+ }
155+ let joined = inside. join ( "/" ) ;
156+ path_safety:: is_safe_multi_segment ( & joined) . then_some ( joined)
157+ }
158+
159+ /// The modules-folder settings of one yarn classic `.yarnrc`, kept apart
160+ /// because yarn merges and applies them separately.
161+ #[ derive( Debug , Default , PartialEq ) ]
162+ struct YarnrcModulesFolder {
163+ /// `--modules-folder`.
164+ general : Option < String > ,
165+ /// `--install.modules-folder`.
166+ install : Option < String > ,
118167}
119168
120- /// The `--modules-folder` (or command-scoped `--install.modules-folder`)
121- /// value of a yarn classic `.yarnrc`. The file is yarn's lockfile syntax:
122- /// one `key value` pair per line, either side optionally double-quoted, an
123- /// optional `:` after the key, `#` comment lines. The last setting wins.
124- fn parse_yarnrc_modules_folder ( rc : & str ) -> Option < String > {
125- let mut found = None ;
169+ /// The `--modules-folder` and command-scoped `--install.modules-folder`
170+ /// values of a yarn classic `.yarnrc`. The file is yarn's lockfile
171+ /// syntax: one `key value` pair per line, either side optionally
172+ /// double-quoted, an optional `:` after the key, `#` comment lines. For
173+ /// each key the last setting wins.
174+ fn parse_yarnrc_modules_folder ( rc : & str ) -> YarnrcModulesFolder {
175+ let mut found = YarnrcModulesFolder :: default ( ) ;
126176 for line in rc. trim_start_matches ( '\u{feff}' ) . lines ( ) {
127177 let line = line. trim ( ) ;
128178 if line. is_empty ( ) || line. starts_with ( '#' ) {
@@ -131,14 +181,16 @@ fn parse_yarnrc_modules_folder(rc: &str) -> Option<String> {
131181 let Some ( ( key, rest) ) = split_yarnrc_token ( line, true ) else {
132182 continue ;
133183 } ;
134- if key != "--modules-folder" && key != "--install.modules-folder" {
135- continue ;
136- }
184+ let slot = match key. as_str ( ) {
185+ "--modules-folder" => & mut found. general ,
186+ "--install.modules-folder" => & mut found. install ,
187+ _ => continue ,
188+ } ;
137189 let rest = rest. trim_start ( ) ;
138190 let rest = rest. strip_prefix ( ':' ) . unwrap_or ( rest) . trim_start ( ) ;
139191 if let Some ( ( value, _) ) = split_yarnrc_token ( rest, false ) {
140192 if !value. is_empty ( ) {
141- found = Some ( value) ;
193+ * slot = Some ( value) ;
142194 }
143195 }
144196 }
@@ -4319,16 +4371,39 @@ mod tests {
43194371 /// CRLF, a Windows drive path, and last-setting-wins.
43204372 #[ test]
43214373 fn test_parse_yarnrc_modules_folder ( ) {
4322- let parse = parse_yarnrc_modules_folder;
4374+ let parse = | rc : & str | parse_yarnrc_modules_folder ( rc ) . general ;
43234375 assert_eq ! ( parse( "--modules-folder deps\n " ) . as_deref( ) , Some ( "deps" ) ) ;
43244376 assert_eq ! (
43254377 parse( "\" --modules-folder\" \" ./my deps\" \n " ) . as_deref( ) ,
43264378 Some ( "./my deps" )
43274379 ) ;
43284380 assert_eq ! ( parse( "--modules-folder: lib\n " ) . as_deref( ) , Some ( "lib" ) ) ;
4381+ // The command-scoped key is kept apart from the general one, in
4382+ // either line order: yarn merges and applies them separately.
4383+ for rc in [
4384+ "--install.modules-folder specific\n --modules-folder general\n " ,
4385+ "--modules-folder general\n --install.modules-folder specific\n " ,
4386+ ] {
4387+ assert_eq ! (
4388+ parse_yarnrc_modules_folder( rc) ,
4389+ YarnrcModulesFolder {
4390+ general: Some ( "general" . into( ) ) ,
4391+ install: Some ( "specific" . into( ) ) ,
4392+ } ,
4393+ "{rc:?}"
4394+ ) ;
4395+ }
43294396 assert_eq ! (
4330- parse( "--install.modules-folder vendor_modules" ) . as_deref( ) ,
4331- Some ( "vendor_modules" )
4397+ parse_yarnrc_modules_folder( "--install.modules-folder vendor_modules" ) ,
4398+ YarnrcModulesFolder {
4399+ general: None ,
4400+ install: Some ( "vendor_modules" . into( ) ) ,
4401+ }
4402+ ) ;
4403+ // Other command scopes are not the install's.
4404+ assert_eq ! (
4405+ parse_yarnrc_modules_folder( "--add.modules-folder x\n " ) ,
4406+ YarnrcModulesFolder :: default ( )
43324407 ) ;
43334408 assert_eq ! (
43344409 parse( "\u{feff} # comment\r \n yarn-offline-mirror \" ./m\" \r \n --modules-folder deps\r \n " )
@@ -4356,7 +4431,11 @@ mod tests {
43564431 "--modules-folder\n " ,
43574432 "--modules-folder \" unterminated\n " ,
43584433 ] {
4359- assert_eq ! ( parse( rc) , None , "{rc:?}" ) ;
4434+ assert_eq ! (
4435+ parse_yarnrc_modules_folder( rc) ,
4436+ YarnrcModulesFolder :: default ( ) ,
4437+ "{rc:?}"
4438+ ) ;
43604439 }
43614440 }
43624441
@@ -4418,11 +4497,16 @@ mod tests {
44184497 . unwrap ( ) ;
44194498 assert_eq ! ( found. len( ) , 1 ) ;
44204499
4421- // An ancestor's .yarnrc applies too; the nearest one wins.
4500+ // An ancestor's .yarnrc applies too (its value resolved against
4501+ // its own directory); the nearest one wins.
44224502 let member = root. join ( "packages/member" ) ;
44234503 write_pkg ( & member. join ( "lib/ms" ) , "ms" , "2.1.3" ) ;
44244504 std:: fs:: write ( member. join ( "package.json" ) , r#"{"name":"member"}"# ) . unwrap ( ) ;
4425- std:: fs:: write ( root. join ( ".yarnrc" ) , "--modules-folder lib\n " ) . unwrap ( ) ;
4505+ std:: fs:: write (
4506+ root. join ( ".yarnrc" ) ,
4507+ "--modules-folder packages/member/lib\n " ,
4508+ )
4509+ . unwrap ( ) ;
44264510 let roots = crawler
44274511 . get_node_modules_paths ( & local_options ( & member) )
44284512 . await
@@ -4522,8 +4606,8 @@ mod tests {
45224606 /// `.`/`..` resolve lexically, and an absolute, drive-qualified,
45234607 /// escaping or empty value is refused.
45244608 #[ test]
4525- fn test_normalize_modules_folder ( ) {
4526- let n = normalize_modules_folder ;
4609+ fn test_resolve_modules_folder ( ) {
4610+ let n = | raw : & str | resolve_modules_folder ( & [ ] , raw ) ;
45274611 assert_eq ! ( n( "deps" ) . as_deref( ) , Some ( "deps" ) ) ;
45284612 assert_eq ! ( n( "./deps/" ) . as_deref( ) , Some ( "deps" ) ) ;
45294613 assert_eq ! ( n( "lib/./deps" ) . as_deref( ) , Some ( "lib/deps" ) ) ;
@@ -4543,6 +4627,104 @@ mod tests {
45434627 ] {
45444628 assert_eq ! ( n( raw) , None , "{raw:?}" ) ;
45454629 }
4630+ // Defined by an ancestor `.yarnrc`: resolved against that file's
4631+ // directory, then kept only when strictly inside the project.
4632+ let project = [ "project" . to_string ( ) ] ;
4633+ let a = |raw : & str | resolve_modules_folder ( & project, raw) ;
4634+ assert_eq ! ( a( "project/deps" ) . as_deref( ) , Some ( "deps" ) ) ;
4635+ assert_eq ! ( a( "./project/./lib\\ deps" ) . as_deref( ) , Some ( "lib/deps" ) ) ;
4636+ assert_eq ! ( a( "x/../project/deps" ) . as_deref( ) , Some ( "deps" ) ) ;
4637+ for raw in [
4638+ "deps" ,
4639+ "project" ,
4640+ "project/.." ,
4641+ "../project/deps" ,
4642+ "projectx/deps" ,
4643+ ".." ,
4644+ ] {
4645+ assert_eq ! ( a( raw) , None , "{raw:?}" ) ;
4646+ }
4647+ }
4648+
4649+ /// REVIEW (#520): a modules folder inherited from an ancestor
4650+ /// `.yarnrc` resolves against that file's directory, as yarn 1.x does:
4651+ /// `/repo/.yarnrc` `--modules-folder project/deps` installs
4652+ /// `/repo/project` into `/repo/project/deps`, not
4653+ /// `/repo/project/project/deps`. A value resolving outside the project
4654+ /// (here the sibling `/repo/deps`) is still refused.
4655+ #[ tokio:: test]
4656+ async fn test_inherited_yarnrc_modules_folder_resolves_against_its_dir ( ) {
4657+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
4658+ let repo = tmp. path ( ) ;
4659+ let project = repo. join ( "project" ) ;
4660+ write_pkg ( & project. join ( "deps/ms" ) , "ms" , "2.1.3" ) ;
4661+ write_pkg ( & repo. join ( "deps/ms" ) , "ms" , "2.1.3" ) ;
4662+ let crawler = NpmCrawler :: new ( ) ;
4663+
4664+ std:: fs:: write ( repo. join ( ".yarnrc" ) , "--modules-folder project/deps\n " ) . unwrap ( ) ;
4665+ let roots = crawler
4666+ . get_node_modules_paths ( & local_options ( & project) )
4667+ . await
4668+ . unwrap ( ) ;
4669+ assert_eq ! ( roots, vec![ project. join( "deps" ) ] ) ;
4670+
4671+ std:: fs:: write ( repo. join ( ".yarnrc" ) , "--modules-folder deps\n " ) . unwrap ( ) ;
4672+ let roots = crawler
4673+ . get_node_modules_paths ( & local_options ( & project) )
4674+ . await
4675+ . unwrap ( ) ;
4676+ assert ! ( roots. is_empty( ) , "{roots:?}" ) ;
4677+ }
4678+
4679+ /// REVIEW (#520): `--install.modules-folder` wins over
4680+ /// `--modules-folder` whatever their line order, and when they come
4681+ /// from different `.yarnrc` files (yarn merges each key through the
4682+ /// hierarchy on its own, then appends install-scoped args after the
4683+ /// general ones).
4684+ #[ test]
4685+ fn test_install_scoped_modules_folder_takes_precedence ( ) {
4686+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
4687+ let repo = tmp. path ( ) ;
4688+ let project = repo. join ( "project" ) ;
4689+ write_pkg ( & project. join ( "specific/ms" ) , "ms" , "2.1.3" ) ;
4690+ write_pkg ( & project. join ( "general/ms" ) , "ms" , "2.1.3" ) ;
4691+ let roots_for = |project_rc : Option < & str > , repo_rc : Option < & str > | {
4692+ for ( dir, rc) in [ ( & project, project_rc) , ( & repo. to_path_buf ( ) , repo_rc) ] {
4693+ let path = dir. join ( ".yarnrc" ) ;
4694+ match rc {
4695+ Some ( rc) => std:: fs:: write ( & path, rc) . unwrap ( ) ,
4696+ None => {
4697+ let _ = std:: fs:: remove_file ( & path) ;
4698+ }
4699+ }
4700+ }
4701+ NpmCrawler :: find_local_node_modules_dirs ( & project)
4702+ } ;
4703+ let want = vec ! [ project. join( "specific" ) ] ;
4704+ for ( project_rc, repo_rc) in [
4705+ (
4706+ Some ( "--install.modules-folder specific\n --modules-folder general\n " ) ,
4707+ None ,
4708+ ) ,
4709+ (
4710+ Some ( "--modules-folder general\n --install.modules-folder specific\n " ) ,
4711+ None ,
4712+ ) ,
4713+ (
4714+ Some ( "--modules-folder general\n " ) ,
4715+ Some ( "--install.modules-folder project/specific\n " ) ,
4716+ ) ,
4717+ (
4718+ Some ( "--install.modules-folder specific\n " ) ,
4719+ Some ( "--modules-folder project/general\n " ) ,
4720+ ) ,
4721+ ] {
4722+ assert_eq ! (
4723+ roots_for( project_rc, repo_rc) ,
4724+ want,
4725+ "{project_rc:?} / {repo_rc:?}"
4726+ ) ;
4727+ }
45464728 }
45474729
45484730 /// An escaping or absolute `--modules-folder` is not a crawl root
0 commit comments