@@ -127,6 +127,179 @@ pub(crate) fn other_versions_detail(
127127 )
128128}
129129
130+ /// MSBuild project files NuGet restores `PackageReference`s for.
131+ const PROJECT_EXTENSIONS : [ & str ; 3 ] = [ ".csproj" , ".fsproj" , ".vbproj" ] ;
132+
133+ /// Whether `name` (a basename) is an MSBuild project file.
134+ pub ( crate ) fn is_project_file ( name : & str ) -> bool {
135+ let lower = name. to_ascii_lowercase ( ) ;
136+ PROJECT_EXTENSIONS
137+ . iter ( )
138+ . any ( |ext| lower. len ( ) > ext. len ( ) && lower. ends_with ( ext) )
139+ }
140+
141+ /// The locks a project tree restores into, as root-relative `/` paths
142+ /// (#353, #514). Every project under the root inherits the root
143+ /// `nuget.config`, so the Socket source and mapping wired there reach every
144+ /// one of them, and each lock they restore must be pinned with it:
145+ ///
146+ /// * the root `packages.lock.json`, when present (a project file NuGet
147+ /// restores from the root, or a lock with no project beside it);
148+ /// * per project, the lock NuGet reads: a literal `NuGetLockFilePath`
149+ /// (relative to the project), else `packages.<ProjectName>.lock.json`
150+ /// beside it (spaces in the name become `_`) when that file exists, else
151+ /// `packages.lock.json` beside it.
152+ ///
153+ /// Only existing locks are returned. A `NuGetLockFilePath` this reader cannot
154+ /// evaluate (an MSBuild property or item reference, a `Condition`, an
155+ /// absolute path or one leaving the root) is returned in `unresolved` as
156+ /// `(project, detail)`: the writers refuse rather than leave a lock they
157+ /// cannot find on its upstream hash.
158+ #[ derive( Debug , Default , PartialEq , Eq ) ]
159+ pub ( crate ) struct GovernedLocks {
160+ pub ( crate ) locks : Vec < String > ,
161+ pub ( crate ) unresolved : Vec < ( String , String ) > ,
162+ }
163+
164+ /// [`GovernedLocks`] of `projects` (`(root-relative project path, text)`),
165+ /// asking `exists` whether a root-relative file exists.
166+ pub ( crate ) fn governed_locks (
167+ projects : & [ ( String , String ) ] ,
168+ exists : impl Fn ( & str ) -> bool ,
169+ ) -> GovernedLocks {
170+ let mut out = GovernedLocks :: default ( ) ;
171+ if exists ( PACKAGES_LOCK ) {
172+ out. locks . push ( PACKAGES_LOCK . to_string ( ) ) ;
173+ }
174+ for ( project, text) in projects {
175+ let ( dir, file) = match project. rsplit_once ( '/' ) {
176+ Some ( ( dir, file) ) => ( dir, file) ,
177+ None => ( "" , project. as_str ( ) ) ,
178+ } ;
179+ let join = |leaf : & str | {
180+ if dir. is_empty ( ) {
181+ leaf. to_string ( )
182+ } else {
183+ format ! ( "{dir}/{leaf}" )
184+ }
185+ } ;
186+ let lock = match lock_file_path_property ( text) {
187+ Some ( Err ( detail) ) => {
188+ out. unresolved . push ( ( project. clone ( ) , detail) ) ;
189+ continue ;
190+ }
191+ Some ( Ok ( value) ) => {
192+ match resolve_relative ( dir, & value) {
193+ Some ( rel) => rel,
194+ None => {
195+ out. unresolved . push ( (
196+ project. clone ( ) ,
197+ format ! ( "NuGetLockFilePath `{value}` is absolute or leaves the project root" ) ,
198+ ) ) ;
199+ continue ;
200+ }
201+ }
202+ }
203+ None => {
204+ let stem = & file[ ..file. rfind ( '.' ) . unwrap_or ( file. len ( ) ) ] ;
205+ let named = join ( & format ! ( "packages.{}.lock.json" , stem. replace( ' ' , "_" ) ) ) ;
206+ if exists ( & named) {
207+ named
208+ } else {
209+ join ( PACKAGES_LOCK )
210+ }
211+ }
212+ } ;
213+ if exists ( & lock) && !out. locks . contains ( & lock) {
214+ out. locks . push ( lock) ;
215+ }
216+ }
217+ out
218+ }
219+
220+ /// The project's literal `NuGetLockFilePath`: `None` when it sets none,
221+ /// `Some(Err)` when it sets one this reader cannot evaluate.
222+ fn lock_file_path_property ( text : & str ) -> Option < Result < String , String > > {
223+ const OPEN : & str = "<NuGetLockFilePath" ;
224+ const CLOSE : & str = "</NuGetLockFilePath>" ;
225+ let text = strip_xml_comments ( text) ;
226+ let mut value: Option < Result < String , String > > = None ;
227+ let mut rest = text. as_str ( ) ;
228+ while let Some ( at) = rest. find ( OPEN ) {
229+ let after = & rest[ at + OPEN . len ( ) ..] ;
230+ // `<NuGetLockFilePathX>` is another property.
231+ if !after. starts_with ( [ '>' , ' ' , '\t' , '\r' , '\n' , '/' ] ) {
232+ rest = after;
233+ continue ;
234+ }
235+ let Some ( gt) = after. find ( '>' ) else {
236+ return Some ( Err ( "an unterminated NuGetLockFilePath element" . to_string ( ) ) ) ;
237+ } ;
238+ let attrs = after[ ..gt] . trim ( ) ;
239+ if attrs. ends_with ( '/' ) {
240+ // `<NuGetLockFilePath />`: an empty value, NuGet's default.
241+ value = None ;
242+ rest = & after[ gt + 1 ..] ;
243+ continue ;
244+ }
245+ let Some ( end) = after[ gt + 1 ..] . find ( CLOSE ) else {
246+ return Some ( Err ( "an unterminated NuGetLockFilePath element" . to_string ( ) ) ) ;
247+ } ;
248+ let raw = after[ gt + 1 ..gt + 1 + end] . trim ( ) ;
249+ rest = & after[ gt + 1 + end + CLOSE . len ( ) ..] ;
250+ if raw. is_empty ( ) && attrs. is_empty ( ) {
251+ value = None ;
252+ continue ;
253+ }
254+ value = Some ( if !attrs. is_empty ( ) {
255+ Err ( format ! (
256+ "NuGetLockFilePath `{raw}` is conditional ({attrs}); its value depends on the build"
257+ ) )
258+ } else if raw. contains ( "$(" ) || raw. contains ( "@(" ) || raw. contains ( "%(" ) {
259+ Err ( format ! (
260+ "NuGetLockFilePath `{raw}` references MSBuild properties or items"
261+ ) )
262+ } else {
263+ Ok ( raw. to_string ( ) )
264+ } ) ;
265+ }
266+ value
267+ }
268+
269+ fn strip_xml_comments ( text : & str ) -> String {
270+ let mut out = String :: with_capacity ( text. len ( ) ) ;
271+ let mut rest = text;
272+ while let Some ( at) = rest. find ( "<!--" ) {
273+ out. push_str ( & rest[ ..at] ) ;
274+ match rest[ at + 4 ..] . find ( "-->" ) {
275+ Some ( end) => rest = & rest[ at + 4 + end + 3 ..] ,
276+ None => return out,
277+ }
278+ }
279+ out. push_str ( rest) ;
280+ out
281+ }
282+
283+ /// `value` (a project-relative path, either separator) resolved against
284+ /// the root-relative `dir`; `None` when it is absolute or leaves the root.
285+ fn resolve_relative ( dir : & str , value : & str ) -> Option < String > {
286+ let value = value. replace ( '\\' , "/" ) ;
287+ if value. starts_with ( '/' ) || value. as_bytes ( ) . get ( 1 ) == Some ( & b':' ) {
288+ return None ;
289+ }
290+ let mut parts: Vec < & str > = dir. split ( '/' ) . filter ( |p| !p. is_empty ( ) ) . collect ( ) ;
291+ for seg in value. split ( '/' ) {
292+ match seg {
293+ "" | "." => { }
294+ ".." => {
295+ parts. pop ( ) ?;
296+ }
297+ seg => parts. push ( seg) ,
298+ }
299+ }
300+ ( !parts. is_empty ( ) ) . then ( || parts. join ( "/" ) )
301+ }
302+
130303#[ cfg( test) ]
131304mod tests {
132305 use super :: * ;
@@ -176,4 +349,77 @@ mod tests {
176349 let doc = parse_lock ( & MULTI . replace ( "\" 12.0.3\" " , "\" 13.0.3.0\" " ) ) . unwrap ( ) ;
177350 assert ! ( other_versions( & doc, "Newtonsoft.Json" , "13.0.3" ) . is_empty( ) ) ;
178351 }
352+
353+ fn exists_in ( files : & ' static [ & ' static str ] ) -> impl Fn ( & str ) -> bool {
354+ move |p| files. contains ( & p)
355+ }
356+
357+ #[ test]
358+ fn member_and_named_locks_are_governed ( ) {
359+ let projects = vec ! [
360+ ( "src/App/App.csproj" . to_string( ) , "<Project />" . to_string( ) ) ,
361+ (
362+ "src/Lib/My Lib.fsproj" . to_string( ) ,
363+ "<Project />" . to_string( ) ,
364+ ) ,
365+ ( "tests/T/T.vbproj" . to_string( ) , "<Project />" . to_string( ) ) ,
366+ ] ;
367+ let got = governed_locks (
368+ & projects,
369+ exists_in ( & [
370+ "packages.lock.json" ,
371+ "src/App/packages.lock.json" ,
372+ "src/Lib/packages.My_Lib.lock.json" ,
373+ "src/Lib/packages.lock.json" ,
374+ ] ) ,
375+ ) ;
376+ assert_eq ! (
377+ got. locks,
378+ [
379+ "packages.lock.json" ,
380+ "src/App/packages.lock.json" ,
381+ "src/Lib/packages.My_Lib.lock.json"
382+ ]
383+ ) ;
384+ assert ! ( got. unresolved. is_empty( ) ) ;
385+ }
386+
387+ #[ test]
388+ fn lock_file_path_property_is_honored_or_refused ( ) {
389+ let project = |body : & str | {
390+ vec ! [ (
391+ "src/App/App.csproj" . to_string( ) ,
392+ format!( "<Project><PropertyGroup>{body}</PropertyGroup></Project>" ) ,
393+ ) ]
394+ } ;
395+ let got = governed_locks (
396+ & project ( "<NuGetLockFilePath>..\\ locks/app.lock.json</NuGetLockFilePath>" ) ,
397+ exists_in ( & [ "src/locks/app.lock.json" , "src/App/packages.lock.json" ] ) ,
398+ ) ;
399+ assert_eq ! ( got. locks, [ "src/locks/app.lock.json" ] ) ;
400+ // A commented-out property is not set.
401+ let got = governed_locks (
402+ & project ( "<!-- <NuGetLockFilePath>x.json</NuGetLockFilePath> -->" ) ,
403+ exists_in ( & [ "src/App/packages.lock.json" ] ) ,
404+ ) ;
405+ assert_eq ! ( got. locks, [ "src/App/packages.lock.json" ] ) ;
406+ for body in [
407+ "<NuGetLockFilePath>$(MSBuildProjectDirectory)/l.json</NuGetLockFilePath>" ,
408+ "<NuGetLockFilePath Condition=\" '$(CI)'=='true'\" >l.json</NuGetLockFilePath>" ,
409+ "<NuGetLockFilePath>../../../outside.json</NuGetLockFilePath>" ,
410+ "<NuGetLockFilePath>/abs/l.json</NuGetLockFilePath>" ,
411+ ] {
412+ let got = governed_locks ( & project ( body) , exists_in ( & [ ] ) ) ;
413+ assert ! ( got. locks. is_empty( ) , "{body}" ) ;
414+ assert_eq ! ( got. unresolved. len( ) , 1 , "{body}" ) ;
415+ }
416+ }
417+
418+ #[ test]
419+ fn project_files_are_recognized_by_extension ( ) {
420+ assert ! ( is_project_file( "App.csproj" ) ) ;
421+ assert ! ( is_project_file( "App.FSPROJ" ) ) ;
422+ assert ! ( !is_project_file( ".csproj" ) ) ;
423+ assert ! ( !is_project_file( "App.sln" ) ) ;
424+ }
179425}
0 commit comments