Skip to content

Commit a4be1f2

Browse files
committed
feat: implement Phase 2 core scanning with caching, validation, and progress tracking
1 parent 8b249db commit a4be1f2

6 files changed

Lines changed: 334 additions & 120 deletions

File tree

‎src/main/drive-utils.ts‎

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,39 @@ export function getDrives(): DriveInfo[] {
88
// Windows: use wmic to get drive info
99
const output = execSync(
1010
'wmic logicaldisk get name,size,freespace /csv',
11-
{ encoding: 'utf-8' }
11+
{
12+
encoding: 'utf-8',
13+
stdio: ['pipe', 'pipe', 'pipe'],
14+
timeout: 5000,
15+
}
1216
)
1317

1418
const lines = output.trim().split('\n')
15-
// Skip header
19+
1620
for (let i = 1; i < lines.length; i++) {
17-
const parts = lines[i].split(',')
21+
const parts = lines[i].split(',').map(p => p.trim())
22+
1823
if (parts.length >= 3) {
1924
const letter = parts[0].trim()
20-
const total = parseInt(parts[1].trim()) || 0
21-
const free = parseInt(parts[2].trim()) || 0
22-
const used = total - free
25+
const totalStr = parts[1].trim()
26+
const freeStr = parts[2].trim()
27+
28+
// Validate input
29+
if (!letter || !totalStr || !freeStr) continue
30+
if (!letter.match(/^[A-Z]:$/)) continue
31+
32+
const total = parseInt(totalStr, 10)
33+
const free = parseInt(freeStr, 10)
34+
35+
if (!isNaN(total) && !isNaN(free) && total > 0) {
36+
const used = Math.max(0, total - free)
2337

24-
if (letter && total > 0) {
2538
drives.push({
2639
letter: letter.replace(':', ''),
2740
name: letter,
2841
total,
2942
used,
30-
free,
43+
free: Math.max(0, free),
3144
})
3245
}
3346
}
@@ -36,5 +49,5 @@ export function getDrives(): DriveInfo[] {
3649
console.error('Failed to get drives:', err)
3750
}
3851

39-
return drives
52+
return drives.sort((a, b) => a.letter.localeCompare(b.letter))
4053
}

‎src/main/file-ops.ts‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,22 @@ import fs from 'fs/promises'
22
import path from 'path'
33

44
export async function deleteFile(filePath: string): Promise<void> {
5+
// Security: Validate path to prevent directory traversal
6+
const normalizedPath = path.normalize(filePath)
7+
const realPath = await fs.realpath(filePath)
8+
9+
// Ensure the real path hasn't escaped the intended directory
10+
if (!realPath.includes(normalizedPath.split(path.sep)[0])) {
11+
throw new Error('Invalid path: directory traversal detected')
12+
}
13+
514
try {
6-
const stat = await fs.stat(filePath)
15+
const stat = await fs.stat(realPath)
716

817
if (stat.isDirectory()) {
9-
// Recursively delete directory
10-
await deleteDirectory(filePath)
18+
await deleteDirectory(realPath)
1119
} else {
12-
// Delete file
13-
await fs.unlink(filePath)
20+
await fs.unlink(realPath)
1421
}
1522
} catch (err) {
1623
throw new Error(`Failed to delete ${filePath}: ${err}`)
@@ -24,10 +31,17 @@ async function deleteDirectory(dirPath: string): Promise<void> {
2431
for (const entry of entries) {
2532
const fullPath = path.join(dirPath, entry.name)
2633

27-
if (entry.isDirectory()) {
28-
await deleteDirectory(fullPath)
29-
} else {
30-
await fs.unlink(fullPath)
34+
try {
35+
const stat = await fs.stat(fullPath)
36+
37+
if (stat.isDirectory()) {
38+
await deleteDirectory(fullPath)
39+
} else {
40+
await fs.unlink(fullPath)
41+
}
42+
} catch (err) {
43+
// Log but continue deleting other files
44+
console.error(`Could not delete ${fullPath}:`, err)
3145
}
3246
}
3347

‎src/main/ipc-handlers.ts‎

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,74 @@ import { deleteFile } from './file-ops.js'
66

77
export function setupIPCHandlers() {
88
ipcMain.handle(IPC_CHANNELS.GET_DRIVES, async () => {
9-
return getDrives()
9+
try {
10+
return getDrives()
11+
} catch (err) {
12+
console.error('Error getting drives:', err)
13+
return []
14+
}
1015
})
1116

12-
ipcMain.handle(IPC_CHANNELS.SCAN_DRIVE, async (event, drive: string) => {
13-
const window = BrowserWindow.fromWebContents(event.sender)
14-
if (!window) return
17+
ipcMain.handle(
18+
IPC_CHANNELS.SCAN_DRIVE,
19+
async (event, drive: string | unknown) => {
20+
// Input validation
21+
if (typeof drive !== 'string') {
22+
throw new Error('Invalid drive parameter')
23+
}
1524

16-
return scanDrive(drive, (progress) => {
17-
window.webContents.send(IPC_CHANNELS.SCAN_PROGRESS, progress)
18-
})
19-
})
25+
if (!/^[A-Z]:?$/.test(drive.toUpperCase())) {
26+
throw new Error('Invalid drive letter')
27+
}
2028

21-
ipcMain.handle(IPC_CHANNELS.DELETE_FILE, async (_event, path: string) => {
22-
return deleteFile(path)
23-
})
29+
const window = BrowserWindow.fromWebContents(event.sender)
30+
if (!window) {
31+
throw new Error('Window not found')
32+
}
33+
34+
try {
35+
return await scanDrive(drive, (progress) => {
36+
window.webContents.send(IPC_CHANNELS.SCAN_PROGRESS, progress)
37+
})
38+
} catch (err) {
39+
console.error('Scan error:', err)
40+
throw err
41+
}
42+
}
43+
)
44+
45+
ipcMain.handle(
46+
IPC_CHANNELS.DELETE_FILE,
47+
async (_event, filePath: string | unknown) => {
48+
// Input validation
49+
if (typeof filePath !== 'string') {
50+
throw new Error('Invalid path parameter')
51+
}
52+
53+
if (filePath.length === 0 || filePath.length > 260) {
54+
throw new Error('Invalid path length')
55+
}
56+
57+
// Prevent deletion of critical system paths
58+
const forbiddenPaths = [
59+
'C:\\Windows',
60+
'C:\\Program Files',
61+
'C:\\Program Files (x86)',
62+
'C:\\ProgramData',
63+
'C:\\Users',
64+
]
65+
66+
const normalizedPath = filePath.toUpperCase().replace(/\//g, '\\')
67+
if (forbiddenPaths.some(fp => normalizedPath.startsWith(fp))) {
68+
throw new Error('Cannot delete system directory')
69+
}
70+
71+
try {
72+
return await deleteFile(filePath)
73+
} catch (err) {
74+
console.error('Delete error:', err)
75+
throw err
76+
}
77+
}
78+
)
2479
}

‎src/main/scanner.ts‎

Lines changed: 90 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -6,74 +6,112 @@ interface ScanCallback {
66
(progress: ScanProgress): void
77
}
88

9-
let scannedCount = 0
10-
let totalDirs = 0
9+
interface ScanCache {
10+
[path: string]: FolderInfo
11+
}
12+
13+
const scanCache: ScanCache = {}
1114

1215
export async function scanDrive(
1316
drive: string,
1417
onProgress: ScanCallback
1518
): Promise<FolderInfo> {
16-
scannedCount = 0
17-
totalDirs = 1
19+
const normalizedDrive = normalizePath(drive)
20+
21+
// Check cache first
22+
if (scanCache[normalizedDrive]) {
23+
return scanCache[normalizedDrive]
24+
}
1825

1926
const root: FolderInfo = {
20-
name: drive,
21-
path: drive,
27+
name: normalizedDrive,
28+
path: normalizedDrive,
2229
size: 0,
2330
children: [],
2431
fileCount: 0,
2532
}
2633

27-
await scanDirectory(drive, root, onProgress)
28-
return root
29-
}
34+
let scannedCount = 0
35+
let totalDirs = 1
3036

31-
async function scanDirectory(
32-
dirPath: string,
33-
parent: FolderInfo,
34-
onProgress: ScanCallback
35-
): Promise<void> {
36-
try {
37-
const entries = await fs.readdir(dirPath, { withFileTypes: true })
38-
39-
for (const entry of entries) {
40-
try {
41-
const fullPath = path.join(dirPath, entry.name)
42-
43-
if (entry.isDirectory()) {
44-
const folder: FolderInfo = {
45-
name: entry.name,
46-
path: fullPath,
47-
size: 0,
48-
children: [],
49-
fileCount: 0,
50-
}
37+
async function scanDirectory(dirPath: string, parent: FolderInfo): Promise<void> {
38+
try {
39+
const entries = await fs.readdir(dirPath, { withFileTypes: true })
40+
const folders: FolderInfo[] = []
41+
42+
for (const entry of entries) {
43+
try {
44+
const fullPath = path.join(dirPath, entry.name)
45+
46+
if (entry.isDirectory()) {
47+
const folder: FolderInfo = {
48+
name: entry.name,
49+
path: fullPath,
50+
size: 0,
51+
children: [],
52+
fileCount: 0,
53+
}
54+
55+
folders.push(folder)
56+
scannedCount++
57+
totalDirs++
58+
59+
if (scannedCount % 10 === 0) {
60+
onProgress({
61+
current: scannedCount,
62+
total: totalDirs,
63+
percentage: Math.round(
64+
(scannedCount / Math.max(totalDirs, 1)) * 100
65+
),
66+
currentPath: fullPath,
67+
})
68+
}
5169

52-
parent.children.push(folder)
53-
scannedCount++
54-
totalDirs++
55-
56-
onProgress({
57-
current: scannedCount,
58-
total: totalDirs,
59-
percentage: Math.round((scannedCount / Math.max(totalDirs, 1)) * 100),
60-
currentPath: fullPath,
61-
})
62-
63-
await scanDirectory(fullPath, folder, onProgress)
64-
65-
parent.size += folder.size
66-
parent.fileCount += folder.fileCount
67-
} else {
68-
const stat = await fs.stat(fullPath)
69-
parent.size += stat.size
70-
parent.fileCount++
70+
await scanDirectory(fullPath, folder)
71+
72+
parent.size += folder.size
73+
parent.fileCount += folder.fileCount
74+
} else {
75+
try {
76+
const stat = await fs.stat(fullPath)
77+
parent.size += stat.size
78+
parent.fileCount++
79+
} catch {
80+
// File may have been deleted, skip it
81+
}
82+
}
83+
} catch {
84+
// Skip inaccessible files/folders
7185
}
72-
} catch (err) {
73-
// Skip files/folders we can't access
7486
}
87+
88+
parent.children = folders.sort((a, b) => b.size - a.size)
89+
} catch {
90+
// Skip directories we cannot read
7591
}
76-
} catch (err) {
77-
// Skip directories we can't access
7892
}
93+
94+
await scanDirectory(normalizedDrive, root)
95+
onProgress({
96+
current: scannedCount,
97+
total: totalDirs,
98+
percentage: 100,
99+
currentPath: normalizedDrive,
100+
})
101+
102+
// Cache the result
103+
scanCache[normalizedDrive] = root
104+
return root
105+
}
106+
107+
export function clearScanCache(): void {
108+
Object.keys(scanCache).forEach(key => delete scanCache[key])
109+
}
110+
111+
export function getCachedScan(drivePath: string): FolderInfo | undefined {
112+
return scanCache[normalizePath(drivePath)]
113+
}
114+
115+
function normalizePath(drivePath: string): string {
116+
return drivePath.replace(/\\/g, '/').toUpperCase()
79117
}

0 commit comments

Comments
 (0)