11import { z } from "zod"
22import { router , publicProcedure } from "../index"
3- import { readdir , stat , readFile , writeFile , mkdir } from "node:fs/promises"
4- import { join , relative , basename , extname } from "node:path"
5- import { app } from "electron"
3+ import { readdir , stat , readFile , writeFile , mkdir , rename as fsRename , rm } from "node:fs/promises"
4+ import { join , relative , basename , extname , dirname , resolve , isAbsolute } from "node:path"
5+ import { app , shell } from "electron"
66import { watch } from "node:fs"
77import { observable } from "@trpc/server/observable"
88
@@ -65,10 +65,43 @@ interface FileEntry {
6565 type : "file" | "folder"
6666}
6767
68- // Cache for file and folder listings
68+ // Cache for file and folder listings (bounded LRU)
69+ const MAX_CACHE_ENTRIES = 20
6970const fileListCache = new Map < string , { entries : FileEntry [ ] ; timestamp : number } > ( )
7071const CACHE_TTL = 5000 // 5 seconds
7172
73+ /**
74+ * Validate that a path doesn't contain path traversal attacks.
75+ * Checks for null bytes and ensures the resolved path stays within the expected parent.
76+ */
77+ function validatePathSafe ( targetPath : string , allowedParent ?: string ) : void {
78+ if ( targetPath . includes ( "\0" ) ) {
79+ throw new Error ( "Path contains invalid characters" )
80+ }
81+ if ( ! isAbsolute ( targetPath ) ) {
82+ throw new Error ( "Path must be absolute" )
83+ }
84+ const resolved = resolve ( targetPath )
85+ if ( allowedParent ) {
86+ const resolvedParent = resolve ( allowedParent )
87+ if ( ! resolved . startsWith ( resolvedParent + "/" ) && resolved !== resolvedParent ) {
88+ throw new Error ( "Path escapes allowed directory" )
89+ }
90+ }
91+ }
92+
93+ function validateFileName ( name : string ) : void {
94+ if ( name . includes ( "/" ) || name . includes ( "\\" ) ) {
95+ throw new Error ( "File name cannot contain path separators" )
96+ }
97+ if ( name . includes ( "\0" ) ) {
98+ throw new Error ( "File name contains invalid characters" )
99+ }
100+ if ( name === "." || name === ".." ) {
101+ throw new Error ( "Invalid file name" )
102+ }
103+ }
104+
72105/**
73106 * Recursively scan a directory and return all file and folder paths
74107 */
@@ -135,8 +168,21 @@ async function getEntryList(projectPath: string): Promise<FileEntry[]> {
135168 }
136169
137170 const entries = await scanDirectory ( projectPath )
138- fileListCache . set ( projectPath , { entries, timestamp : now } )
139171
172+ // Evict oldest entries if cache is full
173+ if ( fileListCache . size >= MAX_CACHE_ENTRIES ) {
174+ let oldest : string | null = null
175+ let oldestTime = Infinity
176+ for ( const [ key , val ] of fileListCache ) {
177+ if ( val . timestamp < oldestTime ) {
178+ oldestTime = val . timestamp
179+ oldest = key
180+ }
181+ }
182+ if ( oldest ) fileListCache . delete ( oldest )
183+ }
184+
185+ fileListCache . set ( projectPath , { entries, timestamp : now } )
140186 return entries
141187}
142188
@@ -146,14 +192,18 @@ async function getEntryList(projectPath: string): Promise<FileEntry[]> {
146192function filterEntries (
147193 entries : FileEntry [ ] ,
148194 query : string ,
149- limit : number
195+ limit : number ,
196+ typeFilter ?: "file" | "folder" ,
150197) : Array < { id : string ; label : string ; path : string ; repository : string ; type : "file" | "folder" } > {
151198 const queryLower = query . toLowerCase ( )
152199
153- // Filter entries that match the query
200+ // Filter entries that match the query and optional type filter
154201 let filtered = entries
202+ if ( typeFilter ) {
203+ filtered = filtered . filter ( ( entry ) => entry . type === typeFilter )
204+ }
155205 if ( query ) {
156- filtered = entries . filter ( ( entry ) => {
206+ filtered = filtered . filter ( ( entry ) => {
157207 const name = basename ( entry . path ) . toLowerCase ( )
158208 const pathLower = entry . path . toLowerCase ( )
159209 return name . includes ( queryLower ) || pathLower . includes ( queryLower )
@@ -198,7 +248,7 @@ function filterEntries(
198248 } )
199249
200250 // Limit results
201- const limited = filtered . slice ( 0 , Math . min ( limit , 200 ) )
251+ const limited = filtered . slice ( 0 , Math . min ( limit , 5000 ) )
202252
203253 // Map to expected format with type
204254 return limited . map ( ( entry ) => ( {
@@ -219,11 +269,12 @@ export const filesRouter = router({
219269 z . object ( {
220270 projectPath : z . string ( ) ,
221271 query : z . string ( ) . default ( "" ) ,
222- limit : z . number ( ) . min ( 1 ) . max ( 200 ) . default ( 50 ) ,
272+ limit : z . number ( ) . min ( 1 ) . max ( 5000 ) . default ( 50 ) ,
273+ typeFilter : z . enum ( [ "file" , "folder" ] ) . optional ( ) ,
223274 } )
224275 )
225276 . query ( async ( { input } ) => {
226- const { projectPath, query, limit } = input
277+ const { projectPath, query, limit, typeFilter } = input
227278
228279 if ( ! projectPath ) {
229280 return [ ]
@@ -239,16 +290,9 @@ export const filesRouter = router({
239290
240291 // Get entry list (cached or fresh scan)
241292 const entries = await getEntryList ( projectPath )
242-
243- // Debug: log folder count
244- const folderCount = entries . filter ( e => e . type === "folder" ) . length
245- const fileCount = entries . filter ( e => e . type === "file" ) . length
246- console . log ( `[files] Scanned ${ projectPath } : ${ folderCount } folders, ${ fileCount } files` )
247293
248294 // Filter and sort by query
249- const results = filterEntries ( entries , query , limit )
250- console . log ( `[files] Query "${ query } ": returning ${ results . length } results, folders: ${ results . filter ( r => r . type === "folder" ) . length } ` )
251- return results
295+ return filterEntries ( entries , query , limit , typeFilter )
252296 } catch ( error ) {
253297 console . error ( `[files] Error searching files:` , error )
254298 return [ ]
@@ -407,8 +451,15 @@ export const filesRouter = router({
407451
408452 // Generate filename with timestamp
409453 const finalFilename = filename || `pasted_${ Date . now ( ) } .txt`
454+
455+ // Validate filename doesn't contain path separators or null bytes
456+ validateFileName ( finalFilename )
457+
410458 const filePath = join ( pastedDir , finalFilename )
411459
460+ // Ensure the resolved path stays within the pasted directory
461+ validatePathSafe ( filePath , pastedDir )
462+
412463 // Write file
413464 await writeFile ( filePath , text , "utf-8" )
414465
@@ -420,4 +471,41 @@ export const filesRouter = router({
420471 size : text . length ,
421472 }
422473 } ) ,
474+
475+ /**
476+ * Rename a file or folder
477+ */
478+ renameFile : publicProcedure
479+ . input ( z . object ( {
480+ absolutePath : z . string ( ) ,
481+ newName : z . string ( ) . min ( 1 ) ,
482+ } ) )
483+ . mutation ( async ( { input } ) => {
484+ const { absolutePath, newName } = input
485+
486+ validatePathSafe ( absolutePath )
487+ validateFileName ( newName )
488+
489+ const dir = dirname ( absolutePath )
490+ const newPath = join ( dir , newName )
491+
492+ // Ensure the new path stays in the same directory
493+ validatePathSafe ( newPath , dir )
494+
495+ await fsRename ( absolutePath , newPath )
496+ return { success : true , newPath }
497+ } ) ,
498+
499+ /**
500+ * Delete a file or folder (move to trash)
501+ */
502+ deleteFile : publicProcedure
503+ . input ( z . object ( {
504+ absolutePath : z . string ( ) ,
505+ } ) )
506+ . mutation ( async ( { input } ) => {
507+ validatePathSafe ( input . absolutePath )
508+ await shell . trashItem ( input . absolutePath )
509+ return { success : true }
510+ } ) ,
423511} )
0 commit comments