1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-16 17:35:16 +00:00

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.
This commit is contained in:
Bhadra
2026-01-08 19:33:26 +05:30
parent 3ebaeefa18
commit 3653900e2d
4 changed files with 180 additions and 70 deletions

View File

@@ -124,7 +124,7 @@ function Get-SearchPaths {
} }
# Add default paths if no custom paths or custom paths don't exist # 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) { foreach ($path in $script:DefaultSearchPaths) {
if (Test-Path $path) { if (Test-Path $path) {
$paths += $path $paths += $path
@@ -201,7 +201,10 @@ function Find-Projects {
$esc = [char]27 $esc = [char]27
$pathCount = 0 $pathCount = 0
$totalPaths = $SearchPaths.Count $totalPaths = if ($null -eq $SearchPaths) { 0 } else { @($SearchPaths).Count }
if ($totalPaths -eq 0) {
return $projects
}
foreach ($searchPath in $SearchPaths) { foreach ($searchPath in $SearchPaths) {
$pathCount++ $pathCount++
@@ -217,16 +220,19 @@ function Find-Projects {
$projectPath = Split-Path -Parent $item.FullName $projectPath = Split-Path -Parent $item.FullName
# Skip if already found or if it's inside node_modules, etc. # 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 "*\node_modules\*") { continue }
if ($projectPath -like "*\vendor\*") { continue } if ($projectPath -like "*\vendor\*") { continue }
if ($projectPath -like "*\.git\*") { continue } if ($projectPath -like "*\.git\*") { continue }
# Find artifacts in this project # Find artifacts in this project
$artifacts = @(Find-ProjectArtifacts -ProjectPath $projectPath) $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 $totalSize = ($artifacts | Measure-Object -Property SizeKB -Sum).Sum
if ($null -eq $totalSize) { $totalSize = 0 }
$projects += [PSCustomObject]@{ $projects += [PSCustomObject]@{
Path = $projectPath Path = $projectPath
@@ -305,7 +311,8 @@ function Show-ProjectSelectionMenu {
#> #>
param([array]$Projects) 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" Write-Warning "No projects with cleanable artifacts found"
return @() return @()
} }
@@ -316,7 +323,7 @@ function Show-ProjectSelectionMenu {
$pageSize = 12 $pageSize = 12
$pageStart = 0 $pageStart = 0
[Console]::CursorVisible = $false try { [Console]::CursorVisible = $false } catch { }
try { try {
while ($true) { while ($true) {
@@ -330,7 +337,7 @@ function Show-ProjectSelectionMenu {
Write-Host "" Write-Host ""
# Display projects # Display projects
$pageEnd = [Math]::Min($pageStart + $pageSize, $Projects.Count) $pageEnd = [Math]::Min($pageStart + $pageSize, $projectCount)
for ($i = $pageStart; $i -lt $pageEnd; $i++) { for ($i = $pageStart; $i -lt $pageEnd; $i++) {
$project = $Projects[$i] $project = $Projects[$i]
@@ -348,7 +355,7 @@ function Show-ProjectSelectionMenu {
$name = $name.Substring(0, 27) + "..." $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 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 # Page indicator
$totalPages = [Math]::Ceiling($Projects.Count / $pageSize) $totalPages = [Math]::Ceiling($projectCount / $pageSize)
$currentPage = [Math]::Floor($pageStart / $pageSize) + 1 $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 # Handle input
$key = [Console]::ReadKey($true) $key = [Console]::ReadKey($true)
@@ -390,7 +397,7 @@ function Show-ProjectSelectionMenu {
} }
} }
'DownArrow' { 'DownArrow' {
if ($currentIndex -lt $Projects.Count - 1) { if ($currentIndex -lt $projectCount - 1) {
$currentIndex++ $currentIndex++
if ($currentIndex -ge $pageStart + $pageSize) { if ($currentIndex -ge $pageStart + $pageSize) {
$pageStart += $pageSize $pageStart += $pageSize
@@ -402,7 +409,7 @@ function Show-ProjectSelectionMenu {
$currentIndex = $pageStart $currentIndex = $pageStart
} }
'PageDown' { 'PageDown' {
$pageStart = [Math]::Min($Projects.Count - $pageSize, $pageStart + $pageSize) $pageStart = [Math]::Min($projectCount - $pageSize, $pageStart + $pageSize)
if ($pageStart -lt 0) { $pageStart = 0 } if ($pageStart -lt 0) { $pageStart = 0 }
$currentIndex = $pageStart $currentIndex = $pageStart
} }
@@ -416,11 +423,11 @@ function Show-ProjectSelectionMenu {
} }
'A' { 'A' {
# Select/deselect all # Select/deselect all
if ($selectedIndices.Count -eq $Projects.Count) { if ($selectedIndices.Count -eq $projectCount) {
$selectedIndices.Clear() $selectedIndices.Clear()
} }
else { else {
for ($i = 0; $i -lt $Projects.Count; $i++) { for ($i = 0; $i -lt $projectCount; $i++) {
$selectedIndices[$i] = $true $selectedIndices[$i] = $true
} }
} }
@@ -440,7 +447,7 @@ function Show-ProjectSelectionMenu {
} }
} }
finally { finally {
[Console]::CursorVisible = $true try { [Console]::CursorVisible = $true } catch { }
} }
} }
@@ -543,9 +550,9 @@ function Main {
Write-Host "" Write-Host ""
# Get search paths # 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-Warning "No valid search paths found"
Write-Host "Run 'mole purge -Paths' to configure search directories" Write-Host "Run 'mole purge -Paths' to configure search directories"
return return
@@ -554,9 +561,9 @@ function Main {
Write-Info "Searching in $($searchPaths.Count) directories..." Write-Info "Searching in $($searchPaths.Count) directories..."
# Find projects # 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 ""
Write-Host "$esc[32m$($script:Icons.Success)$esc[0m No cleanable artifacts found" Write-Host "$esc[32m$($script:Icons.Success)$esc[0m No cleanable artifacts found"
Write-Host "" Write-Host ""
@@ -564,6 +571,7 @@ function Main {
} }
$totalSize = ($projects | Measure-Object -Property TotalSizeKB -Sum).Sum $totalSize = ($projects | Measure-Object -Property TotalSizeKB -Sum).Sum
if ($null -eq $totalSize) { $totalSize = 0 }
$totalSizeHuman = Format-ByteSize -Bytes ($totalSize * 1024) $totalSizeHuman = Format-ByteSize -Bytes ($totalSize * 1024)
Write-Host "" Write-Host ""
@@ -571,9 +579,9 @@ function Main {
Write-Host "" Write-Host ""
# Project selection # 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" Write-Info "No projects selected"
return return
} }
@@ -582,6 +590,7 @@ function Main {
Clear-Host Clear-Host
Write-Host "" Write-Host ""
$selectedSize = ($selected | Measure-Object -Property TotalSizeKB -Sum).Sum $selectedSize = ($selected | Measure-Object -Property TotalSizeKB -Sum).Sum
if ($null -eq $selectedSize) { $selectedSize = 0 }
$selectedSizeHuman = Format-ByteSize -Bytes ($selectedSize * 1024) $selectedSizeHuman = Format-ByteSize -Bytes ($selectedSize * 1024)
Write-Host "$esc[33mThe following will be cleaned ($selectedSizeHuman):$esc[0m" Write-Host "$esc[33mThe following will be cleaned ($selectedSizeHuman):$esc[0m"

View File

@@ -130,44 +130,71 @@ function Get-InstalledApplications {
$count++ $count++
Write-Progress -Activity "Scanning applications" -Status "Registry path $count of $total" -PercentComplete (($count / $total) * 50) Write-Progress -Activity "Scanning applications" -Status "Registry path $count of $total" -PercentComplete (($count / $total) * 50)
$items = Get-ItemProperty -Path $path -ErrorAction SilentlyContinue | try {
Where-Object { $regItems = Get-ItemProperty -Path $path -ErrorAction SilentlyContinue
$_.DisplayName -and
$_.UninstallString -and
-not (Test-ProtectedApp $_.DisplayName)
}
foreach ($item in $items) { foreach ($item in $regItems) {
# Calculate size # Skip items without required properties
$sizeKB = 0 $displayName = $null
if ($item.EstimatedSize) { $uninstallString = $null
$sizeKB = [long]$item.EstimatedSize
}
elseif ($item.InstallLocation -and (Test-Path $item.InstallLocation)) {
$sizeKB = Get-PathSizeKB -Path $item.InstallLocation
}
# Get install date try { $displayName = $item.DisplayName } catch { }
$installDate = $null try { $uninstallString = $item.UninstallString } catch { }
if ($item.InstallDate) {
if ([string]::IsNullOrWhiteSpace($displayName) -or [string]::IsNullOrWhiteSpace($uninstallString)) {
continue
}
if (Test-ProtectedApp $displayName) {
continue
}
# Calculate size
$sizeKB = 0
try { 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 { } catch { }
}
$apps += [PSCustomObject]@{ # Get install date
Name = $item.DisplayName $installDate = $null
Publisher = $item.Publisher try {
Version = $item.DisplayVersion if ($item.InstallDate) {
SizeKB = $sizeKB $installDate = [DateTime]::ParseExact($item.InstallDate, "yyyyMMdd", $null)
SizeHuman = Format-ByteSize -Bytes ($sizeKB * 1024) }
InstallLocation = $item.InstallLocation }
UninstallString = $item.UninstallString catch { }
InstallDate = $installDate
Source = "Registry" # 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"
}
} }
} }
catch {
Write-Debug "Error scanning registry path $path : $_"
}
} }
# UWP / Store Apps # UWP / Store Apps
@@ -255,8 +282,8 @@ function Show-AppSelectionMenu {
$searchTerm = "" $searchTerm = ""
$filteredApps = $Apps $filteredApps = $Apps
# Hide cursor # Hide cursor (may fail in non-interactive terminals)
[Console]::CursorVisible = $false try { [Console]::CursorVisible = $false } catch { }
try { try {
while ($true) { while ($true) {
@@ -387,9 +414,9 @@ function Show-AppSelectionMenu {
# Search mode # Search mode
Write-Host "" Write-Host ""
Write-Host "Search: " -NoNewline Write-Host "Search: " -NoNewline
[Console]::CursorVisible = $true try { [Console]::CursorVisible = $true } catch { }
$searchTerm = Read-Host $searchTerm = Read-Host
[Console]::CursorVisible = $false try { [Console]::CursorVisible = $false } catch { }
if ($searchTerm) { if ($searchTerm) {
$filteredApps = $Apps | Where-Object { $_.Name -like "*$searchTerm*" } $filteredApps = $Apps | Where-Object { $_.Name -like "*$searchTerm*" }
@@ -412,7 +439,7 @@ function Show-AppSelectionMenu {
} }
} }
finally { finally {
[Console]::CursorVisible = $true try { [Console]::CursorVisible = $true } catch { }
} }
} }

View File

@@ -3,6 +3,7 @@
package main package main
import ( import (
"context"
"flag" "flag"
"fmt" "fmt"
"os" "os"
@@ -17,6 +18,13 @@ import (
tea "github.com/charmbracelet/bubbletea" 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 // ANSI color codes
const ( const (
colorReset = "\033[0m" colorReset = "\033[0m"
@@ -555,23 +563,88 @@ func scanDirectory(path string) ([]dirEntry, []fileEntry, int64, error) {
return dirEntries, largeFiles, totalSize, nil 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 { 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 { var size int64
if err != nil { var fileCount int64
return nil // Skip errors
} // Use a channel to signal completion
if !info.IsDir() { done := make(chan struct{})
size += info.Size()
} go func() {
return nil defer close(done)
}) walkDirWithLimit(ctx, path, 0, &size, &fileCount)
}()
select {
case <-done:
// Completed normally
case <-ctx.Done():
// Timeout - return partial size
}
return 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 // formatBytes formats bytes to human readable string
func formatBytes(bytes int64) string { func formatBytes(bytes int64) string {
const unit = 1024 const unit = 1024

View File

@@ -163,7 +163,8 @@ function Invoke-MoleCommand {
# Execute the command script with arguments using splatting # Execute the command script with arguments using splatting
# This properly handles switch parameters passed as strings # 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 # Build a hashtable for splatting
$splatParams = @{} $splatParams = @{}
$positionalArgs = @() $positionalArgs = @()