@@ -349,6 +349,62 @@ export async function handleLocalOp(
349349/** Directory names that are listed at their level but never recursed into. */
350350const SKIP_DIRS = new Set ( [ "node_modules" ] ) ;
351351
352+ /**
353+ * Check whether an entry is inside a hidden dir or node_modules.
354+ * Top-level skip-dirs (relFromTarget === "") are still listed.
355+ */
356+ function isInsideSkippedDir ( relFromTarget : string ) : boolean {
357+ if ( relFromTarget === "" ) {
358+ return false ;
359+ }
360+ const segments = relFromTarget . split ( path . sep ) ;
361+ return segments . some ( ( s ) => s . startsWith ( "." ) || SKIP_DIRS . has ( s ) ) ;
362+ }
363+
364+ /** Return true when a symlink resolves to a path outside `cwd`. */
365+ function isEscapingSymlink (
366+ entry : fs . Dirent ,
367+ cwd : string ,
368+ relPath : string
369+ ) : boolean {
370+ if ( ! entry . isSymbolicLink ( ) ) {
371+ return false ;
372+ }
373+ try {
374+ safePath ( cwd , relPath ) ;
375+ return false ;
376+ } catch {
377+ return true ;
378+ }
379+ }
380+
381+ /** Convert a Dirent to a DirEntry, or return null if it should be skipped. */
382+ function toDirEntry (
383+ entry : fs . Dirent ,
384+ cwd : string ,
385+ targetPath : string ,
386+ maxDepth : number
387+ ) : DirEntry | null {
388+ const relFromTarget = path . relative ( targetPath , entry . parentPath ) ;
389+ const depth = relFromTarget === "" ? 0 : relFromTarget . split ( path . sep ) . length ;
390+
391+ if ( depth > maxDepth ) {
392+ return null ;
393+ }
394+ if ( isInsideSkippedDir ( relFromTarget ) ) {
395+ return null ;
396+ }
397+
398+ const relPath = path . relative ( cwd , path . join ( entry . parentPath , entry . name ) ) ;
399+
400+ if ( isEscapingSymlink ( entry , cwd , relPath ) ) {
401+ return null ;
402+ }
403+
404+ const type = entry . isDirectory ( ) ? "directory" : "file" ;
405+ return { name : entry . name , path : relPath , type } ;
406+ }
407+
352408async function listDir ( payload : ListDirPayload ) : Promise < LocalOpResult > {
353409 const { cwd, params } = payload ;
354410 const targetPath = safePath ( cwd , params . path ) ;
@@ -364,37 +420,14 @@ async function listDir(payload: ListDirPayload): Promise<LocalOpResult> {
364420 bufferSize : 1024 ,
365421 } ) ;
366422
367- for await ( const entry of dir ) {
368- if ( entries . length >= maxEntries ) break ;
369-
370- const relFromTarget = path . relative ( targetPath , entry . parentPath ) ;
371- const depth =
372- relFromTarget === "" ? 0 : relFromTarget . split ( path . sep ) . length ;
373-
374- if ( depth > maxDepth ) continue ;
375-
376- // Skip entries nested inside hidden dirs or node_modules,
377- // but still list the skip-dirs themselves at their parent level.
378- if ( relFromTarget !== "" ) {
379- const segments = relFromTarget . split ( path . sep ) ;
380- if ( segments . some ( ( s ) => s . startsWith ( "." ) || SKIP_DIRS . has ( s ) ) ) {
381- continue ;
382- }
423+ for await ( const dirent of dir ) {
424+ if ( entries . length >= maxEntries ) {
425+ break ;
383426 }
384-
385- const relPath = path . relative ( cwd , path . join ( entry . parentPath , entry . name ) ) ;
386-
387- // If this entry is a symlink, verify it doesn't escape the project directory.
388- if ( entry . isSymbolicLink ( ) ) {
389- try {
390- safePath ( cwd , relPath ) ;
391- } catch {
392- continue ;
393- }
427+ const parsed = toDirEntry ( dirent , cwd , targetPath , maxDepth ) ;
428+ if ( parsed ) {
429+ entries . push ( parsed ) ;
394430 }
395-
396- const type = entry . isDirectory ( ) ? "directory" : "file" ;
397- entries . push ( { name : entry . name , path : relPath , type } ) ;
398431 }
399432 } catch {
400433 // Directory doesn't exist or can't be read
0 commit comments