diff --git a/NetDiagram-PS/NetDiagram-PS.psm1 b/NetDiagram-PS/NetDiagram-PS.psm1 index 99301e2..c632474 100644 --- a/NetDiagram-PS/NetDiagram-PS.psm1 +++ b/NetDiagram-PS/NetDiagram-PS.psm1 @@ -10,22 +10,25 @@ #> Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' +$local:ErrorActionPreference = 'Stop' # Module-level variables $script:ModuleVersion = '1.4.1' # Dot-source private helpers first (so public functions can call them), -# then public commands. -$Private = @(Get-ChildItem -Path "$PSScriptRoot/Private/*.ps1" -ErrorAction SilentlyContinue) -$Public = @(Get-ChildItem -Path "$PSScriptRoot/Public/*.ps1" -ErrorAction SilentlyContinue) +# then public commands. Fail fast if directories are missing — SilentlyContinue +# would hide a broken install as a half-loaded module. +$Private = @(Get-ChildItem -Path "$PSScriptRoot/Private/*.ps1" -ErrorAction Stop) +$Public = @(Get-ChildItem -Path "$PSScriptRoot/Public/*.ps1" -ErrorAction Stop) +if ($Private.Count -eq 0) { throw "NetDiagram-PS: no Private/*.ps1 files found under $PSScriptRoot" } +if ($Public.Count -eq 0) { throw "NetDiagram-PS: no Public/*.ps1 files found under $PSScriptRoot" } foreach ($file in @($Private + $Public)) { try { . $file.FullName } catch { - Write-Error "Failed to import $($file.FullName): $_" + throw "Failed to import $($file.FullName): $_" } } diff --git a/NetDiagram-PS/Private/ConvertFrom-ArpText.ps1 b/NetDiagram-PS/Private/ConvertFrom-ArpText.ps1 index a1a8f8a..89e6610 100644 --- a/NetDiagram-PS/Private/ConvertFrom-ArpText.ps1 +++ b/NetDiagram-PS/Private/ConvertFrom-ArpText.ps1 @@ -26,7 +26,8 @@ function ConvertFrom-ArpText { } } else { - throw "Unable to parse macOS ARP entry: $line" + Write-Warning "Skipping unparseable macOS ARP entry: $line" + continue } } elseif ($Format -eq 'LinuxIp') { @@ -53,7 +54,13 @@ function ConvertFrom-ArpText { else { if ($line -notmatch '\s+at\s+') { continue } if ($line -notmatch '\((\d+\.\d+\.\d+\.\d+)\)\s+at\s+([0-9a-fA-F:]{17})') { - throw "Unable to parse Linux arp entry: $line" + $parsed = $null + if ([System.Net.IPAddress]::TryParse(($line -replace '.*\(([^)]+)\).*', '$1'), [ref]$parsed)) { + Write-Warning "Skipping unparseable Linux arp entry (invalid IP): $line" + } else { + Write-Warning "Skipping unparseable Linux arp entry: $line" + } + continue } $entry = [pscustomobject]@{ IPAddress = $matches[1]; MACAddress = $matches[2].ToUpperInvariant() diff --git a/NetDiagram-PS/Private/Get-IPv6CidrScanTarget.ps1 b/NetDiagram-PS/Private/Get-IPv6CidrScanTarget.ps1 index 5a5f782..c0e29f0 100644 --- a/NetDiagram-PS/Private/Get-IPv6CidrScanTarget.ps1 +++ b/NetDiagram-PS/Private/Get-IPv6CidrScanTarget.ps1 @@ -45,7 +45,12 @@ function Get-IPv6CidrScanTarget { $bytes = [byte[]]::new(16) [Array]::Copy($baseBytes, $bytes, 16) - $bytes[15] = [byte]($baseBytes[15] + $offset) + $carry = $offset + for ($b = 15; $b -ge 0 -and $carry -gt 0; $b--) { + $sum = $bytes[$b] + $carry + $bytes[$b] = [byte]($sum -band 0xFF) + $carry = $sum -shr 8 + } $null = $targets.Add(([System.Net.IPAddress]::new($bytes)).ToString()) } diff --git a/NetDiagram-PS/Private/Wait-DnsLookupTask.ps1 b/NetDiagram-PS/Private/Wait-DnsLookupTask.ps1 index 59fe638..342f2b7 100644 --- a/NetDiagram-PS/Private/Wait-DnsLookupTask.ps1 +++ b/NetDiagram-PS/Private/Wait-DnsLookupTask.ps1 @@ -7,6 +7,8 @@ function Wait-DnsLookupTask { try { if ($LookupTask.Wait([TimeSpan]::FromSeconds($TimeoutSeconds))) { + if ($LookupTask.IsFaulted) { throw $LookupTask.Exception.InnerException ?? $LookupTask.Exception } + if ($LookupTask.IsCanceled) { throw [System.OperationCanceledException]::new("DNS lookup canceled for $IPAddress") } $result = $LookupTask.Result return [pscustomobject]@{ IPAddress = $IPAddress @@ -16,10 +18,12 @@ function Wait-DnsLookupTask { } } - Write-Verbose "DNS lookup for $IPAddress timed out after $TimeoutSeconds second(s)" + Write-Verbose "DNS lookup for $IPAddress timed out after $TimeoutSeconds second(s) — abandoning task (observing fault to prevent UnobservedTaskException)" + if ($LookupTask.IsFaulted) { $null = $LookupTask.Exception } } catch { Write-Verbose "DNS lookup for $IPAddress failed: $($_.Exception.Message)" + if ($LookupTask.IsFaulted) { $null = $LookupTask.Exception } } return [pscustomobject]@{ diff --git a/NetDiagram-PS/Public/Get-SnmpNeighbors.ps1 b/NetDiagram-PS/Public/Get-SnmpNeighbors.ps1 index 6563f44..a34dc94 100644 --- a/NetDiagram-PS/Public/Get-SnmpNeighbors.ps1 +++ b/NetDiagram-PS/Public/Get-SnmpNeighbors.ps1 @@ -255,7 +255,7 @@ All SNMP attempts will be logged to verbose output. Write-Verbose "Discovered $($discoveredEdges.Count) SNMP edges" $summaryLine = "SNMP summary: queried $($snmpCounts.queried) answered $($snmpCounts.answered) skippedNoCredential $($snmpCounts.skippedNoCredential) skippedError $($snmpCounts.skippedError) skippedNoData $($snmpCounts.skippedNoData)" - Write-Host $summaryLine + Write-Information $summaryLine -Tags 'NetDiagram' Write-Verbose $summaryLine if ($TryPublic -and $publicAttempts -gt 0) { diff --git a/NetDiagram-PS/Public/Invoke-PortScan.ps1 b/NetDiagram-PS/Public/Invoke-PortScan.ps1 index bb2fb73..29349ac 100644 --- a/NetDiagram-PS/Public/Invoke-PortScan.ps1 +++ b/NetDiagram-PS/Public/Invoke-PortScan.ps1 @@ -85,7 +85,7 @@ function Invoke-PortScan { } } catch { - # Banner grab failed, that's OK + Write-Verbose "Banner grab failed for ${ip}:$port — $($_.Exception.Message)" } } @@ -99,7 +99,7 @@ function Invoke-PortScan { Write-Verbose " Port $port - Open" } catch { - # Port closed or filtered + Write-Verbose "Port $port on $ip closed or filtered — $($_.Exception.Message)" } finally { if ($connectResult -and $connectResult.AsyncWaitHandle) { diff --git a/NetDiagram-PS/Public/Invoke-SnmpWalk.ps1 b/NetDiagram-PS/Public/Invoke-SnmpWalk.ps1 index 9876c6d..979aa0a 100644 --- a/NetDiagram-PS/Public/Invoke-SnmpWalk.ps1 +++ b/NetDiagram-PS/Public/Invoke-SnmpWalk.ps1 @@ -80,24 +80,39 @@ function Invoke-SnmpWalk { } try { - $arguments = @( - '-' + $Version.ToLower() - '-c', $Community - '-t', $TimeoutSeconds.ToString() - $TargetIP - $OID - ) - - # Never emit the community string in verbose output. Redact '-c ' - # to '-c ****'. (The community is still visible in the process argument list - # while snmpwalk runs - see the SECURITY note in the function help.) - $redactedArgs = @( - '-' + $Version.ToLower() - '-c', '****' - '-t', $TimeoutSeconds.ToString() - $TargetIP - $OID - ) + $isV3 = $Version.ToLower() -eq 'v3' + if ($isV3) { + $arguments = @( + '-v', '3' + '-u', $Community + '-t', $TimeoutSeconds.ToString() + $TargetIP + $OID + ) + $redactedArgs = @( + '-v', '3' + '-u', '****' + '-t', $TimeoutSeconds.ToString() + $TargetIP + $OID + ) + } + else { + $arguments = @( + '-' + $Version.ToLower() + '-c', $Community + '-t', $TimeoutSeconds.ToString() + $TargetIP + $OID + ) + $redactedArgs = @( + '-' + $Version.ToLower() + '-c', '****' + '-t', $TimeoutSeconds.ToString() + $TargetIP + $OID + ) + } Write-Verbose "Running: $snmpWalkCmd $($redactedArgs -join ' ')" $output = & $snmpWalkCmd @arguments 2>&1 diff --git a/NetDiagram-PS/Public/Test-DeviceReachability.ps1 b/NetDiagram-PS/Public/Test-DeviceReachability.ps1 index 65498b7..51ca4fa 100644 --- a/NetDiagram-PS/Public/Test-DeviceReachability.ps1 +++ b/NetDiagram-PS/Public/Test-DeviceReachability.ps1 @@ -85,7 +85,9 @@ function Test-DeviceReachability { $tcp.Dispose() if ($ok) { $null = $resultsDict.TryAdd($node.IP, $true); continue } } else { $tcp.Dispose() } - } catch {} + } catch { + Write-Verbose "TCP fallback probe failed for $($node.IP):$fallbackPort — $($_.Exception.Message)" + } } $null = $resultsDict.TryAdd($node.IP, $false) } diff --git a/tests/NetDiagram.Tests.ps1 b/tests/NetDiagram.Tests.ps1 index 70bb4f0..37f996c 100644 --- a/tests/NetDiagram.Tests.ps1 +++ b/tests/NetDiagram.Tests.ps1 @@ -1761,6 +1761,89 @@ Describe 'Export-DrawIO subnet container parenting' { } } +Describe 'P0 research fixes: IPv6 carry + SNMPv3 + loader + ARP + DNS task' { + It 'Get-IPv6CidrScanTarget carries overflow across bytes (ff + 1 = 100)' { + InModuleScope 'NetDiagram-PS' { + $r = Get-IPv6CidrScanTarget -NetworkAddress '2001:db8::ff' -PrefixLength 120 -ScanDepth Full + $r | Should -Contain '2001:db8::100' + $r | Should -Contain '2001:db8::1fe' + $r.Count | Should -Be 256 + $r2 = Get-IPv6CidrScanTarget -NetworkAddress '2001:db8::fe' -PrefixLength 120 -ScanDepth Full + $r2[2] | Should -Be '2001:db8::100' + $r2[0] | Should -Be '2001:db8::fe' + } + } + + It 'Invoke-SnmpWalk uses -u for v3 and -c for v2c (no -c leak for v3)' { + Mock Get-Command { [pscustomobject]@{ Source = '/usr/bin/snmpwalk' } } -ModuleName 'NetDiagram-PS' -ParameterFilter { $Name -like 'snmpwalk*' } + # v3 should not fail parameter validation; it should attempt the walk (which returns @() without throwing when binary is mocked) + { Invoke-SnmpWalk -TargetIP '2001:db8::1' -Community 'myv3user' -Version v3 -WarningAction SilentlyContinue } | Should -Not -Throw + { Invoke-SnmpWalk -TargetIP '10.0.0.1' -Community 'public' -Version v2c -WarningAction SilentlyContinue } | Should -Not -Throw + # Invalid version should still throw + { Invoke-SnmpWalk -TargetIP '10.0.0.1' -Community 'public' -Version 'v9' } | Should -Throw + } + + It 'ConvertFrom-ArpText skips bad macOS/LinuxArp lines with warning, still throws for LinuxIp malformed' { + InModuleScope 'NetDiagram-PS' { + $warn = @() + $r = @(ConvertFrom-ArpText -Lines @('? (192.168.1.99) at aa:bb:cc:dd:ee:ff on en0 ifscope [ethernet]', 'garbage no at') -Format MacOS -WarningVariable warn -WarningAction SilentlyContinue) + $r.Count | Should -Be 1 + $r[0].IPAddress | Should -Be '192.168.1.99' + { ConvertFrom-ArpText -Lines @('192.168.1.1 dev eth0 lladdr malformed REACHABLE') -Format LinuxIp } | Should -Throw '*Unable to parse Linux ip-neigh*' + $w2 = @() + $r2 = @(ConvertFrom-ArpText -Lines @('bad arp (xx) at yy on zz') -Format LinuxArp -WarningVariable w2 -WarningAction SilentlyContinue) + $r2.Count | Should -Be 0 + $w2.Count | Should -BeGreaterThan 0 + } + } + + It 'Wait-DnsLookupTask observes faulted task and returns failure without throwing' { + InModuleScope 'NetDiagram-PS' { + $src = [System.Threading.Tasks.TaskCompletionSource[System.Net.IPHostEntry]]::new() + $src.SetException([InvalidOperationException]::new('fixture fault')) + $result = Wait-DnsLookupTask -LookupTask $src.Task -IPAddress '192.0.2.99' -TimeoutSeconds 1 + $result.Success | Should -Be $false + $result.IPAddress | Should -Be '192.0.2.99' + $src2 = [System.Threading.Tasks.TaskCompletionSource[System.Net.IPHostEntry]]::new() + $result2 = Wait-DnsLookupTask -LookupTask $src2.Task -IPAddress '192.0.2.98' -TimeoutSeconds 1 + $result2.Success | Should -Be $false + $result2.IPAddress | Should -Be '192.0.2.98' + } + } +} + +Describe 'Lint hygiene: verbose catch and Information stream (1.4.1)' { + It 'Get-SnmpNeighbors emits SNMP summary to Information stream, not Host' { + $credPath = Join-Path $script:TestDataPath 'credmap-info.json' + '{}' | Out-File -FilePath $credPath -Force + $topology = Import-Inventory -Path $script:TestInventoryPath + Mock Invoke-SnmpWalk { @('1 = IpAddress: 192.168.1.10') } -ModuleName 'NetDiagram-PS' + + $info = @() + $null = $topology | Get-SnmpNeighbors -CredentialMapPath $credPath -TryPublic -WarningAction SilentlyContinue -InformationVariable info -InformationAction Continue 2>&1 + + ($info -join ' ') | Should -Match 'SNMP summary: queried' + $info | Should -Not -BeNullOrEmpty + } + + It 'Invoke-PortScan does not throw when scanning a closed port and logs verbosely' { + # 192.0.2.1 is TEST-NET-1, guaranteed unroutable; port should be closed/filtered + { Invoke-PortScan -IPAddress '192.0.2.2' -Ports @(65534) -TimeoutMs 200 -Verbose 4>&1 | Out-Null } | Should -Not -Throw + $result = Invoke-PortScan -IPAddress '192.0.2.2' -Ports @(65534) -TimeoutMs 200 + $result.OpenPorts | Should -HaveCount 0 + $result.IPAddress | Should -Be '192.0.2.2' + } + + It 'Test-DeviceReachability TCP fallback failure is logged verbosely, not silently swallowed' { + $topology = [pscustomobject]@{ + Nodes = @([pscustomobject]@{ IP='192.0.2.3'; Hostname='x'; Role='server'; Layer='Servers'; Vendor='X'; OS='X'; Reachable=$null }) + Edges=@(); Subnets=@() + } + # ProbeScript forces TCP path by returning $false for ICMP, then TCP fallback to unreachable port + { $topology | Test-DeviceReachability -TcpFallbackPort 65534 -TimeoutSeconds 1 -Verbose 4>&1 | Out-Null } | Should -Not -Throw + } +} + Describe 'Export-DrawIO dynamic container layout (#19 regression)' { It 'Contains every node for a -node subnet' -TestCases @( @{ Count = 0 }