From 3653900e2d81e7f73b0e26784f895b6abf86cb09 Mon Sep 17 00:00:00 2001 From: Bhadra Date: Thu, 8 Jan 2026 19:33:26 +0530 Subject: [PATCH] fix(windows): fix property access errors and scanning performance - Fix analyze: Add timeout and depth limits to calculateDirSize() to prevent indefinite scanning on large directories like user profile - Fix purge: Add null checks for .Count property access under StrictMode - Fix uninstall: Wrap registry property access in try/catch for items without DisplayName, and add null checks throughout - Fix mole.ps1: Add null check for Arguments.Count - Add try/catch around CursorVisible calls for non-interactive terminals All 73 Pester tests and all Go tests pass. --- windows/bin/purge.ps1 | 51 ++++++++++-------- windows/bin/uninstall.ps1 | 101 +++++++++++++++++++++++------------- windows/cmd/analyze/main.go | 95 +++++++++++++++++++++++++++++---- windows/mole.ps1 | 3 +- 4 files changed, 180 insertions(+), 70 deletions(-) diff --git a/windows/bin/purge.ps1 b/windows/bin/purge.ps1 index 883dcf6..5a5965e 100644 --- a/windows/bin/purge.ps1 +++ b/windows/bin/purge.ps1 @@ -124,7 +124,7 @@ function Get-SearchPaths { } # Add default paths if no custom paths or custom paths don't exist - if ($paths.Count -eq 0) { + if ($null -eq $paths -or @($paths).Count -eq 0) { foreach ($path in $script:DefaultSearchPaths) { if (Test-Path $path) { $paths += $path @@ -201,7 +201,10 @@ function Find-Projects { $esc = [char]27 $pathCount = 0 - $totalPaths = $SearchPaths.Count + $totalPaths = if ($null -eq $SearchPaths) { 0 } else { @($SearchPaths).Count } + if ($totalPaths -eq 0) { + return $projects + } foreach ($searchPath in $SearchPaths) { $pathCount++ @@ -217,16 +220,19 @@ function Find-Projects { $projectPath = Split-Path -Parent $item.FullName # Skip if already found or if it's inside node_modules, etc. - if ($projects.Path -contains $projectPath) { continue } + $existingPaths = @($projects | ForEach-Object { $_.Path }) + if ($existingPaths -contains $projectPath) { continue } if ($projectPath -like "*\node_modules\*") { continue } if ($projectPath -like "*\vendor\*") { continue } if ($projectPath -like "*\.git\*") { continue } # Find artifacts in this project $artifacts = @(Find-ProjectArtifacts -ProjectPath $projectPath) + $artifactCount = if ($null -eq $artifacts) { 0 } else { $artifacts.Count } - if ($artifacts.Count -gt 0) { + if ($artifactCount -gt 0) { $totalSize = ($artifacts | Measure-Object -Property SizeKB -Sum).Sum + if ($null -eq $totalSize) { $totalSize = 0 } $projects += [PSCustomObject]@{ Path = $projectPath @@ -305,7 +311,8 @@ function Show-ProjectSelectionMenu { #> param([array]$Projects) - if ($Projects.Count -eq 0) { + $projectCount = if ($null -eq $Projects) { 0 } else { @($Projects).Count } + if ($projectCount -eq 0) { Write-Warning "No projects with cleanable artifacts found" return @() } @@ -316,7 +323,7 @@ function Show-ProjectSelectionMenu { $pageSize = 12 $pageStart = 0 - [Console]::CursorVisible = $false + try { [Console]::CursorVisible = $false } catch { } try { while ($true) { @@ -330,7 +337,7 @@ function Show-ProjectSelectionMenu { Write-Host "" # Display projects - $pageEnd = [Math]::Min($pageStart + $pageSize, $Projects.Count) + $pageEnd = [Math]::Min($pageStart + $pageSize, $projectCount) for ($i = $pageStart; $i -lt $pageEnd; $i++) { $project = $Projects[$i] @@ -348,7 +355,7 @@ function Show-ProjectSelectionMenu { $name = $name.Substring(0, 27) + "..." } - $artifactCount = $project.Artifacts.Count + $artifactCount = if ($null -eq $project.Artifacts) { 0 } else { @($project.Artifacts).Count } Write-Host (" {0} {1,-32} {2,10} ({3} items)" -f $checkbox, $name, $project.TotalSizeHuman, $artifactCount) -NoNewline @@ -373,9 +380,9 @@ function Show-ProjectSelectionMenu { } # Page indicator - $totalPages = [Math]::Ceiling($Projects.Count / $pageSize) + $totalPages = [Math]::Ceiling($projectCount / $pageSize) $currentPage = [Math]::Floor($pageStart / $pageSize) + 1 - Write-Host "$esc[90mPage $currentPage of $totalPages | Total: $($Projects.Count) projects$esc[0m" + Write-Host "$esc[90mPage $currentPage of $totalPages | Total: $projectCount projects$esc[0m" # Handle input $key = [Console]::ReadKey($true) @@ -390,7 +397,7 @@ function Show-ProjectSelectionMenu { } } 'DownArrow' { - if ($currentIndex -lt $Projects.Count - 1) { + if ($currentIndex -lt $projectCount - 1) { $currentIndex++ if ($currentIndex -ge $pageStart + $pageSize) { $pageStart += $pageSize @@ -402,7 +409,7 @@ function Show-ProjectSelectionMenu { $currentIndex = $pageStart } 'PageDown' { - $pageStart = [Math]::Min($Projects.Count - $pageSize, $pageStart + $pageSize) + $pageStart = [Math]::Min($projectCount - $pageSize, $pageStart + $pageSize) if ($pageStart -lt 0) { $pageStart = 0 } $currentIndex = $pageStart } @@ -416,11 +423,11 @@ function Show-ProjectSelectionMenu { } 'A' { # Select/deselect all - if ($selectedIndices.Count -eq $Projects.Count) { + if ($selectedIndices.Count -eq $projectCount) { $selectedIndices.Clear() } else { - for ($i = 0; $i -lt $Projects.Count; $i++) { + for ($i = 0; $i -lt $projectCount; $i++) { $selectedIndices[$i] = $true } } @@ -440,7 +447,7 @@ function Show-ProjectSelectionMenu { } } finally { - [Console]::CursorVisible = $true + try { [Console]::CursorVisible = $true } catch { } } } @@ -543,9 +550,9 @@ function Main { Write-Host "" # Get search paths - $searchPaths = Get-SearchPaths + $searchPaths = @(Get-SearchPaths) - if ($searchPaths.Count -eq 0) { + if ($null -eq $searchPaths -or $searchPaths.Count -eq 0) { Write-Warning "No valid search paths found" Write-Host "Run 'mole purge -Paths' to configure search directories" return @@ -554,9 +561,9 @@ function Main { Write-Info "Searching in $($searchPaths.Count) directories..." # Find projects - $projects = Find-Projects -SearchPaths $searchPaths + $projects = @(Find-Projects -SearchPaths $searchPaths) - if ($projects.Count -eq 0) { + if ($null -eq $projects -or $projects.Count -eq 0) { Write-Host "" Write-Host "$esc[32m$($script:Icons.Success)$esc[0m No cleanable artifacts found" Write-Host "" @@ -564,6 +571,7 @@ function Main { } $totalSize = ($projects | Measure-Object -Property TotalSizeKB -Sum).Sum + if ($null -eq $totalSize) { $totalSize = 0 } $totalSizeHuman = Format-ByteSize -Bytes ($totalSize * 1024) Write-Host "" @@ -571,9 +579,9 @@ function Main { Write-Host "" # Project selection - $selected = Show-ProjectSelectionMenu -Projects $projects + $selected = @(Show-ProjectSelectionMenu -Projects $projects) - if ($selected.Count -eq 0) { + if ($null -eq $selected -or $selected.Count -eq 0) { Write-Info "No projects selected" return } @@ -582,6 +590,7 @@ function Main { Clear-Host Write-Host "" $selectedSize = ($selected | Measure-Object -Property TotalSizeKB -Sum).Sum + if ($null -eq $selectedSize) { $selectedSize = 0 } $selectedSizeHuman = Format-ByteSize -Bytes ($selectedSize * 1024) Write-Host "$esc[33mThe following will be cleaned ($selectedSizeHuman):$esc[0m" diff --git a/windows/bin/uninstall.ps1 b/windows/bin/uninstall.ps1 index ef9100c..85b148c 100644 --- a/windows/bin/uninstall.ps1 +++ b/windows/bin/uninstall.ps1 @@ -130,43 +130,70 @@ function Get-InstalledApplications { $count++ Write-Progress -Activity "Scanning applications" -Status "Registry path $count of $total" -PercentComplete (($count / $total) * 50) - $items = Get-ItemProperty -Path $path -ErrorAction SilentlyContinue | - Where-Object { - $_.DisplayName -and - $_.UninstallString -and - -not (Test-ProtectedApp $_.DisplayName) - } - - foreach ($item in $items) { - # Calculate size - $sizeKB = 0 - if ($item.EstimatedSize) { - $sizeKB = [long]$item.EstimatedSize - } - elseif ($item.InstallLocation -and (Test-Path $item.InstallLocation)) { - $sizeKB = Get-PathSizeKB -Path $item.InstallLocation - } + try { + $regItems = Get-ItemProperty -Path $path -ErrorAction SilentlyContinue - # Get install date - $installDate = $null - if ($item.InstallDate) { + foreach ($item in $regItems) { + # Skip items without required properties + $displayName = $null + $uninstallString = $null + + try { $displayName = $item.DisplayName } catch { } + try { $uninstallString = $item.UninstallString } catch { } + + if ([string]::IsNullOrWhiteSpace($displayName) -or [string]::IsNullOrWhiteSpace($uninstallString)) { + continue + } + + if (Test-ProtectedApp $displayName) { + continue + } + + # Calculate size + $sizeKB = 0 try { - $installDate = [DateTime]::ParseExact($item.InstallDate, "yyyyMMdd", $null) + if ($item.EstimatedSize) { + $sizeKB = [long]$item.EstimatedSize + } + elseif ($item.InstallLocation -and (Test-Path $item.InstallLocation -ErrorAction SilentlyContinue)) { + $sizeKB = Get-PathSizeKB -Path $item.InstallLocation + } } catch { } + + # Get install date + $installDate = $null + try { + if ($item.InstallDate) { + $installDate = [DateTime]::ParseExact($item.InstallDate, "yyyyMMdd", $null) + } + } + catch { } + + # Get other properties safely + $publisher = $null + $version = $null + $installLocation = $null + + try { $publisher = $item.Publisher } catch { } + try { $version = $item.DisplayVersion } catch { } + try { $installLocation = $item.InstallLocation } catch { } + + $apps += [PSCustomObject]@{ + Name = $displayName + Publisher = $publisher + Version = $version + SizeKB = $sizeKB + SizeHuman = Format-ByteSize -Bytes ($sizeKB * 1024) + InstallLocation = $installLocation + UninstallString = $uninstallString + InstallDate = $installDate + Source = "Registry" + } } - - $apps += [PSCustomObject]@{ - Name = $item.DisplayName - Publisher = $item.Publisher - Version = $item.DisplayVersion - SizeKB = $sizeKB - SizeHuman = Format-ByteSize -Bytes ($sizeKB * 1024) - InstallLocation = $item.InstallLocation - UninstallString = $item.UninstallString - InstallDate = $installDate - Source = "Registry" - } + } + catch { + Write-Debug "Error scanning registry path $path : $_" } } @@ -255,8 +282,8 @@ function Show-AppSelectionMenu { $searchTerm = "" $filteredApps = $Apps - # Hide cursor - [Console]::CursorVisible = $false + # Hide cursor (may fail in non-interactive terminals) + try { [Console]::CursorVisible = $false } catch { } try { while ($true) { @@ -387,9 +414,9 @@ function Show-AppSelectionMenu { # Search mode Write-Host "" Write-Host "Search: " -NoNewline - [Console]::CursorVisible = $true + try { [Console]::CursorVisible = $true } catch { } $searchTerm = Read-Host - [Console]::CursorVisible = $false + try { [Console]::CursorVisible = $false } catch { } if ($searchTerm) { $filteredApps = $Apps | Where-Object { $_.Name -like "*$searchTerm*" } @@ -412,7 +439,7 @@ function Show-AppSelectionMenu { } } finally { - [Console]::CursorVisible = $true + try { [Console]::CursorVisible = $true } catch { } } } diff --git a/windows/cmd/analyze/main.go b/windows/cmd/analyze/main.go index 5da970d..0504234 100644 --- a/windows/cmd/analyze/main.go +++ b/windows/cmd/analyze/main.go @@ -3,6 +3,7 @@ package main import ( + "context" "flag" "fmt" "os" @@ -17,6 +18,13 @@ import ( tea "github.com/charmbracelet/bubbletea" ) +// Scanning limits to prevent infinite scanning +const ( + dirSizeTimeout = 5 * time.Second // Max time to calculate a single directory size + maxFilesPerDir = 50000 // Max files to scan per directory + maxScanDepth = 50 // Max recursion depth +) + // ANSI color codes const ( colorReset = "\033[0m" @@ -555,23 +563,88 @@ func scanDirectory(path string) ([]dirEntry, []fileEntry, int64, error) { return dirEntries, largeFiles, totalSize, nil } -// calculateDirSize calculates the size of a directory +// calculateDirSize calculates the size of a directory with timeout and limits func calculateDirSize(path string) int64 { - var size int64 + ctx, cancel := context.WithTimeout(context.Background(), dirSizeTimeout) + defer cancel() - filepath.Walk(path, func(p string, info os.FileInfo, err error) error { - if err != nil { - return nil // Skip errors - } - if !info.IsDir() { - size += info.Size() - } - return nil - }) + var size int64 + var fileCount int64 + + // Use a channel to signal completion + done := make(chan struct{}) + + go func() { + defer close(done) + walkDirWithLimit(ctx, path, 0, &size, &fileCount) + }() + + select { + case <-done: + // Completed normally + case <-ctx.Done(): + // Timeout - return partial size + } return size } +// walkDirWithLimit walks a directory with depth limit and file count limit +func walkDirWithLimit(ctx context.Context, path string, depth int, size *int64, fileCount *int64) { + // Check context cancellation + select { + case <-ctx.Done(): + return + default: + } + + // Check depth limit + if depth > maxScanDepth { + return + } + + // Check file count limit + if atomic.LoadInt64(fileCount) > maxFilesPerDir { + return + } + + entries, err := os.ReadDir(path) + if err != nil { + return + } + + for _, entry := range entries { + // Check cancellation frequently + select { + case <-ctx.Done(): + return + default: + } + + // Check file count limit + if atomic.LoadInt64(fileCount) > maxFilesPerDir { + return + } + + entryPath := filepath.Join(path, entry.Name()) + + if entry.IsDir() { + // Skip system/protected directories + name := entry.Name() + if skipPatterns[name] || strings.HasPrefix(name, ".") && len(name) > 1 { + continue + } + walkDirWithLimit(ctx, entryPath, depth+1, size, fileCount) + } else { + info, err := entry.Info() + if err == nil { + atomic.AddInt64(size, info.Size()) + atomic.AddInt64(fileCount, 1) + } + } + } +} + // formatBytes formats bytes to human readable string func formatBytes(bytes int64) string { const unit = 1024 diff --git a/windows/mole.ps1 b/windows/mole.ps1 index cc8f1b0..08edd98 100644 --- a/windows/mole.ps1 +++ b/windows/mole.ps1 @@ -163,7 +163,8 @@ function Invoke-MoleCommand { # Execute the command script with arguments using splatting # This properly handles switch parameters passed as strings - if ($Arguments -and $Arguments.Count -gt 0) { + $argCount = if ($null -eq $Arguments) { 0 } else { @($Arguments).Count } + if ($argCount -gt 0) { # Build a hashtable for splatting $splatParams = @{} $positionalArgs = @()