Ctrl K
ComunidadGuías…

packaging/installer/netdata-support-bundle.ps1

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# SPDX-License-Identifier: GPL-3.0-or-later
#
# netdata-support-bundle - collect a diagnostic bundle for Netdata support tickets.
# Windows counterpart of netdata-support-bundle. Same bundle layout, same MANIFEST
# schema (netdata-support-bundle/v2), same sanitization rules.
#
# Standard captures redact secrets except the streaming API key in stream.conf
# (netdata/netdata#23448). -IncludeSnmpDiagnostics adds raw SNMP files with neither
# secret redaction nor PII obfuscation. Standard collected files
# keep their original bytes - BOM, line terminators and a missing final newline
# all survive redaction - and what was observed is recorded per file in
# MANIFEST.json, so encoding faults stay diagnosable.
#
# What is collected and WHY each item is included is documented in
# packaging/installer/SUPPORT-BUNDLE.md - read it before adding or changing
# a collection item, and keep it in sync with this script.
#
# Usage (run in an elevated PowerShell):
#   powershell -ExecutionPolicy Bypass -File netdata-support-bundle.ps1 [-Output DIR]
#              [-SinceHours 24] [-NoObfuscate] [-IncludeSnmpDiagnostics] [-KeepStaging] [-SelfTest]
#
# Requires Windows PowerShell 5.1+ (ships with Windows Server 2016+) or
# PowerShell 7. Output: netdata-support-bundle-<timestamp>.zip

[CmdletBinding()]
param(
    [string]$Output = $(if ($env:TEMP) { $env:TEMP } elseif ($env:TMPDIR) { $env:TMPDIR } else { [System.IO.Path]::GetTempPath() }),
    [int]$SinceHours = 24,
    [int]$TimeoutSeconds = 10,
    [switch]$NoObfuscate,
    [switch]$IncludeSnmpDiagnostics,
    [switch]$KeepStaging,
    [switch]$SelfTest,
    [switch]$Version
)

Set-StrictMode -Version 2.0
$ErrorActionPreference = 'SilentlyContinue'

$ToolVersion = '1.3.0'
$script:SnmpFiles = 0
$script:SnmpStatus = 'not_requested'
if ($Version) { Write-Output "netdata-support-bundle $ToolVersion"; exit 0 }

$Obfuscate = -not $NoObfuscate
$LogCap = 5MB
$FileCap = 1MB
$GlobalDeadline = (Get-Date).AddSeconds(240)

# --- run at lowest priority: never compete with real workloads ---------------
try {
    $proc = Get-Process -Id $PID
    $proc.PriorityClass = [System.Diagnostics.ProcessPriorityClass]::Idle
} catch { Write-Verbose "could not lower process priority: $_" }

$StartTime = Get-Date
$Stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss')
$BundleName = "netdata-support-bundle-$Stamp-$PID"
$TempBase = if ($env:TEMP) { $env:TEMP } elseif ($env:TMPDIR) { $env:TMPDIR } else { [System.IO.Path]::GetTempPath() }
$Staging = Join-Path $TempBase ("netdata-support-bundle-staging-" + [System.IO.Path]::GetRandomFileName())
$Work = Join-Path $Staging $BundleName
New-Item -ItemType Directory -Path $Work -Force | Out-Null
$script:ManifestRows = New-Object System.Collections.ArrayList
$script:PseudoMap = @{}   # original -> pseudonym
$script:IpCount = 0
$script:Ip6Count = 0
$script:FqdnCount = 0
$script:DomainCount = 0
$script:SeededHosts = @() # child/mirrored hostnames pre-seeded from the local API
# byte-exact encoding preservation reads the whole file into one string; the
# Windows perflib dump can be tens of MB, where fidelity is irrelevant (we
# generated it) and the memory cost is not. Above this, use the line path.
$script:ByteExactMax = 8MB

function Show-Info([string]$msg) { Write-Host " [*] $msg" }

# PS 5.1 Set-Content writes UTF-16LE by default; every bundle file must be UTF-8
$script:Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# ISO-8859-1 maps bytes 0x00-0xFF one-to-one onto U+0000-U+00FF, so decoding and
# re-encoding with it reproduces the ORIGINAL bytes exactly. The byte-exact
# sanitize path uses it instead of UTF-8, whose replacement fallback would turn
# every invalid sequence in a Latin-1 config into U+FFFD and corrupt the copy.
# Every sanitizer pattern is ASCII, which this mapping leaves unchanged.
$script:BytePreserving = [System.Text.Encoding]::GetEncoding(28591)
function Write-Utf8([string]$path, [string]$text) {
    [System.IO.File]::WriteAllText($path, $text, $script:Utf8NoBom)
}

function Test-Deadline { return ((Get-Date) -gt $GlobalDeadline) }

# Invoke-WebRequest wrapper: -UseBasicParsing is needed on Windows PowerShell 5.1
# (avoids the IE-engine dependency) but is a deprecated no-op on PowerShell 7+;
# gate it on the major version so the same call works everywhere.
function Invoke-LocalApi([string]$url, [int]$timeoutSec) {
    $iwrArgs = @{ Uri = $url; TimeoutSec = $timeoutSec }
    if ($PSVersionTable.PSVersion.Major -lt 6) { $iwrArgs["UseBasicParsing"] = $true }
    return Invoke-WebRequest @iwrArgs
}

# --- sanitizer ----------------------------------------------------------------
# pass 1 (always): credential-bearing key values, URL/DSN creds, JWT,
#                  Bearer/Basic values, private keys, UUID section headers
# pass 2 (default): emails, MACs, IPv4 pseudonyms, this host's names,
#                   private-TLD FQDNs
$SecretKeyWords = @(
    # substring-matched, so only unambiguous secret tokens belong here:
    # e.g. 'pat' is excluded because it matches 'path' (kept in sync with the .sh list)
    'api key','apikey','token','password','passwd','pwd','secret','community',
    'bearer','webhook','license key','auth','credential','cookie','passphrase',
    'proxy user','proxy pass','username','dsn','private key','access key',
    'session','recipient','account sid','priv key'
)
$HostShort = $env:COMPUTERNAME
$HostFqdn = try { [System.Net.Dns]::GetHostEntry('').HostName } catch { Write-Verbose "no FQDN: $_"; '' }
$RunUser = $env:USERNAME
if ($RunUser -in @('SYSTEM', 'Administrator') -or -not $RunUser -or $RunUser.Length -lt 3) { $RunUser = '' }
# An Active Directory domain name is a corporate identifier, and the new
# 09-permissions ACL captures print DOMAIN\user everywhere. Pseudonymize the
# machine's OWN domain by exact value - the same approach used for the hostname
# and the invoking user - rather than by pattern: a generic DOMAIN\user regex
# cannot be told apart from a Windows path segment (C:\Users\Public).
# POSIX has no counterpart: getfacl there yields local accounts only.
$UserDomain = $env:USERDOMAIN
if (-not $UserDomain -or $UserDomain -eq $HostShort -or $UserDomain.Length -lt 3) { $UserDomain = '' }
$DnsDomain = ''
try {
    $cs = Get-CimInstance Win32_ComputerSystem -ErrorAction SilentlyContinue
    if ($cs -and $cs.PartOfDomain -and $cs.Domain) { $DnsDomain = [string]$cs.Domain }
} catch { Write-Verbose "could not read the computer domain: $_" }
if ($DnsDomain -in @('WORKGROUP', $HostShort, $HostFqdn) -or $DnsDomain.Length -lt 4) { $DnsDomain = '' }
# set per file by Invoke-SanitizeLine; "stream" marks stream.conf, the one file
# whose streaming API key is kept verbatim (netdata/netdata#23448)
$script:SanCtx = ''

function Test-SecretKey([string]$key) {
    $k = ($key -replace '[-_]', ' ').ToLower().Trim(' ', '#', "`t")
    foreach ($w in $SecretKeyWords) { if ($k.Contains($w)) { return $true } }
    return $false
}

function Test-DiagnosticKey([string]$key) {
    # keys that DESCRIBE secrets rather than hold them ("bearer token protection",
    # "netdata management api key file") end in a diagnostic noun; their values are
    # settings, not credentials. Exemption is KEY-based on purpose: a value like
    # "false" or "/root/x" attached to a real secret key must still be redacted.
    $k = ($key -replace '[-_]', ' ').ToLower().Trim(' ', '#', "`t")
    foreach ($w in @('file', 'path', 'dir', 'directory', 'protection', 'support', 'mode',
                     'level', 'port', 'timeout', 'cookies', 'secure', 'log', 'size', 'options')) {
        if ($k -eq $w -or $k.EndsWith(" $w")) { return $true }
    }
    return $false
}

function Test-KeyExempt([string]$key) {
    # the two reasons a key that LOOKS secret is left alone: it names or
    # describes a secret rather than holding one, or it is the streaming api key
    return ((Test-DiagnosticKey $key) -or (Test-StreamingApiKey $key))
}

function Test-StreamingApiKey([string]$key) {
    # netdata/netdata#23448: the STREAMING api key is not treated as a secret -
    # support needs its value to tell whether a child and its parent agree.
    # Scoped to stream.conf ($script:SanCtx) and to an EXACT key match, so a
    # third-party "api key" in any other file, and any other secret key inside
    # stream.conf, is still redacted. Deliberately NOT applied to access logs,
    # where the same value also appears (stream-receiver-connection.c logs it,
    # and children send it as a "key=" query parameter).
    if ($script:SanCtx -ne 'stream') { return $false }
    $k = ($key -replace '[-_]', ' ').ToLower().Trim(' ', '#', "`t")
    return ($k -eq 'api key' -or $k -eq 'proxy api key')
}

function Get-Pseudonym([string]$orig, [string]$prefix) {
    if (-not $script:PseudoMap.ContainsKey($orig)) {
        # cap: past 4096 mappings, use a constant non-correlating placeholder
        if ($script:PseudoMap.Count -ge 4096) { return "redacted-$prefix" }
        if ($prefix -eq 'ip') { $script:IpCount++; $script:PseudoMap[$orig] = "ip-$($script:IpCount)" }
        elseif ($prefix -eq 'ip6') { $script:Ip6Count++; $script:PseudoMap[$orig] = "ip6-$($script:Ip6Count)" }
        elseif ($prefix -eq 'fqdn') { $script:FqdnCount++; $script:PseudoMap[$orig] = "private-host-$($script:FqdnCount)" }
        else { $script:PseudoMap[$orig] = 'redacted-host' }
    }
    return $script:PseudoMap[$orig]
}

function Invoke-RedactSecretLine([string]$line) {
    # pass 1 (always on): credential redaction
    # [<uuid>] section headers are api keys (or machine GUIDs). In stream.conf
    # they are KEPT: support needs to see which key each section configures, and
    # collapsing them all to one placeholder also made per-key settings
    # unattributable. Everywhere else they are still withheld.
    if ($script:SanCtx -ne 'stream' -and
        $line -match '^\s*\[[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\]\s*$') {
        return '[REDACTED-KEY-SECTION]'
    }
    # ini/yaml/env key = value | key: value
    # (JSON-shaped lines are owned by the json rule below, which preserves quoting)
    # only plausible config keys: short, no sentence/shell punctuation, no path
    # separators (command lines with paths belong to the argv rule below)
    if ($line -notmatch '^\s*"' -and $line -match '^([^=:]{1,64})([=:])(.+)$' -and $Matches[1] -notmatch '["`;|()/]') {
        if ((Test-SecretKey $Matches[1]) -and $Matches[3].Trim() -ne '' -and
            -not (Test-DiagnosticKey $Matches[1]) -and -not (Test-StreamingApiKey $Matches[1])) {
            $line = $Matches[1] + $Matches[2] + ' [REDACTED]'
        }
    }
    # json "key": "value" pairs (possibly several per line)
    $line = [regex]::Replace($line, '"([^"]+)"\s*:\s*"(?:[^"\\]|\\.)*"', {
        param($m)
        if ((Test-SecretKey $m.Groups[1].Value) -and -not (Test-DiagnosticKey $m.Groups[1].Value)) { '"' + $m.Groups[1].Value + '": "[REDACTED]"' }
        else { $m.Value }
    })
    # scalar (unquoted) JSON values under secret keys: "key": 12345 / true / null
    $line = [regex]::Replace($line, '"([^"]+)"\s*:\s*(-?\d[\d.eE+-]*|true|false|null)', {
        param($m)
        if ((Test-SecretKey $m.Groups[1].Value) -and -not (Test-DiagnosticKey $m.Groups[1].Value)) { '"' + $m.Groups[1].Value + '": "[REDACTED]"' }
        else { $m.Value }
    })
    # NOTE (scope): nested JSON arrays/objects under secret keys are not
    # deep-parsed - see the matching note in netdata-support-bundle and the
    # "Redaction philosophy" section of SUPPORT-BUNDLE.md. Collected agent JSON
    # keeps credentials in string/scalar fields (handled above), not structures.
    # URL creds and Go-style DSN creds
    $line = $line -replace '://[^:/@\s]+:[^@\s]+@', '://[REDACTED]@'
    $line = $line -replace '\b[\w]+:[^@\s]+@(tcp|unix)\(', '[REDACTED]@$1('
    # JWTs, HTTP auth header values
    $line = $line -replace 'eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+', '[REDACTED-JWT]'
    # Bearer value must contain a digit: real tokens do, prose after "bearer" does not
    $line = $line -replace '[Bb]earer\s+[A-Za-z._~+/=-]*\d[A-Za-z0-9._~+/=-]*', 'Bearer [REDACTED]'
    $line = $line -replace '[Bb]asic\s+[A-Za-z0-9+/=]{8,}', 'Basic [REDACTED]'
    # secrets passed as URL query parameters (access-log request lines etc.)
    $line = $line -replace '([?&][\w.-]*(token|apikey|api_key|access_key|private_key|secret_key|password|passwd|secret|bearer|claim_token|claim_rooms|key|auth)=)[^&"\s]+', '$1[REDACTED]'
    # argv/env-style secrets mid-line (process command lines: -token=X, CLAIM_TOKEN=X),
    # incl. two-word keys ("api key = X"); diagnostic-noun keys are kept
    $line = [regex]::Replace($line, '(?i)(([\w.-]*(token|password|passwd|secret|apikey|api_key|community|bearer)|(api|license|auth|access) key|proxy (user|pass|password)) ?[=:] ?)([^&"\s\[]+)', {
        param($m)
        if ((Test-DiagnosticKey $m.Groups[2].Value) -or (Test-StreamingApiKey $m.Groups[2].Value)) { $m.Value }
        else { $m.Groups[1].Value + '[REDACTED]' }
    })
    # NOTE: multiline PEM private-key blocks are handled by Invoke-SanitizeFile,
    # which tracks BEGIN/END state across lines and withholds the whole block.
    return $line
}

function Invoke-RedactDestinationLine([string]$line) {
    # stream.conf destination hosts are user infrastructure regardless of TLD.
    # Token syntax: [PROTOCOL:]HOST[%IFACE][:PORT][:SSL]. Runs BEFORE the IP
    # rules (pure-IP tokens are skipped and left to them).
    $eq = $line.IndexOf('=')
    $head = $line.Substring(0, $eq + 1)
    $rebuilt = foreach ($tok in ($line.Substring($eq + 1) -split '\s+')) {
        if (-not $tok) { continue }
        $work = $tok; $prefix = ''; $suffix = ''
        if ($work -match '^(tcp:|udp:|unix:)(.*)$') { $prefix = $Matches[1]; $work = $Matches[2] }
        if (-not $work.StartsWith('[') -and -not $work.StartsWith('/')) {
            $cut = $work.IndexOfAny([char[]]('%', ':'))
            if ($cut -ge 0) { $suffix = $work.Substring($cut); $work = $work.Substring(0, $cut) }
            if ($work.Length -ge 4 -and
                $work -notmatch '^(ip6?-\d+|private-host-\d+)$' -and
                $work -notmatch '^\d{1,3}(\.\d{1,3}){3}$' -and
                $work -notmatch '^[0-9A-Fa-f:]+$') {
                $work = Get-Pseudonym $work 'fqdn'
            }
        }
        $prefix + $work + $suffix
    }
    return $head + ' ' + ($rebuilt -join ' ')
}

function Invoke-ReplaceAddressText([string]$line) {
    # emails, MACs, IPv4, IPv6
    $line = $line -replace '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}', '[EMAIL]'
    $line = $line -replace '\b([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b', '[MAC]'
    # IPv4 (keep loopback/wildcard/broadcast)
    $line = [regex]::Replace($line, '\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', {
        param($m)
        $ip = $m.Value
        if ($ip -like '127.*' -or $ip -eq '0.0.0.0' -or $ip -like '255.*') { return $ip }
        return (Get-Pseudonym $ip 'ip')
    })
    # IPv6: hex-and-colon runs, validated to skip timestamps/:: tokens/loopback.
    # 6+ colons is always accepted: numeric-only uncompressed IPv6 has 7,
    # timestamps never have more than 2.
    $line = [regex]::Replace($line, '(?<![\w.-])[0-9A-Fa-f:]{5,}', {
        param($m)
        $c = $m.Value
        $nc = [regex]::Matches($c, ':').Count
        if ($nc -eq 0 -or $c -eq '::1' -or
            ($nc -lt 3 -and $c -notmatch '::') -or
            $c.EndsWith(':') -or
            ($nc -lt 6 -and $c -notmatch '[A-Fa-f]' -and $c -notmatch '::')) { return $c }
        return (Get-Pseudonym $c 'ip6')
    })
    return $line
}

function Invoke-ObfuscatePiiLine([string]$line) {
    # pass 2 (default on): PII pseudonymization
    if ($line -match '^[\s#]*(proxy )?destination\s*=') { $line = Invoke-RedactDestinationLine $line }
    $line = Invoke-ReplaceAddressText $line
    # clearly-private FQDNs
    $line = [regex]::Replace($line, '\b[A-Za-z0-9][A-Za-z0-9.-]*\.(internal|local|lan|corp|intranet|localdomain)\b', {
        param($m); return (Get-Pseudonym $m.Value 'fqdn')
    })
    # child/mirrored hostnames pre-seeded from the local API (longest first,
    # word-boundary guarded so "host1" never corrupts "host10")
    foreach ($k in $script:SeededHosts) {
        if ($line.IndexOf($k, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) {
            $line = [regex]::Replace($line, "(?<![\w.-])$([regex]::Escape($k))(?![\w.-])",
                $script:PseudoMap[$k], [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
        }
    }
    # this host's names
    if ($HostFqdn -and $HostFqdn.Length -ge 4) { $line = $line.Replace($HostFqdn, (Get-Pseudonym $HostFqdn 'host')) }
    if ($HostShort -and $HostShort.Length -ge 4 -and $HostShort -ne $HostFqdn) {
        $line = $line -ireplace [regex]::Escape($HostShort), (Get-Pseudonym $HostShort 'host')
    }
    if ($RunUser) {
        if (-not $script:PseudoMap.ContainsKey($RunUser)) { $script:PseudoMap[$RunUser] = 'redacted-user' }
        $line = $line -ireplace [regex]::Escape($RunUser), 'redacted-user'
    }
    # the machine's own AD domain (DOMAIN\user in ACL output, and the DNS domain)
    if ($DnsDomain) {
        if (-not $script:PseudoMap.ContainsKey($DnsDomain)) { $script:PseudoMap[$DnsDomain] = 'redacted-domain' }
        $line = $line -ireplace [regex]::Escape($DnsDomain), 'redacted-domain'
    }
    if ($UserDomain) {
        if (-not $script:PseudoMap.ContainsKey($UserDomain)) { $script:PseudoMap[$UserDomain] = 'redacted-domain' }
        $line = $line -ireplace [regex]::Escape($UserDomain), 'redacted-domain'
    }
    return $line
}

function Invoke-SanitizeLine([string]$line, [bool]$secretScan = $true, [string]$ctx = '') {
    $script:SanCtx = $ctx
    # $secretScan can be disabled for content that structurally cannot hold
    # credentials (the Windows perflib dump: counter/instance metadata only), so
    # the expensive per-"key":"value" credential regexes are not run millions of
    # times over a ~60 MB blob. PII obfuscation (host/IP names) is always applied.
    if ($secretScan) { $line = Invoke-RedactSecretLine $line }
    if ($Obfuscate) { $line = Invoke-ObfuscatePiiLine $line }
    return $line
}

function Get-FileEncodingFacts([string]$path) {
    # What the sanitizer must preserve: where the BOM ends, whether the body is
    # valid UTF-8 (which decides the round-trip encoding), and whether it holds
    # a NUL. The old ReadAllLines/WriteAllLines pair normalized the BOM and the
    # line endings away silently - netdata/netdata#23448.
    $len = (Get-Item $path -Force).Length
    if ($len -gt $script:ByteExactMax) {
        # a large GENERATED capture (the perflib dump): scanning tens of MB in a
        # scalar loop would eat the global deadline, so probe a prefix for NUL
        # only and let the caller take the line-based path
        $probeLen = [int][Math]::Min($len, 1MB)
        $probe = New-Object byte[] $probeLen
        $fs = [System.IO.File]::OpenRead($path)
        try { [void]$fs.Read($probe, 0, $probeLen) } finally { $fs.Close() }
        return [pscustomobject]@{
            size = $len; bomLen = 0; utf8Valid = $true
            nul = ([Array]::IndexOf($probe, [byte]0) -ge 0)
        }
    }
    $bytes = [System.IO.File]::ReadAllBytes($path)
    $bomLen = 0
    if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $bomLen = 3 }
    elseif ($bytes.Length -ge 4 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE -and $bytes[2] -eq 0 -and $bytes[3] -eq 0) { $bomLen = 4 }
    elseif ($bytes.Length -ge 4 -and $bytes[0] -eq 0 -and $bytes[1] -eq 0 -and $bytes[2] -eq 0xFE -and $bytes[3] -eq 0xFF) { $bomLen = 4 }
    elseif ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) { $bomLen = 2 }
    elseif ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) { $bomLen = 2 }
    # a strict decoder is the cheapest UTF-8 validator available here
    $utf8Valid = $true
    try {
        $strict = New-Object System.Text.UTF8Encoding($false, $true)
        [void]$strict.GetString($bytes, $bomLen, $bytes.Length - $bomLen)
    } catch { $utf8Valid = $false }
    return [pscustomobject]@{
        size = $bytes.Length; bomLen = $bomLen; utf8Valid = $utf8Valid
        nul = ([Array]::IndexOf($bytes, [byte]0) -ge 0)
    }
}

function Convert-SanitizedText([string[]]$lines, [bool]$secretScan = $true, [string]$ctx = '') {
    # per-line redaction, plus whole-block withholding for PEM private keys
    # (clear BEGIN/END markers). Multi-line YAML block scalars are NOT specially
    # withheld - their indentation-based boundary can't be detected robustly by
    # a line tool, and inline "secret: value" is the common form (redacted by
    # the key/value rules). See SUPPORT-BUNDLE.md "Redaction philosophy".
    $out = New-Object System.Collections.ArrayList
    $inPem = $false
    foreach ($l in $lines) {
        # PEM private key: withhold BEGIN..END; fail closed if END never comes
        if ($inPem) {
            if ($l -match '-----END [A-Z0-9 ]*PRIVATE KEY') { $inPem = $false }
            continue
        }
        if ($l -match '-----BEGIN [A-Z0-9 ]*PRIVATE KEY') {
            $inPem = $true
            [void]$out.Add('[REDACTED PRIVATE KEY BLOCK]')
            continue
        }
        [void]$out.Add((Invoke-SanitizeLine $l $secretScan $ctx))
    }
    return $out
}

function Invoke-SanitizeFile([string]$path, [bool]$secretScan = $true, [string]$ctx = '') {
    if (-not (Test-Path $path)) { return }
    $facts = Get-FileEncodingFacts $path
    if ($facts.nul) {
        # a NUL means binary/UTF-16: line-based redaction would be byte-unsafe
        Write-Utf8 $path '[content withheld: file contains NUL bytes (binary or UTF-16?)]'
        return
    }
    if ($facts.size -gt $script:ByteExactMax) {
        # large GENERATED capture (the perflib dump): there is no source encoding
        # to preserve, and holding tens of MB twice is not worth it
        $out = Convert-SanitizedText ([System.IO.File]::ReadAllLines($path)) $secretScan $ctx
        [System.IO.File]::WriteAllLines($path, $out, $script:Utf8NoBom)
        return
    }
    # byte-exact path: keep the BOM bytes, keep every line's OWN terminator, and
    # never invent a final newline the source did not have
    $bytes = [System.IO.File]::ReadAllBytes($path)
    # Valid UTF-8 is decoded AS UTF-8: it re-encodes to the same bytes, and the
    # PII rules compare against real .NET strings, so a non-ASCII hostname or
    # username must not be reduced to Latin-1 mojibake - the replacement would
    # miss it and the real value would ship. ISO-8859-1 is only the fallback for
    # sources that are not valid UTF-8, where it is the byte-preserving choice.
    $enc = if ($facts.utf8Valid) { $script:Utf8NoBom } else { $script:BytePreserving }
    $text = $enc.GetString($bytes, $facts.bomLen, $bytes.Length - $facts.bomLen)
    $sb = New-Object System.Text.StringBuilder
    $inPem = $false
    $pos = 0
    $breaks = [char[]]@("`r", "`n")
    while ($pos -lt $text.Length) {
        $nl = $text.IndexOfAny($breaks, $pos)
        if ($nl -lt 0) {
            $lineText = $text.Substring($pos); $term = ''; $pos = $text.Length
        } else {
            $lineText = $text.Substring($pos, $nl - $pos)
            if ($text[$nl] -eq "`r" -and ($nl + 1) -lt $text.Length -and $text[$nl + 1] -eq "`n") {
                $term = "`r`n"; $pos = $nl + 2
            } else {
                $term = [string]$text[$nl]; $pos = $nl + 1
            }
        }
        # PEM private key: withhold BEGIN..END; fail closed if END never comes
        if ($inPem) {
            if ($lineText -match '-----END [A-Z0-9 ]*PRIVATE KEY') { $inPem = $false }
            continue
        }
        if ($lineText -match '-----BEGIN [A-Z0-9 ]*PRIVATE KEY') {
            $inPem = $true
            [void]$sb.Append('[REDACTED PRIVATE KEY BLOCK]').Append($term)
            continue
        }
        [void]$sb.Append((Invoke-SanitizeLine $lineText $secretScan $ctx)).Append($term)
    }
    $body = $enc.GetBytes($sb.ToString())
    if ($facts.bomLen -gt 0) {
        $outBytes = New-Object byte[] ($facts.bomLen + $body.Length)
        [System.Array]::Copy($bytes, 0, $outBytes, 0, $facts.bomLen)
        [System.Array]::Copy($body, 0, $outBytes, $facts.bomLen, $body.Length)
    } else {
        $outBytes = $body
    }
    [System.IO.File]::WriteAllBytes($path, $outBytes)
}

# --- sanitizer self-test (-SelfTest) ----------------------------------------------
# Table-driven regression suite for the redaction rules. Run it after ANY change
# to the sanitizer, and keep it in parity with the POSIX script's --selftest.
if ($SelfTest) {
    $HostShort = 'testhost99'
    $HostFqdn = 'testhost99.example.com'
    $RunUser = 'testuser9'
    $UserDomain = 'TESTDOM'
    $DnsDomain = 'testdom.example.com'
    $vectors = @(
        @{ in = 'api key = SENTINEL-1';                                            mustNot = @('SENTINEL');            must = @('[REDACTED]') }
        # assembled at runtime so secret scanners do not flag the source
        @{ in = ('    Authorization: ' + ('Bea' + 'rer') + ' SENTINEL-2abc123');    mustNot = @('SENTINEL');            must = @('[REDACTED]') }
        @{ in = 'password: SENTINEL-3';                                            mustNot = @('SENTINEL');            must = @('[REDACTED]') }
        @{ in = '"claim_token": "SENTINEL-4"';                                     mustNot = @('SENTINEL');            must = @('[REDACTED]') }
        @{ in = '"api_token": -98765';                                            mustNot = @('98765');               must = @('[REDACTED]') }
        @{ in = 'url: https://admin:SENTINEL-5@app.example.com/x';                 mustNot = @('SENTINEL');            must = @('[REDACTED]@') }
        @{ in = 'dsn: user:SENTINEL-6@tcp(10.1.2.3:3306)/db';                      mustNot = @('SENTINEL');            must = @('[REDACTED]') }
        @{ in = 'TELEGRAM_BOT_TOKEN="SENTINEL-7"';                                 mustNot = @('SENTINEL');            must = @('[REDACTED]') }
        @{ in = 'DEFAULT_RECIPIENT_SLACK="SENTINEL-8"';                            mustNot = @('SENTINEL');            must = @('[REDACTED]') }
        @{ in = '[11111111-2222-3333-4444-555555555555]';                          mustNot = @('11111111');            must = @('[REDACTED-KEY-SECTION]') }
        @{ in = 'destination = parent.example.internal:19999';                     mustNot = @('parent.example');      must = @('private-host') }
        @{ in = 'server at 10.1.2.3 talked to 192.168.5.7 then 10.1.2.3 again';    mustNot = @('10.1.2.3', '192.168'); must = @('ip-') }
        @{ in = 'admin email is ops@customer-corp.com on host testhost99';         mustNot = @('customer-corp', 'testhost99'); must = @('[EMAIL]', 'redacted-host') }
        @{ in = 'GET /api/v1/data?chart=x&token=SENTINEL-9&after=-60';             mustNot = @('SENTINEL');            must = @('[REDACTED]', 'after=-60') }
        @{ in = 'netdata 1234 /usr/sbin/netdata-claim.sh -token=SENTINEL-10 -rooms=abc'; mustNot = @('SENTINEL');      must = @('[REDACTED]', '-rooms=abc') }
        @{ in = 'Environment: NETDATA_CLAIM_TOKEN=SENTINEL-11 PATH=/usr/bin';      mustNot = @('SENTINEL');            must = @('[REDACTED]') }
        @{ in = 'IPv6: 2606:4700:10::ac42:aad8 and fe80::1ff:fe23:4567:890a';      mustNot = @('2606:4700', 'fe80::1ff'); must = @('ip6-') }
        @{ in = 'peer 2001:470:26:307:0:0:0:1 connected';                          mustNot = @('2001:470');            must = @('ip6-') }
        @{ in = 'captured: 2026-07-16T13:38:34Z listening on ::1 and file.c:123';  mustNot = @();                      must = @('13:38:34Z', '::1', 'file.c:123') }
        @{ in = '# bearer token protection = no';                                  mustNot = @('[REDACTED]');          must = @('bearer token protection = no') }
        @{ in = '# TCP SYN cookies = auto';                                        mustNot = @('[REDACTED]');          must = @('auto') }
        @{ in = '# netdata management api key file = /var/lib/netdata/netdata.api.key'; mustNot = @('[REDACTED]');     must = @('/var/lib/netdata/netdata.api.key') }
        @{ in = 'cmdline: /x/claim.sh api key = SENTINEL-12 end';                  mustNot = @('SENTINEL');            must = @('[REDACTED]', 'end') }
        @{ in = 'TOKEN=false';                                                     mustNot = @('false');               must = @('[REDACTED]') }
        @{ in = 'PASSWORD=/root/x';                                                mustNot = @('/root/x');             must = @('[REDACTED]') }
        @{ in = 'destination = tcp:parent.example.com:19999';                      mustNot = @('parent.example.com');  must = @('tcp:', 'private-host', ':19999') }
        @{ in = 'tcp LISTEN 0 4096 *:19999';                                       mustNot = @('private-host');        must = @('tcp LISTEN') }
        @{ in = '# destination = old-parent.example.org:19999';                    mustNot = @('old-parent.example');  must = @('private-host', ':19999') }
        @{ in = 'connect user:SENTINEL-13@unix(/run/x)/db ok';                     mustNot = @('SENTINEL');            must = @('[REDACTED]@unix(/run/x)/db', 'ok') }
        @{ in = '/etc/netdata/claim_token: SENTINEL-14';                           mustNot = @('SENTINEL');            must = @('[REDACTED]') }
        @{ in = 'destination = [2001:db8::77]:19999 unix:/run/nd.sock';            mustNot = @('2001:db8::77');        must = @('ip6-', ']:19999', 'unix:/run/nd.sock') }
        @{ in = 'password: q';                                                     mustNot = @(': q');                 must = @('[REDACTED]') }
        @{ in = '"api_token": 731942';                                             mustNot = @('731942');              must = @('[REDACTED]') }
        # 09-permissions ACL output: the machine's own AD domain is a corporate
        # identifier, built-in authorities are not
        @{ in = 'BUILTIN\Administrators and TESTDOM\svc_netdata have Modify';       mustNot = @('TESTDOM');             must = @('redacted-domain', 'BUILTIN\Administrators') }
        # stream.conf context (netdata/netdata#23448): the STREAMING api key is
        # kept verbatim, while every other secret in the same file is redacted
        @{ ctx = 'stream'; in = '    api key = 11111111-2222-3333-4444-555555555555';       mustNot = @('REDACTED'); must = @('api key = 11111111-2222-3333-4444-555555555555') }
        @{ ctx = 'stream'; in = '    proxy api key = 99999999-8888-7777-6666-555555555555'; mustNot = @('REDACTED'); must = @('proxy api key = 99999999-8888-7777-6666-555555555555') }
        @{ ctx = 'stream'; in = '[aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee]';                   mustNot = @('REDACTED'); must = @('[aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee]') }
        @{ ctx = 'stream'; in = '    password = SENTINEL-STREAM-PW';                        mustNot = @('SENTINEL'); must = @('[REDACTED]') }
        # PII obfuscation is NOT disabled by the stream context
        @{ ctx = 'stream'; in = '    destination = parent.example.internal:19999';          mustNot = @('parent.example'); must = @('private-host') }
        # the SAME lines with NO context must still be redacted
        @{ in = '    api key = 11111111-2222-3333-4444-555555555555'; mustNot = @('11111111'); must = @('[REDACTED]') }
        @{ in = '[aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee]';             mustNot = @('aaaaaaaa'); must = @('[REDACTED-KEY-SECTION]') }
    )
    $fails = 0
    foreach ($v in $vectors) {
        $ctx = if ($v.ContainsKey('ctx')) { [string]$v.ctx } else { '' }
        $label = if ($ctx) { "[$ctx] " } else { '' }
        $result = Invoke-SanitizeLine $v.in $true $ctx
        $ok = $true
        foreach ($p in $v.mustNot) { if ($result.IndexOf($p, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { $ok = $false } }
        foreach ($p in $v.must) { if ($result.IndexOf($p, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { $ok = $false } }
        if ($ok) { Write-Output "ok:   $label$result" } else { Write-Output "FAIL: $label'$($v.in)' -> '$result'"; $fails++ }
    }

    # --- encoding fidelity (netdata/netdata#23448): a BOM, CRLF endings and a
    # --- missing final newline must all survive redaction, and a BOM must NOT
    # --- shift the ^-anchored rules (it used to defeat the section redaction)
    $encTmp = Join-Path $Staging 'enc-test.conf'
    $encText = "[11111111-2222-3333-4444-555555555555]`r`npassword = SENTINEL-ENC`r`nlast line without newline"
    [System.IO.File]::WriteAllBytes($encTmp,
        [byte[]](@(0xEF, 0xBB, 0xBF) + $script:Utf8NoBom.GetBytes($encText)))
    Invoke-SanitizeFile $encTmp
    $encOut = [System.IO.File]::ReadAllBytes($encTmp)
    $encStr = $script:Utf8NoBom.GetString($encOut)
    $encFails = @()
    if (-not ($encOut.Length -ge 3 -and $encOut[0] -eq 0xEF -and $encOut[1] -eq 0xBB -and $encOut[2] -eq 0xBF)) {
        $encFails += 'the UTF-8 BOM was stripped by sanitization'
    }
    if ($encStr.IndexOf('[REDACTED-KEY-SECTION]') -lt 0) { $encFails += 'a BOM defeated the [<UUID>] section redaction' }
    if ($encStr.IndexOf('11111111-2222') -ge 0) { $encFails += 'the BOM-prefixed api key section survived' }
    if ($encStr.IndexOf('SENTINEL-ENC') -ge 0) { $encFails += 'a secret survived in the encoding vector' }
    if (@([regex]::Matches($encStr, "`r`n")).Count -lt 2) { $encFails += 'CRLF line endings were lost (including on the redacted line)' }
    if ($encOut[$encOut.Length - 1] -eq 10) { $encFails += 'a final newline was added to a source that had none' }
    if ($encFails.Count -gt 0) { foreach ($e in $encFails) { Write-Output "FAIL: $e"; $fails++ } }
    else { Write-Output 'ok:   encoding preserved (BOM + CRLF + no trailing newline) and the BOM did not defeat redaction' }

    # multiline PEM block must be withheld end-to-end by the file-level sanitizer
    $pemTmp = Join-Path $Staging 'pem-test.txt'
    @('before line',
      '-----BEGIN RSA PRIVATE KEY-----',
      'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQ',
      'aGVsbG8gd29ybGQgdGhpcyBpcyBrZXkgbWF0ZXJpYWw=',
      '-----END RSA PRIVATE KEY-----',
      'after line') | Out-String | ForEach-Object { Write-Utf8 $pemTmp $_ }
    Invoke-SanitizeFile $pemTmp
    $pem = Get-Content $pemTmp -Raw
    if ($pem -match 'MIIEvQ' -or $pem -match 'aGVsbG8' -or $pem -notmatch '\[REDACTED PRIVATE KEY BLOCK\]' -or
        $pem -notmatch 'before line' -or $pem -notmatch 'after line') {
        Write-Output 'FAIL: PEM block not fully withheld'; $fails++
    } else { Write-Output 'ok:   PEM block withheld (begin/body/end gone, surrounding lines kept)' }
    Remove-Item $Staging -Recurse -Force
    if ($fails -gt 0) { Write-Output "SELF TEST: $fails FAILURES"; exit 1 }
    Write-Output 'SELF TEST: ALL PASS'
    exit 0
}

# --- manifest -------------------------------------------------------------------
function Add-Manifest([string]$rel, [string]$kind, [string]$origin, [string]$title, [bool]$raw = $false) {
    $full = Join-Path $Work $rel
    $bytes = 0
    if (Test-Path $full) { $bytes = (Get-Item $full).Length }
    $row = [ordered]@{
        path = $rel.Replace('\', '/'); kind = $kind; origin = $origin; title = $title
        bytes = [long]$bytes; pii_obfuscated = [bool]($Obfuscate -and -not $raw); sanitized = -not $raw
    }
    [void]$script:ManifestRows.Add($row)
}


# --- SNMP diagnostics ----------------------------------------------------------
function Add-SnmpNote([string]$message) {
    [System.IO.File]::AppendAllText((Join-Path $Work '06-state\snmp-diagnostics-status.txt'), "$message`n", $script:Utf8NoBom)
}

function Test-SnmpDirectory([string]$path) {
    try {
        $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop
        return ($item.PSIsContainer -and -not ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint))
    } catch { return $false }
}

function Save-SnmpFile([string]$src, [string]$rel) {
    $full = Join-Path $Work ("06-state\snmp-diagnostics\" + $rel)
    $inputStream = $null; $outputStream = $null
    try {
        if (Test-Deadline) { throw 'deadline reached' }
        $item = Get-Item -LiteralPath $src -Force -ErrorAction Stop
        if ($item.PSIsContainer -or ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
            throw 'source is not a regular file'
        }
        New-Item -ItemType Directory -Path (Split-Path $full) -Force -ErrorAction Stop | Out-Null
        # Delete sharing lets the Agent atomically replace a file while it is read.
        $sharing = [System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete
        $inputStream = [System.IO.File]::Open($src, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, $sharing)
        $outputStream = [System.IO.File]::Open("$full.tmp", [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
        $buffer = New-Object byte[] 65536
        $copyDeadline = (Get-Date).AddSeconds($TimeoutSeconds)
        while (($count = $inputStream.Read($buffer, 0, $buffer.Length)) -gt 0) {
            if ((Test-Deadline) -or (Get-Date) -gt $copyDeadline) { throw 'copy deadline reached' }
            $outputStream.Write($buffer, 0, $count)
        }
        $outputStream.Dispose(); $outputStream = $null
        $inputStream.Dispose(); $inputStream = $null
        [System.IO.File]::Move("$full.tmp", $full)
        $script:SnmpFiles++
        Add-Manifest ("06-state\snmp-diagnostics\" + $rel) 'file' ("snmp/diagnostics/" + $rel.Replace('\', '/')) 'Original SNMP evidence (UNSANITIZED)' $true
        Add-SnmpNote ($rel.Replace('\', '/') + ': copied')
        return $true
    } catch {
        # Error text can contain machine-specific paths. Keep raw-copy status safe.
        $script:SnmpStatus = 'partial'
        Add-SnmpNote ($rel.Replace('\', '/') + ': unavailable or copy failed (withheld)')
        return $false
    } finally {
        if ($outputStream) { $outputStream.Dispose() }
        if ($inputStream) { $inputStream.Dispose() }
        if (Test-Path -LiteralPath "$full.tmp") { Remove-Item -LiteralPath "$full.tmp" -Force }
    }
}

function Get-SnmpRuns([string]$path) {
    # Match the fixed Agent-written index, not arbitrary JSON/path expressions.
    $uuid = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
    $pattern = '\A\s*\{\s*"current"\s*:\s*"(?<current>' + $uuid + ')"(?:\s*,\s*"previous"\s*:\s*"(?<previous>' + $uuid + ')")?\s*\}\s*\z'
    $match = [regex]::Match([System.IO.File]::ReadAllText($path), $pattern)
    if (-not $match.Success) { throw 'invalid normal run index' }
    $current = $match.Groups['current'].Value
    $previous = $match.Groups['previous'].Value
    if ($current -eq $previous) { throw 'duplicate normal run identity' }
    $current
    if ($previous) { $previous }
}

function Save-SnmpDirectoryFiles([string]$root, [string]$relative, [string]$pattern) {
    $directory = Join-Path $root $relative
    if (-not (Test-SnmpDirectory $directory)) {
        $script:SnmpStatus = 'partial'
        Add-SnmpNote ($relative.Replace('\', '/') + ': directory unavailable or reparse point (withheld)')
        return
    }
    try {
        $files = @(Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop)
        foreach ($file in $files) {
            if ($file.Name -cmatch $pattern) {
                Save-SnmpFile $file.FullName (Join-Path $relative $file.Name) | Out-Null
            }
        }
    } catch {
        $script:SnmpStatus = 'partial'
        Add-SnmpNote ($relative.Replace('\', '/') + ': listing failed')
    }
}

function Collect-SnmpDiagnostics {
    New-Item -ItemType Directory -Path (Join-Path $Work '06-state') -Force | Out-Null
    Write-Utf8 (Join-Path $Work '06-state\snmp-diagnostics-status.txt') ''
    if (-not $IncludeSnmpDiagnostics) {
        Add-SnmpNote 'Not requested. Use -IncludeSnmpDiagnostics to include original, UNSANITIZED evidence.'
    } elseif (-not $LibDir -or -not (Test-Path -LiteralPath (Join-Path $LibDir 'snmp\diagnostics'))) {
        $script:SnmpStatus = 'unavailable'
        Add-SnmpNote 'No SNMP diagnostic directory found.'
    } else {
        $script:SnmpStatus = 'complete'
        $root = Join-Path $LibDir 'snmp\diagnostics'
        Add-SnmpNote 'Raw SNMP evidence requested: no secret redaction or PII obfuscation. Share privately.'
        if (-not (Test-SnmpDirectory (Join-Path $LibDir 'snmp')) -or -not (Test-SnmpDirectory $root)) {
            $script:SnmpStatus = 'partial'
            Add-SnmpNote 'Diagnostic directory unavailable or reparse point (withheld).'
        } else {
            $lifecycleMissing = $false
            if (Test-Path -LiteralPath (Join-Path $root 'lifecycle.zst')) {
                Save-SnmpFile (Join-Path $root 'lifecycle.zst') 'lifecycle.zst' | Out-Null
            } else {
                $lifecycleMissing = $true
                Add-SnmpNote 'lifecycle.zst: missing'
            }
            if (Test-Path -LiteralPath (Join-Path $root 'topology')) {
                Save-SnmpDirectoryFiles $root 'topology' '^checkpoint-[0-9]{20}\.zst$'
            }
            if (Test-Path -LiteralPath (Join-Path $root 'normal')) {
                if ((Test-SnmpDirectory (Join-Path $root 'normal')) -and
                    (Save-SnmpFile (Join-Path $root 'normal\runs.json') 'normal\runs.json')) {
                    try {
                        $runs = @(Get-SnmpRuns (Join-Path $Work '06-state\snmp-diagnostics\normal\runs.json'))
                        foreach ($run in $runs) {
                            Save-SnmpDirectoryFiles $root ("normal\" + $run) '^device-[0-9]{20}\.zst$'
                        }
                    } catch {
                        $script:SnmpStatus = 'partial'
                        Add-SnmpNote 'normal/runs.json: invalid index; normal device files withheld.'
                    }
                } else {
                    $script:SnmpStatus = 'partial'
                    Add-SnmpNote 'normal: directory or run index unavailable (device files withheld).'
                }
            }
            if ($script:SnmpFiles -gt 0 -and $lifecycleMissing) {
                $script:SnmpStatus = 'partial'
            }
            if ($script:SnmpFiles -eq 0 -and $script:SnmpStatus -eq 'complete') {
                $script:SnmpStatus = 'unavailable'
                Add-SnmpNote 'No completed SNMP diagnostic files found.'
            }
        }
    }
    Add-SnmpNote "Result: $script:SnmpStatus; complete files copied: $script:SnmpFiles."
    Add-Manifest '06-state\snmp-diagnostics-status.txt' 'file' 'generated' 'SNMP evidence collection status'
}
# --- end SNMP diagnostics ------------------------------------------------------

# --- collectors -------------------------------------------------------------------
# Launch a native netdata binary (netdata.exe / netdatacli.exe) DIRECTLY as a
# foreground process. These MUST NOT go through Start-Job: they are MSYS2
# binaries whose runtime does not initialize inside a PowerShell background-job
# runspace, so they exit non-zero with no output (an empty dump / empty stdout).
# A foreground child with an inherited console works. Notes:
#  - System.Diagnostics.Process, not Start-Process: Start-Process -PassThru does
#    not expose a reliable .ExitCode on Windows PowerShell 5.1 (it reads back
#    empty even for `cmd /c exit 7`).
#  - CreateNoWindow stays $false: detaching from the console reproduces the same
#    empty-output failure. The bundle runs from a console PowerShell, so the
#    child inherits it and no extra window appears.
#  - both pipes are decoded as UTF-8 (see below) and drained asynchronously so a full buffer cannot deadlock the
#    wait, which is bounded by WaitForExit (kill on timeout).
function Invoke-NativeProcess([string]$exe, [string[]]$argList, [int]$timeoutSec) {
    $r = [pscustomobject]@{ StdOut = ''; StdErr = ''; ExitCode = $null; TimedOut = $false; LaunchError = '' }
    $p = $null
    try {
        $psi = New-Object System.Diagnostics.ProcessStartInfo
        $psi.FileName = $exe
        # PS 5.1 ProcessStartInfo has no ArgumentList; quote args containing spaces
        $psi.Arguments = ($argList | ForEach-Object { if ($_ -match '\s') { '"' + $_ + '"' } else { $_ } }) -join ' '
        $psi.UseShellExecute = $false
        $psi.RedirectStandardOutput = $true
        $psi.RedirectStandardError = $true
        # the MSYS2 binaries write UTF-8; without this .NET decodes the pipes
        # with the console code page and non-ASCII text (dyncfg names, labels)
        # is mangled before it is re-written as UTF-8
        $psi.StandardOutputEncoding = New-Object System.Text.UTF8Encoding $false
        $psi.StandardErrorEncoding = New-Object System.Text.UTF8Encoding $false
        $psi.CreateNoWindow = $false
        $p = New-Object System.Diagnostics.Process
        $p.StartInfo = $psi
        [void]$p.Start()
        $outTask = $p.StandardOutput.ReadToEndAsync()
        $errTask = $p.StandardError.ReadToEndAsync()
        if ($p.WaitForExit($timeoutSec * 1000)) {
            $r.ExitCode = $p.ExitCode
            $r.StdOut = $outTask.Result
            $r.StdErr = $errTask.Result
        } else {
            try { $p.Kill() } catch { Write-Verbose "could not kill ${exe}: $_" }
            $r.TimedOut = $true
        }
    } catch {
        $r.LaunchError = "$_"
    } finally {
        if ($p) { $p.Dispose() }
    }
    return $r
}

function Save-Cmd([string]$rel, [string]$title, [scriptblock]$cmd, [string]$originText, [string]$nativeExe = '', [string[]]$nativeArgs = @(), [long]$cap = 0, [int]$timeoutFloor = 0) {
    # $cap mirrors the POSIX "collect_cmd --cap": journal/event-log captures are
    # documented at 5 MiB while command output defaults to 2 MiB
    if ($cap -le 0) { $cap = 2MB }
    # a capture that legitimately needs longer than the per-command default can
    # raise the floor; without it the job is killed and its WHOLE output is
    # replaced by "TIMEOUT after Ns", losing everything it had already gathered
    $timeout = if ($timeoutFloor -gt 0) { [Math]::Max($TimeoutSeconds, $timeoutFloor) } else { $TimeoutSeconds }
    if (Test-Deadline) { return }
    $full = Join-Path $Work $rel
    New-Item -ItemType Directory -Path (Split-Path $full) -Force | Out-Null
    $header = "# netdata-support-bundle v$ToolVersion | command: $originText | captured: $((Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ'))"
    if ($nativeExe) {
        # MSYS2 binary: direct launch, never Start-Job (see Invoke-NativeProcess).
        # Text file, so merge stdout+stderr like the old `2>&1`; never silent.
        $np = Invoke-NativeProcess $nativeExe $nativeArgs $timeout
        if ($np.LaunchError) { $result = "ERROR: could not launch: $($np.LaunchError)" }
        elseif ($np.TimedOut) { $result = "TIMEOUT after ${timeout}s" }
        elseif ($np.ExitCode -ne 0) { $result = "ERROR: exit $($np.ExitCode)`r`n$($np.StdErr)$($np.StdOut)" }
        else { $result = "$($np.StdOut)$($np.StdErr)" }
    } else {
        try {
            $job = Start-Job -ScriptBlock $cmd
            $done = Wait-Job $job -Timeout $timeout
            if ($done) { $result = Receive-Job $job 2>&1 | Select-Object -First 50000 | Out-String } else { $result = "TIMEOUT after ${timeout}s" }
            Stop-Job $job -ErrorAction SilentlyContinue
            Remove-Job $job -Force
        } catch { $result = "ERROR: $_" }
    }
    if ($result.Length -gt $cap) {
        # truncate at a LINE boundary so a secret cannot straddle the cut
        $cut = $result.LastIndexOf("`n", [int]$cap)
        if ($cut -lt 0) { $result = '[content withheld: output exceeds the cap without a line break]' }
        else { $result = $result.Substring(0, $cut + 1) + ("### TRUNCATED at {0} bytes (line-aligned) ###" -f $cap) }
    }
    Write-Utf8 $full ($header + "`r`n" + $result)
    Invoke-SanitizeFile $full
    Add-Manifest $rel 'cmd' $originText $title
}

function Get-RawCmdOutput([scriptblock]$cmd, [string]$nativeExe, [string[]]$nativeArgs) {
    if ($nativeExe) {
        # MSYS2 binary: direct launch, never Start-Job (see Invoke-NativeProcess).
        # JSON output, so keep stdout ONLY (merging stderr would corrupt it); a
        # failed/empty run returns nothing.
        $np = Invoke-NativeProcess $nativeExe $nativeArgs $TimeoutSeconds
        if (-not $np.TimedOut -and -not $np.LaunchError -and $np.ExitCode -eq 0) { return $np.StdOut }
        return ''
    }
    $result = ''
    try {
        $job = Start-Job -ScriptBlock $cmd
        $done = Wait-Job $job -Timeout $TimeoutSeconds
        if ($done) { $result = Receive-Job $job 2>&1 | Select-Object -First 50000 | Out-String }
        Stop-Job $job -ErrorAction SilentlyContinue
        Remove-Job $job -Force
    } catch { Write-Verbose "collector failed: $_" }
    return $result
}

function Limit-RawCmdOutput([string]$result, [bool]$dropOverCap) {
    # a truncated JSON document is worse than none: fail closed
    # measure the UTF-8 bytes written, not UTF-16 code units (see Save-Api)
    if ([System.Text.Encoding]::UTF8.GetByteCount($result) -le 2MB) { return $result }
    if ($dropOverCap) { return '' }
    return '{"error":"output exceeded the cap and was withheld"}'
}

function Save-CmdRaw([string]$rel, [string]$title, [scriptblock]$cmd, [string]$originText, [string]$nativeExe = '', [string[]]$nativeArgs = @(), [switch]$dropOverCap) {
    # like Save-Cmd but with NO header/trailer, for commands whose output must
    # stay parseable (JSON). Provenance lives in MANIFEST.json only. The file is
    # removed if the command produced nothing, and with -dropOverCap also if the
    # output exceeds the cap, so a caller with a fallback source can still try it.
    if (Test-Deadline) { return }
    $full = Join-Path $Work $rel
    New-Item -ItemType Directory -Path (Split-Path $full) -Force | Out-Null
    $result = Get-RawCmdOutput $cmd $nativeExe $nativeArgs
    if ($result -and $result.Trim().Length -gt 0) { $result = Limit-RawCmdOutput $result $dropOverCap.IsPresent }
    if ($result -and $result.Trim().Length -gt 0) {
        Write-Utf8 $full $result
        Invoke-SanitizeFile $full
        Add-Manifest $rel 'cmd' $originText $title
    } elseif (Test-Path $full) {
        Remove-Item $full -Force
    }
}

function Save-File([string]$rel, [string]$title, [string]$src, [long]$cap = 0) {
    if (Test-Deadline) { return }
    if ($cap -eq 0) { $cap = $FileCap }
    if (-not (Test-Path $src -PathType Leaf)) { return }
    # stream.conf is the one file whose streaming API key is kept verbatim; the
    # context is keyed on the SOURCE name, never on the bundle path
    $ctx = if ((Split-Path -Leaf $src) -eq 'stream.conf') { 'stream' } else { '' }
    $full = Join-Path $Work $rel
    New-Item -ItemType Directory -Path (Split-Path $full) -Force | Out-Null
    $item = Get-Item $src -Force
    if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
        # reparse points can silently retarget: fail closed rather than follow
        Write-Utf8 $full '[content withheld: source is a symlink/reparse point]'
        Add-Manifest $rel 'file' "$src (reparse point - withheld)" $title
        return
    }
    $size = $item.Length
    $origin = $src
    if ($size -gt $cap) {
        # keep the tail at a LINE boundary (drop the first, possibly partial,
        # line) so a secret can never straddle the cut and dodge the
        # line-based sanitizer; a tail with no line break at all is withheld
        $fs = [System.IO.File]::OpenRead($src)
        try {
            $slack = [Math]::Min($size, $cap + 4096)
            $fs.Seek(-$slack, [System.IO.SeekOrigin]::End) | Out-Null
            $buf = New-Object byte[] $slack
            $read = $fs.Read($buf, 0, $slack)
            $text = $script:Utf8NoBom.GetString($buf, 0, $read)
            $nl = $text.IndexOf("`n")
            if ($nl -lt 0) {
                Write-Utf8 $full '[content withheld: file tail exceeds the cap without a line break]'
            } else {
                Write-Utf8 $full $text.Substring($nl + 1)
            }
        } finally { $fs.Close() }
        $origin = "$src (last ~$cap of $size bytes, line-aligned)"
    } else {
        try { Copy-Item $src $full -Force -ErrorAction Stop }
        catch {
            Write-Utf8 $full "[content withheld: could not read source ($_)]"
            $origin = "$src (unreadable - withheld)"
        }
    }
    Invoke-SanitizeFile $full $true $ctx
    Add-Manifest $rel 'file' $origin $title
}

$NdPort = 19999
$CloudHost = 'app.netdata.cloud'   # reachability probe target
function Save-Api([string]$rel, [string]$title, [string]$urlPath) {
    if (Test-Deadline) { return }
    $full = Join-Path $Work $rel
    New-Item -ItemType Directory -Path (Split-Path $full) -Force | Out-Null
    try {
        $resp = Invoke-LocalApi "http://127.0.0.1:$NdPort$urlPath" $TimeoutSeconds
        $content = $resp.Content
        # a JSON body cut at any point is malformed, so overflow is withheld whole.
        # Measure the UTF-8 bytes actually written, not the string's UTF-16 code
        # units: a non-ASCII body can be several times longer on disk than in
        # characters and would otherwise sail past the cap.
        if ([System.Text.Encoding]::UTF8.GetByteCount($content) -gt 2MB) { $content = '{"error":"response exceeded the cap and was withheld"}' }
        Write-Utf8 $full $content
        Invoke-SanitizeFile $full
        Add-Manifest $rel 'api' $urlPath $title
    } catch {
        if (Test-Path $full) { Remove-Item $full -Force }
    }
}

# --- environment detection --------------------------------------------------------
$NetdataPrefix = 'C:\Program Files\Netdata'
$ConfDir = Join-Path $NetdataPrefix 'etc\netdata'
$LibDir = Join-Path $NetdataPrefix 'var\lib\netdata'
$CacheDir = Join-Path $NetdataPrefix 'var\cache\netdata'
$LogDir = Join-Path $NetdataPrefix 'var\log\netdata'
$NetdataExe = Join-Path $NetdataPrefix 'usr\bin\netdata.exe'
$NetdataCli = Join-Path $NetdataPrefix 'usr\bin\netdatacli.exe'

$NetdataSvc = Get-Service -Name 'Netdata' -ErrorAction SilentlyContinue
$NetdataProc = Get-Process -Name 'netdata' -ErrorAction SilentlyContinue | Select-Object -First 1
$NetdataProcessIds = @()
if ($NetdataProc) {
    # Walk descendants so sockets opened by plugin processes remain in scope
    # without relying on a brittle fixed process-name list.
    $allProcesses = @(Get-CimInstance Win32_Process -Property ProcessId, ParentProcessId -ErrorAction SilentlyContinue)
    $ids = New-Object 'System.Collections.Generic.HashSet[int]'
    [void]$ids.Add([int]$NetdataProc.Id)
    $changed = $true
    while ($changed) {
        $changed = $false
        foreach ($child in $allProcesses) {
            if ($ids.Contains([int]$child.ParentProcessId) -and $ids.Add([int]$child.ProcessId)) { $changed = $true }
        }
    }
    $NetdataProcessIds = @($ids | Sort-Object)
}
$ApiOk = $false
try {
    # /api/v3/info stays reachable under bearer protection (v1 is locked)
    foreach ($probe in @('/api/v3/info', '/api/v1/info')) {
        try { Invoke-LocalApi "http://127.0.0.1:$NdPort$probe" 3 | Out-Null; $ApiOk = $true; break }
        catch { Write-Verbose "probe $probe failed: $_" }
    }
} catch { Write-Verbose "agent API not reachable: $_" }

# pre-seed pseudonyms for child/mirrored hostnames, so a parent's children are
# obfuscated consistently in every file (node_instances, stream configs, logs)
if ($ApiOk -and $Obfuscate) {
    try {
        $names = @()
        $ni = (Invoke-LocalApi "http://127.0.0.1:$NdPort/api/v2/node_instances" $TimeoutSeconds).Content | ConvertFrom-Json
        if ($ni -and $ni.PSObject.Properties['nodes']) { $names += @($ni.nodes | ForEach-Object { $_.nm }) }
        $v1 = (Invoke-LocalApi "http://127.0.0.1:$NdPort/api/v1/info" $TimeoutSeconds).Content | ConvertFrom-Json
        if ($v1 -and $v1.PSObject.Properties['mirrored_hosts']) { $names += @($v1.mirrored_hosts) }
        foreach ($n in ($names | Sort-Object -Unique)) {
            if (-not $n -or $n.Length -lt 4 -or $n -eq 'localhost' -or $n -eq $HostShort -or $n -eq $HostFqdn) { continue }
            if (-not $script:PseudoMap.ContainsKey($n)) {
                if ($script:PseudoMap.Count -ge 4096) { break }  # honor the map cap
                $script:FqdnCount++
                $script:PseudoMap[$n] = "private-host-$($script:FqdnCount)"
            }
        }
        # longest first so overlapping names (host, host-2) replace correctly
        $script:SeededHosts = @($script:PseudoMap.Keys |
            Where-Object { $script:PseudoMap[$_] -like 'private-host*' } |
            Sort-Object { $_.Length } -Descending)
    } catch { Write-Verbose "child hostname pre-seed failed: $_" }
}

Show-Info "netdata-support-bundle $ToolVersion (Windows)"
Show-Info ("service: {0} | process: {1} | api: {2}" -f `
    $(if ($NetdataSvc) { $NetdataSvc.Status } else { 'not installed' }), `
    $(if ($NetdataProc) { "pid $($NetdataProc.Id)" } else { 'not running' }), `
    $(if ($ApiOk) { 'up' } else { 'unreachable' }))

# ============================================================================
# 01-system
# ============================================================================
Show-Info 'collecting: system'
Save-Cmd '01-system\os-version.txt' 'OS version and build' { Get-CimInstance Win32_OperatingSystem | Format-List Caption, Version, BuildNumber, OSArchitecture, LastBootUpTime, TotalVisibleMemorySize, FreePhysicalMemory } 'Get-CimInstance Win32_OperatingSystem'
Save-Cmd '01-system\computer-info.txt' 'Hardware, domain role, virtualization' { Get-CimInstance Win32_ComputerSystem | Format-List Manufacturer, Model, SystemType, NumberOfProcessors, NumberOfLogicalProcessors, TotalPhysicalMemory, DomainRole, HypervisorPresent } 'Get-CimInstance Win32_ComputerSystem'
Save-Cmd '01-system\disk-usage.txt' 'Volume usage' { Get-CimInstance Win32_LogicalDisk | Format-Table DeviceID, @{n='SizeGB';e={[math]::Round($_.Size/1GB,1)}}, @{n='FreeGB';e={[math]::Round($_.FreeSpace/1GB,1)}}, FileSystem -AutoSize } 'Get-CimInstance Win32_LogicalDisk'
Save-Cmd '01-system\clock-timesync.txt' 'Clock and time sync (drift breaks streaming/cloud)' { Get-Date -Format o; w32tm /query /status 2>&1 } 'Get-Date; w32tm /query /status'
Save-Cmd '01-system\uptime.txt' 'System uptime' { (Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime | Format-List Days, Hours, Minutes } 'uptime via Win32_OperatingSystem'

# ============================================================================
# 02-install
# ============================================================================
Show-Info 'collecting: install'
# NOTE: never use Win32_Product here - querying it triggers MSI reconfiguration
# of every installed package. The uninstall registry keys are the safe source.
Save-Cmd '02-install\msi-info.txt' 'Installed Netdata MSI package info (from uninstall registry)' {
    foreach ($root in @('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
                        'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall')) {
        Get-ItemProperty "$root\*" -ErrorAction SilentlyContinue |
            Where-Object { $_.DisplayName -like '*Netdata*' } |
            Format-List DisplayName, DisplayVersion, InstallDate, InstallLocation, Publisher
    }
} 'registry uninstall keys (Netdata)'
Save-Cmd '02-install\install-tree.txt' 'Install dir layout (top level)' { Get-ChildItem $using:NetdataPrefix | Format-Table Mode, LastWriteTime, Length, Name -AutoSize } "Get-ChildItem $NetdataPrefix (top level; -Depth is PS6+)"
if (Test-Path (Join-Path $ConfDir '.install-type')) {
    Save-File '02-install\install-type.file.txt' 'Install type marker' (Join-Path $ConfDir '.install-type')
}

# ============================================================================
# 03-process
# ============================================================================
Show-Info 'collecting: process'
Save-Cmd '03-process\netdata-processes.txt' 'Netdata process tree with CPU/memory' { Get-Process | Where-Object { $_.ProcessName -match 'netdata|go.d|ebpf|windows.plugin' } | Format-Table Id, ProcessName, CPU, WorkingSet64, HandleCount, Threads -AutoSize } 'Get-Process (netdata family)'
Save-Cmd '03-process\service-status.txt' 'Netdata service state and config' { Get-Service Netdata | Format-List *; (Get-CimInstance Win32_Service -Filter "Name='Netdata'") | Format-List StartMode, StartName, PathName, State, ExitCode } 'Get-Service Netdata + Win32_Service'

# ============================================================================
# 04-config
# ============================================================================
Show-Info 'collecting: config'
if ($ApiOk) {
    Save-Api '04-config\effective-netdata.conf' 'EFFECTIVE running config (merged, annotated) - authoritative over on-disk file' '/netdata.conf'
}
if (Test-Path $ConfDir) {
    Save-Cmd '04-config\config-tree.txt' 'User config dir tree (files here = user-customized; ssl and key material excluded)' { Get-ChildItem $using:ConfDir -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.FullName -notmatch '[\\/]ssl([\\/]|$)' -and $_.Extension -notin @('.pem', '.key') } | Select-Object -First 2000 | Format-Table Mode, LastWriteTime, Length, FullName -AutoSize } "Get-ChildItem $ConfDir -Recurse (ssl/key material excluded)"
    Save-File '04-config\netdata.conf' 'On-disk main config' (Join-Path $ConfDir 'netdata.conf')
    Save-File '04-config\stream.conf' 'Streaming config (parent/child; the streaming api key is KEPT VERBATIM - see README.md)' (Join-Path $ConfDir 'stream.conf')
    Save-File '04-config\claim.conf' 'Cloud claim config (token redacted)' (Join-Path $ConfDir 'claim.conf')
    Save-File '04-config\go.d.conf' 'go.d orchestrator config' (Join-Path $ConfDir 'go.d.conf')
    # every user-customized collector/health config, keeping subdirectory
    # structure (go.d\sd\*.conf must not collide with go.d\*.conf); ssl dirs
    # and key material are never collected. Capped at 200 files.
    $script:ConfCollected = 0
    foreach ($sub in @('go.d', 'health.d', 'python.d', 'charts.d', 'statsd.d')) {
        $subDir = Join-Path $ConfDir $sub
        if (-not (Test-Path $subDir)) { continue }
        Get-ChildItem $subDir -File -Recurse -Include '*.conf', '*.yml', '*.yaml' -ErrorAction SilentlyContinue |
            Where-Object { $_.FullName -notmatch '\\ssl\\' -and $_.Extension -notin @('.pem', '.key') } |
            ForEach-Object {
                if ($script:ConfCollected -ge 200) { return }
                $relSub = $_.FullName.Substring($subDir.Length).TrimStart('\')
                Save-File "04-config\$sub\$relSub" "User-customized $sub config (secrets redacted)" $_.FullName 256KB
                $script:ConfCollected++
            }
    }
}

# ============================================================================
# 05-logs (Windows: Event Log is the primary destination)
# ============================================================================
Show-Info "collecting: logs (last ${SinceHours}h, Event Log + files)"
# Windows builds are ALWAYS compiled with ETW (CMakeLists.txt sets HAVE_ETW
# unconditionally for OS_WINDOWS) and netdata-conf-logs.c then selects "etw" as
# the default log method, so the daemon logs into five manifest-declared
# Operational channels - Netdata/Daemon, /Collectors, /Access, /Health, /Aclk
# (wevt_netdata_mc_generate.c). Querying only NetdataWEL therefore returned NO
# daemon logs at all on a default install (netdata/netdata#23448).
#
# One merged file, ordered for triage: channel/provider state first, then Daemon
# and Collectors (what you read first), and Access LAST with the smallest event
# budget because it is by far the highest volume.
Save-Cmd '05-logs\eventlog-netdata.txt' 'Netdata events from the Windows Event Log: ETW channels (Netdata/*) + NetdataWEL + Application, plus channel and provider registration state' {
    # capture $using: into locals once: the $using: modifier is resolved when the
    # job scriptblock is serialized and must not be relied on inside strings
    $sinceHours = [int]$using:SinceHours
    $since = (Get-Date).AddHours(-$sinceHours)
    '===== channel state (a disabled or full channel loses events) ====='
    Get-WinEvent -ListLog 'Netdata/*', 'NetdataWEL' -ErrorAction SilentlyContinue |
        Select-Object LogName, IsEnabled, RecordCount, FileSize | Format-Table -AutoSize
    # per-channel event budgets: Daemon/Collectors carry the diagnosis, Access is
    # request noise, so it gets the smallest share of the file
    $channels = @(
        @{ Log = 'Netdata/Daemon';     Max = 3000 }
        @{ Log = 'Netdata/Collectors'; Max = 3000 }
        @{ Log = 'Netdata/Health';     Max = 1000 }
        @{ Log = 'Netdata/Aclk';       Max = 1000 }
        @{ Log = 'NetdataWEL';         Max = 2000 }
        @{ Log = 'Application';        Max = 2000 }
        @{ Log = 'Netdata/Access';     Max = 500 }
    )
    foreach ($ch in $channels) {
        "===== channel: $($ch.Log) (max $($ch.Max) events, last ${sinceHours}h) ====="
        $events = Get-WinEvent -FilterHashtable @{ LogName = $ch.Log; StartTime = $since } `
                               -MaxEvents $ch.Max -ErrorAction SilentlyContinue
        # Application is a shared channel: keep only Netdata's own records. The
        # dedicated channels are already Netdata-only, so filtering them would
        # drop records whose ProviderName resolves differently under ETW.
        if ($ch.Log -eq 'Application') { $events = $events | Where-Object { $_.ProviderName -match 'Netdata' } }
        if ($events) {
            $events | Select-Object TimeCreated, ProviderName, LevelDisplayName, Message | Format-List
        } else {
            '(no events in this window, or the channel does not exist on this system)'
        }
    }
} "Get-WinEvent: Netdata/* ETW channels + NetdataWEL + Application (last ${SinceHours}h)" -cap $LogCap -timeoutFloor 30
if (Test-Path $LogDir) {
    Get-ChildItem $LogDir -File -Filter '*.log' | ForEach-Object {
        Save-File "05-logs\$($_.Name)" "Agent log file: $($_.Name)" $_.FullName $LogCap
    }
}

# ============================================================================
# 06-state
# ============================================================================
Show-Info 'collecting: state'
Collect-SnmpDiagnostics
$StatusFile = Join-Path $LibDir 'status-netdata.json'
if (Test-Path $StatusFile) {
    Save-File '06-state\status-file.json' 'Daemon status file: LAST EXIT/CRASH RECORD incl. fatal stack trace (read this first for crashes)' $StatusFile
}
if (Test-Path $LibDir) {
    # bearer_tokens FILENAMES are live API tokens - list only their count
    Save-Cmd '06-state\state-tree.txt' 'State dir listing (bearer token filenames withheld - they are live tokens)' {
        $items = Get-ChildItem $using:LibDir -Recurse -ErrorAction SilentlyContinue | Select-Object -First 2000
        $tokenCount = @($items | Where-Object { $_.FullName -match '\\bearer_tokens\\' }).Count
        $items | Where-Object { $_.FullName -notmatch '\\bearer_tokens\\' } |
            Format-Table Mode, LastWriteTime, Length, FullName -AutoSize
        "[$tokenCount token file(s) - names withheld, they ARE the tokens]"
    } "Get-ChildItem $LibDir -Recurse (bearer_tokens contents withheld)"
    $CloudDir = Join-Path $LibDir 'cloud.d'
    if (Test-Path (Join-Path $CloudDir 'claimed_id')) {
        Save-File '06-state\claimed-id.txt' 'Cloud claim id (safe identifier; token/private.pem never collected)' (Join-Path $CloudDir 'claimed_id')
    }
}
if (Test-Path $CacheDir) {
    Save-Cmd '06-state\db-disk-usage.txt' 'Database disk usage per tier + sqlite sizes' { Get-ChildItem $using:CacheDir -Directory | ForEach-Object { $s = (Get-ChildItem $_.FullName -Recurse -File | Measure-Object Length -Sum).Sum; '{0}  {1:N1} MB' -f $_.Name, ($s/1MB) }; Get-ChildItem $using:CacheDir -File -Filter '*.db*' | Format-Table Name, Length -AutoSize } "du of $CacheDir"
}

# ============================================================================
# 07-runtime
# ============================================================================
# The dyncfg tree through the local command pipe: the agent answers it as a
# member of its own space, which the web API needs and the bundle cannot be.
# It does not need the web API, so it is tried whatever $ApiOk says - but at the
# tree's own place in the order: sanitizing a large tree can take minutes, and
# done first it would spend the deadline the smaller runtime captures need.
# With -dropOverCap Save-CmdRaw leaves no file on any failure, so the API
# fallback can use the same path.
$DyncfgTreeTitle = 'Dynamic configuration tree: go.d/scripts.d job states (accepted/running/failed/disabled/orphan/incomplete/none), service discovery, vnodes, secret stores, alert prototypes - THE source of collector job state'
function Save-DyncfgTreeCli {
    if ((Test-Path $NetdataCli) -and $NetdataProc) {
        # netdatacli.exe is MSYS2: native direct-launch, not Start-Job
        Save-CmdRaw '07-runtime\dyncfg-tree.json' $DyncfgTreeTitle $null 'netdatacli.exe dyncfg-tree' -nativeExe $NetdataCli -nativeArgs 'dyncfg-tree' -dropOverCap
    }
}
if ($ApiOk) {
    Show-Info 'collecting: runtime (agent is up)'
    Save-Api '07-runtime\info-v3.json' 'BEST SINGLE CALL: buildinfo, features, cloud status, per-tier retention' '/api/v3/info'
    Save-Api '07-runtime\info-v1.json' 'Agent info v1' '/api/v1/info'
    Save-Api '07-runtime\node-instances.json' 'Node instances: children, streaming state, db_size, metric counts' '/api/v2/node_instances'
    Save-Api '07-runtime\stream-info.json' 'Streaming diagnostics' '/api/v3/stream_info'
    Save-Api '07-runtime\aclk.json' 'Cloud/ACLK connection state' '/api/v1/aclk'
    Save-Api '07-runtime\alerts-active.json' 'Currently raised alerts' '/api/v3/alerts?options=active'
    Save-Api '07-runtime\alerts-all.json' 'All alert instances (summary)' '/api/v1/alarms?all'
    Save-Api '07-runtime\functions.json' 'Registered functions' '/api/v1/functions'
    Save-DyncfgTreeCli
    # Agents that predate `netdatacli dyncfg-tree` (tried just above), or a bundle run
    # by a user who cannot reach the command pipe, fall back to the API.
    $dyncfgPath = Join-Path $Work '07-runtime\dyncfg-tree.json'
    if (-not (Test-Path $dyncfgPath)) {
        Save-Api '07-runtime\dyncfg-tree.json' $DyncfgTreeTitle '/api/v3/config?action=tree'
    }
    # /api/v3/info answers without any access check, so $ApiOk can be true while
    # this endpoint - behind the dyncfg ACL - refuses us, and Save-Api deletes the
    # file on failure leaving no trace at all. The bundle cannot authenticate:
    # bearer token FILENAMES are themselves live tokens and are never collected.
    if (-not (Test-Path $dyncfgPath)) {
        # Every Save-Api in this block returns early once the deadline is past,
        # so 07-runtime may not exist yet and WriteAllText would throw.
        $dyncfgDir = Split-Path $dyncfgPath -Parent
        if (-not (Test-Path $dyncfgDir)) { New-Item -ItemType Directory -Path $dyncfgDir -Force | Out-Null }
        # State what was observed, not a cause: a refusal, a timeout, a transport
        # error and a deadline all reach this point identically.
        $marker = '{"error":"dyncfg tree was not collected","hint":"netdatacli dyncfg-tree was not usable (the agent predates it, the bundle could not reach the command pipe, or the call failed), so the bundle fell back to /api/v3/config, which only signed-in members of the agent''s space may list on current agents, and which a bearer-protected API refuses on older ones - a timeout, transport error, HTTP failure or an exceeded collection deadline is indistinguishable here. The bundle never collects bearer tokens and cannot authenticate. Run netdatacli.exe dyncfg-tree as an administrator, or ask for the collector job state separately.","means":"NOT evidence that no collector jobs exist"}'
        Write-Utf8 $dyncfgPath $marker
        Add-Manifest '07-runtime\dyncfg-tree.json' 'file' 'generated' 'Collector job state was unavailable (see hint in the file)'
    }
    Save-Api '07-runtime\ml-info.json' 'Machine learning status' '/api/v1/ml_info'
    Save-Api '07-runtime\self-cpu.csv' 'Netdata CPU last 10min (csv)' '/api/v1/data?chart=netdata.server_cpu&after=-600&points=60&format=csv'
    Save-Api '07-runtime\self-memory.csv' 'Netdata memory last 10min (csv)' '/api/v1/data?chart=netdata.memory&after=-600&points=60&format=csv'
    Save-Api '07-runtime\self-api-clients.csv' 'Netdata API clients last 10min (csv)' '/api/v1/data?chart=netdata.clients&after=-600&points=60&format=csv'
} else {
    Show-Info 'agent API unreachable - skipping runtime section'
    $marker = Join-Path $Work '07-runtime'
    New-Item -ItemType Directory -Path $marker -Force | Out-Null
    Write-Utf8 (Join-Path $marker 'AGENT-WAS-DOWN.txt') "Agent API at 127.0.0.1:$NdPort was unreachable when this bundle was created. See 05-logs and 06-state\status-file.json for why."
    Add-Manifest '07-runtime\AGENT-WAS-DOWN.txt' 'file' 'generated' 'Marker: agent was not running'
    Save-DyncfgTreeCli
}
# Elevation state: the perflib dump needs an elevated session, and the summary
# reports it too. Computed once here (after the self-test path has already
# exited) so it is never evaluated on non-Windows self-test runs.
$IsElevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (Test-Path $NetdataExe) {
    # these MSYS2 netdata.exe invocations use the native direct-launch path, not
    # Start-Job (which yields empty output for MSYS2 binaries - see Invoke-NativeProcess)
    Save-Cmd '07-runtime\buildinfo.txt' 'netdata -W buildinfo (verbatim; works with daemon down)' $null 'netdata.exe -W buildinfo' -nativeExe $NetdataExe -nativeArgs '-W', 'buildinfo'
    Save-CmdRaw '07-runtime\buildinfo.json' 'netdata -W buildinfojson (machine-readable; no header so it parses as JSON)' $null 'netdata.exe -W buildinfojson' -nativeExe $NetdataExe -nativeArgs '-W', 'buildinfojson'
    Save-Cmd '07-runtime\cmakecache.txt' 'netdata -W cmakecache: authoritative build config (flags, plugins, paths) - superset of buildinfo' $null 'netdata.exe -W cmakecache' -nativeExe $NetdataExe -nativeArgs '-W', 'cmakecache'
    # Windows performance-library dump: counter/instance metadata used to
    # diagnose Windows collector issues (e.g. PerflibSMB). Written to a file via
    # -perflibfile (not a capped stdout capture) so a large dump is not
    # truncated; sanitized like any other file (instance names can include
    # share/host names). The dump reads HKEY_PERFORMANCE_DATA and needs an
    # elevated session, and enumerating every counter can exceed the default
    # per-command timeout, so perflib gets an elevation pre-check and a 30s
    # floor. A failure is never silent: a JSON marker records why (incl. the
    # exit code and captured stderr/stdout), mirroring the never-omit behaviour
    # of Save-Cmd.
    #
    # Launched via Invoke-NativeProcess (direct foreground child, never
    # Start-Job) since netdata.exe is an MSYS2 binary; the dump itself goes to
    # -perflibfile, so stdout is normally empty and stderr carries the
    # "written to" confirmation or the failure reason.
    $perfPath = Join-Path $Work '07-runtime\perflib.json'
    New-Item -ItemType Directory -Path (Split-Path $perfPath) -Force | Out-Null
    $perfOrigin = 'netdata.exe -W perflibdump -perflibfile'
    $perfTitle = 'Windows performance-library dump: counter/instance metadata for Windows collector diagnosis (e.g. PerflibSMB)'
    if (-not $IsElevated) {
        Write-Utf8 $perfPath (@{ error = 'perflib dump skipped: not running elevated'; hint = 'Re-run the bundle in an elevated (Run as Administrator) PowerShell to collect it.' } | ConvertTo-Json -Compress)
    } else {
        $perfTimeout = [Math]::Max($TimeoutSeconds, 30)
        $np = Invoke-NativeProcess $NetdataExe @('-W', 'perflibdump', '-perflibfile', $perfPath) $perfTimeout
        $perfExit = $np.ExitCode
        $perfReason = if ($np.LaunchError) { "could not launch netdata.exe: $($np.LaunchError)" }
                      elseif ($np.TimedOut) { "TIMEOUT after ${perfTimeout}s" }
                      else { '' }
        if (($perfExit -eq 0) -and (Test-Path $perfPath) -and ((Get-Item $perfPath).Length -gt 0)) {
            # a full counter dump is legitimately large (~60 MB on a typical host,
            # more on busy servers); it compresses ~10-20x in the zip. The ceiling
            # only guards against a pathological runaway.
            if ((Get-Item $perfPath).Length -gt 128MB) { Write-Utf8 $perfPath '{"error":"perflib dump exceeded 128 MB and was withheld"}' }
        } else {
            # never silently drop it: record the exit code and both captured streams
            if (-not $perfReason) {
                $perfReason = (@($np.StdErr, $np.StdOut) | Where-Object { $_ -and $_.Trim() } | ForEach-Object { $_.Trim() }) -join ' | '
                if (-not $perfReason) { $perfReason = 'perflib dump produced no output file and no error text' }
            }
            Write-Utf8 $perfPath (@{
                    error    = 'perflib dump was not collected'
                    exitcode = $perfExit
                    reason   = $perfReason
                    hint     = 'If this timed out, re-run with a larger -TimeoutSeconds. If it reported an access error, ensure the bundle runs in an elevated PowerShell. Requires a netdata.exe build new enough to support -perflibfile.'
                } | ConvertTo-Json -Compress)
        }
    }
    # PII obfuscation only: the dump is counter/instance metadata with no
    # credential fields, and the full per-key credential scan over a ~60 MB blob
    # would blow the global deadline and starve later sections.
    Invoke-SanitizeFile $perfPath $false
    Add-Manifest '07-runtime\perflib.json' 'cmd' $perfOrigin $perfTitle
}
if ((Test-Path $NetdataCli) -and $NetdataProc) {
    # netdatacli.exe is also MSYS2: native direct-launch, not Start-Job
    Save-CmdRaw '07-runtime\aclk-state.json' 'Cloud connectivity state (netdatacli aclk-state json; no header so it parses as JSON)' $null 'netdatacli.exe aclk-state json' -nativeExe $NetdataCli -nativeArgs 'aclk-state', 'json'
}

# ============================================================================
# 08-network
# ============================================================================
Show-Info 'collecting: network'
Save-Cmd '08-network\netdata-sockets.txt' 'All sockets owned by the Netdata process tree' {
    $pids = @($using:NetdataProcessIds)
    if (-not $pids) { 'unavailable: Netdata process not found'; return }
    $rows = @()
    try {
        $rows += @(Get-NetTCPConnection -ErrorAction Stop | Where-Object { $pids -contains $_.OwningProcess } | ForEach-Object {
            $p = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue
            [pscustomobject]@{ Protocol = 'TCP'; LocalAddress = $_.LocalAddress; LocalPort = $_.LocalPort; RemoteAddress = $_.RemoteAddress; RemotePort = $_.RemotePort; State = $_.State; OwningProcess = $_.OwningProcess; ProcessName = if ($p) { $p.ProcessName } else { '-' } }
        })
    } catch { $rows += [pscustomobject]@{ Protocol = 'TCP'; State = "unavailable: $($_.Exception.Message)" } }
    try {
        $rows += @(Get-NetUDPEndpoint -ErrorAction Stop | Where-Object { $pids -contains $_.OwningProcess } | ForEach-Object {
            $p = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue
            [pscustomobject]@{ Protocol = 'UDP'; LocalAddress = $_.LocalAddress; LocalPort = $_.LocalPort; RemoteAddress = '*'; RemotePort = '*'; State = '-'; OwningProcess = $_.OwningProcess; ProcessName = if ($p) { $p.ProcessName } else { '-' } }
        })
    } catch { $rows += [pscustomobject]@{ Protocol = 'UDP'; State = "unavailable: $($_.Exception.Message)" } }
    if ($rows.Count -eq 0) { 'No Netdata-owned TCP or UDP sockets found.' } else { $rows | Format-Table Protocol, LocalAddress, LocalPort, RemoteAddress, RemotePort, State, OwningProcess, ProcessName -AutoSize }
} 'Get-NetTCPConnection/Get-NetUDPEndpoint (Netdata process tree)'
Save-Cmd '08-network\dns-config.txt' 'DNS resolver config' { Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses } | Format-Table InterfaceAlias, ServerAddresses -AutoSize } 'Get-DnsClientServerAddress'
Save-Cmd '08-network\proxy-config.txt' 'System proxy configuration' { netsh winhttp show proxy 2>&1; Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' | Format-List ProxyEnable, ProxyServer, AutoConfigURL } 'netsh winhttp show proxy + registry'
Save-Cmd '08-network\cloud-connectivity.txt' 'Reachability of Netdata Cloud (TCP/TLS only, no data sent)' { Test-NetConnection -ComputerName $using:CloudHost -Port 443 -WarningAction SilentlyContinue | Format-List ComputerName, RemotePort, TcpTestSucceeded, PingSucceeded } "Test-NetConnection ${CloudHost}:443"

# ============================================================================
# 09-permissions
# ============================================================================
Show-Info 'collecting: permissions'
$PluginsDir = Join-Path $NetdataPrefix 'usr\libexec\netdata\plugins.d'
Save-Cmd '09-permissions\plugins-d.txt' 'plugins.d: ACLs, ownership, inheritance and integrity-level state for every plugin (a broken ACL here stops a collector dead)' {
    $d = $using:PluginsDir
    if (-not (Test-Path $d)) { "(plugins.d not found at $d)"; return }
    "===== $d ====="
    Get-Acl $d | Format-List Owner, Group, Access
    '-- contents --'
    Get-ChildItem $d -Force -ErrorAction SilentlyContinue |
        Select-Object Mode, LastWriteTime, Length, Name | Format-Table -AutoSize
    '-- per-file owner and access (non-inherited entries are the interesting ones) --'
    Get-ChildItem $d -File -Force -ErrorAction SilentlyContinue | Select-Object -First 120 | ForEach-Object {
        $acl = Get-Acl $_.FullName -ErrorAction SilentlyContinue
        if ($acl) {
            $explicit = @($acl.Access | Where-Object { -not $_.IsInherited })
            "{0}`n    owner={1}" -f $_.Name, $acl.Owner
            if ($explicit.Count -gt 0) {
                foreach ($a in $explicit) {
                    "    explicit: {0} {1} {2}" -f $a.IdentityReference, $a.AccessControlType, $a.FileSystemRights
                }
            } else {
                '    explicit: (none - all access inherited)'
            }
        }
    }
    '-- alternate data streams (a Zone.Identifier marks a file as downloaded/blocked) --'
    $ads = Get-ChildItem $d -File -Force -ErrorAction SilentlyContinue |
        Select-Object -First 120 |
        ForEach-Object { Get-Item $_.FullName -Stream * -ErrorAction SilentlyContinue } |
        Where-Object { $_.Stream -ne ':$DATA' }
    if ($ads) { $ads | Select-Object FileName, Stream, Length | Format-Table -AutoSize }
    else { '(no alternate data streams)' }
} "Get-Acl + Get-ChildItem -Stream on $PluginsDir"

$PermPaths = @($NetdataPrefix, $ConfDir, (Join-Path $ConfDir 'netdata.conf'), (Join-Path $ConfDir 'stream.conf'),
               (Join-Path $ConfDir 'ssl'), $LogDir, $LibDir, (Join-Path $LibDir 'cloud.d'), $CacheDir,
               $PluginsDir, $NetdataExe) | Where-Object { $_ } | Select-Object -Unique
Save-Cmd '09-permissions\netdata-paths.txt' 'Ownership, ACLs, inheritance and integrity levels for netdata directories, configs and the binary' {
    foreach ($p in $using:PermPaths) {
        if (-not (Test-Path $p)) { continue }
        "===== $p ====="
        $acl = Get-Acl $p -ErrorAction SilentlyContinue
        if ($acl) {
            "owner: $($acl.Owner)"
            "group: $($acl.Group)"
            'access:'
            $acl.Access | ForEach-Object {
                "    {0} {1} {2} (inherited={3})" -f $_.IdentityReference, $_.AccessControlType, $_.FileSystemRights, $_.IsInherited
            }
            if ($acl.AreAccessRulesProtected) { 'NOTE: ACL inheritance is DISABLED on this path' }
        } else {
            '(could not read the ACL - insufficient privilege?)'
        }
        # mandatory integrity level, when one is explicitly set
        $il = (icacls $p 2>$null | Select-String -SimpleMatch 'Mandatory Label') -join '; '
        if ($il) { "integrity: $il" }
    }
} 'Get-Acl + icacls for netdata paths'

# ============================================================================
# summary + manifest + README
# ============================================================================
Show-Info 'writing summary and manifest'
$AgentVersion = ''
$infoV1 = Join-Path $Work '07-runtime\info-v1.json'
if (Test-Path $infoV1) {
    try { $AgentVersion = (Get-Content $infoV1 -Raw | ConvertFrom-Json).version } catch { Write-Verbose "info-v1.json unparsable: $_" }
}
$CrashHint = ''
$sfPath = Join-Path $Work '06-state\status-file.json'
if (Test-Path $sfPath) {
    try {
        $sf = Get-Content $sfPath -Raw | ConvertFrom-Json
        if ($sf.agent -and $sf.agent.exit_reason) { $CrashHint = ($sf.agent.exit_reason -join ',') }
    } catch { Write-Verbose "status-file.json unparsable: $_" }
}
$RuntimeSecs = [int]((Get-Date) - $StartTime).TotalSeconds
$StreamNote = ''
if (Test-Path (Join-Path $Work '04-config\stream.conf')) {
    $StreamNote = 'streaming key:    PRESENT VERBATIM in 04-config\stream.conf (by design - see README.md)'
}

$summary = @"
NETDATA SUPPORT BUNDLE SUMMARY
generated:        $((Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ'))
tool version:     $ToolVersion (windows)
runtime seconds:  $RuntimeSecs
ran elevated:     $IsElevated
pii obfuscation:  $(if ($Obfuscate) { 'on' } else { 'OFF' })
$StreamNote
agent version:    $(if ($AgentVersion) { $AgentVersion } else { 'unknown' })
service status:   $(if ($NetdataSvc) { $NetdataSvc.Status } else { 'NOT INSTALLED' })
agent process:    $(if ($NetdataProc) { "yes (pid $($NetdataProc.Id))" } else { 'NOT RUNNING' })
agent api:        $(if ($ApiOk) { 'reachable' } else { 'UNREACHABLE' })
$(if ($CrashHint) { "last exit reason: $CrashHint   <-- check 06-state\status-file.json" })

SNMP diagnostics: $script:SnmpStatus ($script:SnmpFiles raw files; UNSANITIZED when included)

READ ORDER FOR TRIAGE:
  SNMP issues         -> 06-state\snmp-diagnostics-status.txt, 06-state\snmp-diagnostics\
  crashes/won't start -> 06-state\status-file.json, 05-logs\eventlog-netdata.txt
  collector issues    -> 07-runtime\dyncfg-tree.json (job states), 04-config\go.d*, 05-logs\, 09-permissions\plugins-d.txt
  streaming issues    -> 04-config\stream.conf, 07-runtime\node-instances.json, 01-system\clock-timesync.txt
  cloud/claiming      -> 06-state\claimed-id.txt, 07-runtime\aclk.json, 08-network\
  performance         -> 03-process\netdata-processes.txt, 06-state\db-disk-usage.txt
  access denied       -> 09-permissions\ (ACLs, ownership, inheritance, service account)
"@
Write-Utf8 (Join-Path $Work 'summary.txt') $summary
Add-Manifest 'summary.txt' 'file' 'generated' 'Human summary'

$readme = @"
# Netdata Support Bundle (Windows)

Generated by ``netdata-support-bundle.ps1``. Standard captures are SANITIZED: secrets (tokens, api
keys, passwords) are redacted; by default IPs, MACs, emails and
hostnames are replaced with stable pseudonyms - consistent across all files.
The pseudonym map stays on this machine, next to the zip - it is NOT in this
bundle.

**Streaming-key exception:** the **streaming API key** is kept VERBATIM in
``04-config\stream.conf`` (both ``api key`` values and ``[<API_KEY>]`` section
headers). It is the value support needs to tell whether a child and its parent
agree, so it is not treated as a secret. If your threat model differs, remove or
mask it before sending the bundle. The same key IS still redacted where it
appears in the Access event-log channel.

**Optional raw SNMP evidence:** ``06-state/snmp-diagnostics/``, when included,
is UNSANITIZED: no secret redaction or PII obfuscation. It preserves device
responses, inventory and metric values. Share this bundle through a restricted
support channel, never a public issue. See ``06-state/snmp-diagnostics-status.txt`` for
missing files or copy failures; the directory is not one simultaneous snapshot.

Collected text files keep their original bytes: a byte-order mark, CRLF or CR line
endings and a missing final newline all survive redaction, so an encoding fault
in a config file is still visible here.

Layout matches the POSIX bundle (see MANIFEST.json for every file):
01-system, 02-install, 03-process, 04-config, 05-logs (Windows Event Log:
Netdata/* ETW channels + NetdataWEL), 06-state, 07-runtime, 08-network,
09-permissions (ACLs, ownership, inheritance, service account).
Start with summary.txt.
"@
Write-Utf8 (Join-Path $Work 'README.md') $readme
Add-Manifest 'README.md' 'file' 'generated' 'Bundle documentation'

# emit MANIFEST.json LAST so every file (incl. summary.txt and README.md) is indexed
$manifest = [ordered]@{
    schema = 'netdata-support-bundle/v2'
    tool_version = "$ToolVersion-windows"
    generated_utc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
    runtime_seconds = $RuntimeSecs
    pii_obfuscated = [bool]($Obfuscate -and $script:SnmpFiles -eq 0)
    secrets_redacted = ($script:SnmpFiles -eq 0)
    snmp_diagnostics = [ordered]@{ requested = [bool]$IncludeSnmpDiagnostics; status = $script:SnmpStatus; files = $script:SnmpFiles }
    # the standard-capture exception to secrets_redacted (netdata/netdata#23448)
    streaming_api_key_redacted = $false
    agent_running = [bool]$NetdataProc
    agent_api_reachable = [bool]$ApiOk
    is_container = $false
    files = $script:ManifestRows
}
Write-Utf8 (Join-Path $Work 'MANIFEST.json') (($manifest | ConvertTo-Json -Depth 4) + "`n")

# ============================================================================
# zip
# ============================================================================
New-Item -ItemType Directory -Path $Output -Force | Out-Null
$ZipPath = Join-Path $Output "$BundleName.zip"
# build the zip inside the owner-only staging dir, then publish without
# overwrite: Move-Item without -Force fails if the destination exists
$StagingZip = Join-Path $Staging "$BundleName.zip"
try {
    # Create mode streams entries; Compress-Archive uses Update mode and buffers
    # the complete evidence payload in memory until the archive is closed.
    Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop
    [System.IO.Compression.ZipFile]::CreateFromDirectory(
        $Work, $StagingZip, [System.IO.Compression.CompressionLevel]::Optimal, $true)
    Move-Item -Path $StagingZip -Destination $ZipPath -ErrorAction Stop
} catch {
    # Write-Error would be swallowed by $ErrorActionPreference='SilentlyContinue'
    Show-Info "ERROR: could not create or publish $ZipPath ($_)"
    if (-not $KeepStaging) { Remove-Item $Staging -Recurse -Force -ErrorAction SilentlyContinue }
    exit 1
}

$MapPath = Join-Path $Output "$BundleName.pseudonym-map.tsv"
if ($Obfuscate -and $script:PseudoMap.Count -gt 0) {
    # same format as the POSIX script: type<TAB>original<TAB>pseudonym
    $mapText = ($script:PseudoMap.GetEnumerator() | ForEach-Object {
        $type = 'other'
        if ($_.Value -match '^ip6-\d+$') { $type = 'ip6' }
        elseif ($_.Value -match '^ip-\d+$') { $type = 'ip' }
        elseif ($_.Value -match '^private-host-\d+$') { $type = 'fqdn' }
        elseif ($_.Value -eq 'redacted-host') { $type = 'host' }
        elseif ($_.Value -eq 'redacted-user') { $type = 'user' }
        "$type`t$($_.Key)`t$($_.Value)"
    }) -join "`n"
    # build in the private staging dir, then move without -Force so a
    # pre-existing file or symlink in a shared output dir is never followed
    $mapStaged = Join-Path $Staging 'pseudonym-map.tsv'
    Write-Utf8 $mapStaged ($mapText + "`n")
    try { Move-Item -Path $mapStaged -Destination $MapPath -ErrorAction Stop }
    catch { Show-Info "could not publish pseudonym map to $MapPath ($_) - left in staging"; $MapPath = $mapStaged; $KeepStaging = $true }
}

if (-not $KeepStaging) { Remove-Item $Staging -Recurse -Force }

Show-Info ''
Show-Info ("done in {0}s" -f [int]((Get-Date) - $StartTime).TotalSeconds)
Show-Info "bundle:  $ZipPath"
if ($Obfuscate -and $script:PseudoMap.Count -gt 0) {
    Show-Info "pseudonym map (KEEP PRIVATE, do not send): $MapPath"
}
Show-Info 'attach the bundle to your support ticket.'