66
77const fs = require ( 'fs' ) . promises ;
88const path = require ( 'path' ) ;
9- const { execSync , execFileSync } = require ( 'child_process' ) ;
9+ const { execFileSync } = require ( 'child_process' ) ;
1010const { XMLParser } = require ( 'fast-xml-parser' ) ;
1111
1212const DEFAULT_TFM = 'net472' ;
@@ -170,32 +170,17 @@ ${packageRefs}
170170async function findCsprojFile ( dir ) {
171171 try {
172172 const entries = await fs . readdir ( dir , { withFileTypes : true } ) ;
173- const csprojFiles = [ ] ;
174- for ( const entry of entries ) {
175- if ( entry . isFile ( ) && entry . name . endsWith ( '.csproj' ) ) {
176- const fullPath = path . join ( dir , entry . name ) ;
177- // Validate path: ensure it resolves to expected directory (prevent path traversal)
178- const resolvedPath = path . resolve ( fullPath ) ;
179- const resolvedDir = path . resolve ( dir ) ;
180- if ( ! resolvedPath . startsWith ( resolvedDir ) ) {
181- // Path traversal detected, skip this file
182- continue ;
183- }
184- csprojFiles . push ( fullPath ) ;
185- }
186- }
187- if ( csprojFiles . length === 0 ) {
188- return null ;
189- }
173+ const csprojFiles = entries
174+ . filter ( e => e . isFile ( ) && e . name . endsWith ( '.csproj' ) )
175+ . map ( e => path . join ( dir , e . name ) ) ;
176+
177+ if ( csprojFiles . length === 0 ) return null ;
190178 if ( csprojFiles . length > 1 ) {
191179 throw new Error ( `Multiple .csproj files found in ${ dir } : ${ csprojFiles . map ( f => path . basename ( f ) ) . join ( ', ' ) } ` ) ;
192180 }
193181 return csprojFiles [ 0 ] ;
194182 } catch ( err ) {
195- if ( err . message . includes ( 'Multiple .csproj files' ) ) {
196- throw err ;
197- }
198- // Directory might not exist or be unreadable
183+ if ( err . message . includes ( 'Multiple .csproj files' ) ) throw err ;
199184 return null ;
200185 }
201186}
@@ -229,37 +214,19 @@ async function processPackagesConfig(packagesConfigPath, defaultTfm, failOnSkipp
229214 cwd : dir ,
230215 stdio : 'pipe' ,
231216 } ) ;
232- // Copy lock file if it exists in the project directory
233- // Handle race condition by catching errors during copy operation
217+ // Copy lock file (check root first, then obj/)
234218 const lockPath = path . join ( dir , 'packages.lock.json' ) ;
219+ const objLockPath = path . join ( dir , 'obj' , 'packages.lock.json' ) ;
220+
235221 try {
236- await fs . access ( lockPath ) ;
237- // Lock file already in place
222+ await fs . copyFile ( lockPath , lockFilePath ) ;
238223 console . log ( ` ✓ Lock file already exists: ${ lockFilePath } ` ) ;
239224 return { success : true , skippedPackages : [ ] } ;
240225 } catch {
241- // Lock file should be in obj/ subdirectory
242- const objLockPath = path . join ( dir , 'obj' , 'packages.lock.json' ) ;
243226 try {
244- // Use access to check existence, then copy with error handling for race conditions
245- await fs . access ( objLockPath ) ;
246- try {
247- await fs . copyFile ( objLockPath , lockFilePath ) ;
248- console . log ( ` ✓ Generated: ${ lockFilePath } ` ) ;
249- return { success : true , skippedPackages : [ ] } ;
250- } catch ( copyErr ) {
251- // Race condition: file might have been deleted between access and copy
252- // Check if it still exists
253- try {
254- await fs . access ( objLockPath ) ;
255- // Still exists, rethrow original error
256- throw copyErr ;
257- } catch {
258- // File was deleted, treat as not found
259- console . log ( ` Warning: Lock file not found after restore` ) ;
260- return { success : false , skippedPackages : [ ] } ;
261- }
262- }
227+ await fs . copyFile ( objLockPath , lockFilePath ) ;
228+ console . log ( ` ✓ Generated: ${ lockFilePath } ` ) ;
229+ return { success : true , skippedPackages : [ ] } ;
263230 } catch {
264231 console . log ( ` Warning: Lock file not found after restore` ) ;
265232 return { success : false , skippedPackages : [ ] } ;
@@ -301,16 +268,8 @@ async function processPackagesConfig(packagesConfigPath, defaultTfm, failOnSkipp
301268 let restoreSuccess = false ;
302269 let lastError = null ;
303270
304- // Retry loop: remove incompatible packages on NU1202 errors
305- // Normalize version for comparison: remove trailing .0 segments (1.0.0.0 -> 1.0.0, but 1.0.0.1 -> 1.0.0.1)
306- const normalizeVersion = v => {
307- // Remove trailing .0 segments, but preserve non-zero segments
308- const parts = v . split ( '.' ) ;
309- while ( parts . length > 1 && parts [ parts . length - 1 ] === '0' ) {
310- parts . pop ( ) ;
311- }
312- return parts . join ( '.' ) ;
313- } ;
271+ // Normalize version for comparison: remove trailing .0 segments
272+ const normalizeVersion = v => v . split ( '.' ) . filter ( ( p , i , arr ) => i < arr . length - 1 || p !== '0' ) . join ( '.' ) ;
314273
315274 while ( retryCount < maxRetries && compatiblePackages . length > 0 ) {
316275 try {
@@ -362,18 +321,15 @@ async function processPackagesConfig(packagesConfigPath, defaultTfm, failOnSkipp
362321
363322 if ( ! restoreSuccess ) {
364323 console . error ( ' Error: dotnet restore failed:' ) ;
365- const stdout = lastError . stdout ?. toString ( ) ;
366- const stderr = lastError . stderr ?. toString ( ) ;
367- if ( stdout ) console . error ( stdout ) ;
368- if ( stderr ) console . error ( stderr ) ;
369- return { success : false , skippedPackages : skippedPackages } ;
324+ if ( lastError . stdout ) console . error ( lastError . stdout . toString ( ) ) ;
325+ if ( lastError . stderr ) console . error ( lastError . stderr . toString ( ) ) ;
326+ return { success : false , skippedPackages } ;
370327 }
371328
372329 // Copy lock file
373- const lockPath = path . join ( workdir , 'packages.lock.json' ) ;
374- await fs . copyFile ( lockPath , lockFilePath ) ;
330+ await fs . copyFile ( path . join ( workdir , 'packages.lock.json' ) , lockFilePath ) ;
375331 console . log ( ` ✓ Generated: ${ lockFilePath } ` ) ;
376- return { success : true , skippedPackages : skippedPackages } ;
332+ return { success : true , skippedPackages } ;
377333 } finally {
378334 // Cleanup
379335 await fs . rm ( workdir , { recursive : true , force : true } ) ;
@@ -391,33 +347,13 @@ async function processPackagesConfig(packagesConfigPath, defaultTfm, failOnSkipp
391347async function main ( ) {
392348 const { tfm, rootDir, failOnSkipped } = parseArgs ( ) ;
393349
394- // Resolve root directory to absolute path
350+ // Resolve root directory to absolute path and validate
395351 const resolvedRootDir = path . resolve ( rootDir ) ;
396352
397- // Validate path traversal: ensure resolved path is within expected bounds
398- // Get the actual current working directory to validate against
399- const cwd = process . cwd ( ) ;
400- const resolvedCwd = path . resolve ( cwd ) ;
401-
402- // Check if the resolved path is actually a subdirectory or matches the expected path
403- // This prevents path traversal attacks like ../../../etc/passwd
404- if ( ! resolvedRootDir . startsWith ( resolvedCwd ) && resolvedRootDir !== resolvedCwd ) {
405- // Allow paths that are absolute and start with / (Unix) or drive letter (Windows)
406- // but validate they're reasonable
407- const isAbsolute = path . isAbsolute ( rootDir ) ;
408- if ( isAbsolute ) {
409- // For absolute paths, ensure they exist and are directories
410- // The path.resolve() already normalized, but we need to ensure it's safe
411- // Check that it doesn't contain suspicious patterns
412- if ( resolvedRootDir . includes ( '..' ) || resolvedRootDir . includes ( '~' ) ) {
413- console . error ( `Error: Invalid root directory path: ${ resolvedRootDir } ` ) ;
414- process . exit ( 1 ) ;
415- }
416- } else {
417- // Relative paths should resolve within cwd
418- console . error ( `Error: Root directory resolves outside expected bounds: ${ resolvedRootDir } ` ) ;
419- process . exit ( 1 ) ;
420- }
353+ // Validate path traversal: ensure resolved path doesn't contain suspicious patterns
354+ if ( resolvedRootDir . includes ( '..' ) || ( resolvedRootDir . includes ( '~' ) && ! resolvedRootDir . startsWith ( process . env . HOME || '' ) ) ) {
355+ console . error ( `Error: Invalid root directory path: ${ resolvedRootDir } ` ) ;
356+ process . exit ( 1 ) ;
421357 }
422358
423359 // Check if root directory exists
@@ -432,12 +368,33 @@ async function main() {
432368 process . exit ( 1 ) ;
433369 }
434370
435- // Check for dotnet
436- try {
437- execSync ( 'dotnet' , [ '--version' ] , { stdio : 'ignore' } ) ;
438- } catch ( err ) {
439- console . error ( 'Error: dotnet CLI is required but not installed' ) ;
440- process . exit ( 1 ) ;
371+ // Check for dotnet - try common paths, then fall back to PATH
372+ const commonPaths = [
373+ process . env . DOTNET_ROOT && path . join ( process . env . DOTNET_ROOT , 'dotnet' ) ,
374+ '/usr/bin/dotnet' ,
375+ '/usr/local/bin/dotnet' ,
376+ '/usr/share/dotnet/dotnet'
377+ ] . filter ( Boolean ) ;
378+
379+ let dotnetFound = false ;
380+ for ( const testPath of commonPaths ) {
381+ try {
382+ await fs . access ( testPath ) ;
383+ execFileSync ( testPath , [ '--version' ] , { stdio : 'ignore' } ) ;
384+ dotnetFound = true ;
385+ break ;
386+ } catch {
387+ continue ;
388+ }
389+ }
390+
391+ if ( ! dotnetFound ) {
392+ try {
393+ execFileSync ( 'dotnet' , [ '--version' ] , { stdio : 'ignore' } ) ;
394+ } catch {
395+ console . error ( 'Error: dotnet CLI is required but not installed' ) ;
396+ process . exit ( 1 ) ;
397+ }
441398 }
442399
443400 console . log ( '=== NuGet packages.config to packages.lock.json Converter ===' ) ;
@@ -457,14 +414,11 @@ async function main() {
457414 const result = await processPackagesConfig ( packagesConfig , tfm , failOnSkipped ) ;
458415 if ( result . success ) {
459416 successCount ++ ;
460- if ( result . skippedPackages && result . skippedPackages . length > 0 ) {
461- totalSkippedPackages . push ( ...result . skippedPackages ) ;
462- }
463417 } else {
464418 failCount ++ ;
465- if ( result . skippedPackages && result . skippedPackages . length > 0 ) {
466- totalSkippedPackages . push ( ... result . skippedPackages ) ;
467- }
419+ }
420+ if ( result . skippedPackages ?. length > 0 ) {
421+ totalSkippedPackages . push ( ... result . skippedPackages ) ;
468422 }
469423 }
470424
0 commit comments