# Copyright (c) 2026, R. W. Rodolico # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, this # list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # SysinfoSensor.psm1 # Helpers shared by the Windows sysinfo sensors (windows\sensors\*.ps1). # Sensor contract (exit codes, STDOUT line format): unix/sensors/Documentation/README.md # A sensor imports this module from ..\lib\ relative to its own path: # # Import-Module ([IO.Path]::Combine($PSScriptRoot, '..', 'lib', 'SysinfoSensor.psm1')) # Test-SysinfoWindows # Invoke-SysinfoSensor { # Write-SysinfoFact -Path @('system', 'uptime') -Value $seconds # } # # Output goes through private UTF-8 (no BOM) writers on the raw STDOUT/STDERR # streams, so neither the console code page nor PowerShell's formatter touches # it, and setting [Console]::OutputEncoding (which can fail with no console) # is never needed. Sensors must not use Write-Output/Write-Host for facts. # # Must stay Windows PowerShell 5.1 compatible and pure ASCII. # # Version 1.0.0 20261018 Claude # Initial version (bug 211, stage W1): fact writer, exit-code constants, # Test-SysinfoWindows, Invoke-SysinfoSensor, rules U and P ported from unix/library.pm Set-StrictMode -Version 2.0 $script:VERSION = '1.0.0' # exit codes, unix/sensors/Documentation/README.md $script:SYSINFO_EXIT_OK = 0 $script:SYSINFO_EXIT_WRONG_OS = 1 $script:SYSINFO_EXIT_NOT_APPLICABLE = 2 $script:SYSINFO_EXIT_NOT_DUE = 3 $script:SYSINFO_EXIT_ERROR = 4 $script:Utf8NoBom = New-Object System.Text.UTF8Encoding $false $script:StdOut = New-Object System.IO.StreamWriter ([Console]::OpenStandardOutput()), $script:Utf8NoBom $script:StdOut.AutoFlush = $true $script:StdOut.NewLine = "`n" $script:StdErr = New-Object System.IO.StreamWriter ([Console]::OpenStandardError()), $script:Utf8NoBom $script:StdErr.AutoFlush = $true $script:StdErr.NewLine = "`n" # Spec 1.3 rule P, same list as unix/library.pm isPlaceholderSerial $script:PlaceholderSerialPattern = '^(1234567890|0123456789|0+|NotSpecified|Not Specified|Not Settable|None|N/A|Default string|To Be Filled By O\.E\.M\.|System Serial Number|Chassis Serial Number|0123456789ABCDEF|Unknown|\.+)$' $script:PlaceholderUUIDs = @( '03000200040005000006000700080009', '00020003000400050006000700080009' ) ####################################################### # # ConvertTo-SysinfoField( $Text ) # # Replaces any tab, CR or LF with a space so a hostile # value (e.g. a registry DisplayName) cannot add fields # or lines to the sensor output. # ####################################################### function ConvertTo-SysinfoField { param([AllowNull()][object]$Text) if ($null -eq $Text) { return '' } return ([string]$Text) -replace "[`t`r`n]", ' ' } ####################################################### # # Write-SysinfoFact -Path @('category','key',...) -Value # # Writes one fact line, categorykey...value. # Path is 1 to 5 segments (2 to 6 fields with the value). # Writes nothing when the value is empty (the parser would # take the last key as the value). Values are written as # strings. Throws on an empty path segment. # ####################################################### function Write-SysinfoFact { param( [Parameter(Mandatory = $true)][string[]]$Path, [AllowNull()][object]$Value ) if ($Path.Count -lt 1 -or $Path.Count -gt 5) { throw "Write-SysinfoFact: path must have 1 to 5 segments, got $($Path.Count)" } $fields = New-Object System.Collections.ArrayList foreach ($segment in $Path) { $clean = (ConvertTo-SysinfoField $segment).Trim() if ($clean -eq '') { throw 'Write-SysinfoFact: empty path segment' } [void]$fields.Add($clean) } $text = (ConvertTo-SysinfoField $Value).Trim() if ($text -eq '') { return } [void]$fields.Add($text) $script:StdOut.WriteLine(($fields.ToArray() -join "`t")) } ####################################################### # # Write-SysinfoError( $Message ) # # Writes one line to STDERR (the client keeps the first # line as sysinfo.sensors..message). # ####################################################### function Write-SysinfoError { param([AllowNull()][object]$Message) $script:StdErr.WriteLine((ConvertTo-SysinfoField $Message)) } ####################################################### # # Exit-SysinfoSensor -Code [-Message ] # # Writes the optional one-line reason to STDERR and ends # the sensor with the given exit code. # ####################################################### function Exit-SysinfoSensor { param( [Parameter(Mandatory = $true)][int]$Code, [AllowNull()][string]$Message ) if ($Message) { Write-SysinfoError $Message } $script:StdOut.Flush() $script:StdErr.Flush() exit $Code } ####################################################### # # Test-SysinfoWindows # # Ends the sensor with exit 1 (wrong_os) when not running # on Windows. # ####################################################### function Test-SysinfoWindows { if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { Exit-SysinfoSensor -Code $script:SYSINFO_EXIT_WRONG_OS -Message 'not Windows' } } ####################################################### # # Invoke-SysinfoSensor { body } # # Runs the sensor body with ErrorActionPreference Stop. An # uncaught error exits 4 with its message on STDERR (an # uncaught error in a -File script otherwise exits 1, which # the client would record as wrong_os). Exits 0 when the # body returns. The body may call Exit-SysinfoSensor itself. # ####################################################### function Invoke-SysinfoSensor { param([Parameter(Mandatory = $true)][scriptblock]$Body) $ErrorActionPreference = 'Stop' try { & $Body } catch { Exit-SysinfoSensor -Code $script:SYSINFO_EXIT_ERROR -Message ("failed: " + $_.Exception.Message) } Exit-SysinfoSensor -Code $script:SYSINFO_EXIT_OK } ####################################################### # # ConvertTo-NormalisedUUID( $Value ) # # Spec 1.3 rule U: lower-case, strip non-hex; returns the # 32 hex digits, or '' when not exactly 32 remain. # ####################################################### function ConvertTo-NormalisedUUID { param([AllowNull()][object]$Value) if ($null -eq $Value) { return '' } $hex = ([string]$Value -replace '[^0-9A-Fa-f]', '').ToLowerInvariant() if ($hex -cmatch '^[0-9a-f]{32}$') { return $hex } return '' } ####################################################### # # Test-PlaceholderUUID( $Value ) # # Spec 1.3 rule P: true when missing, malformed or a # known placeholder (as unix/library.pm isPlaceholderUUID). # ####################################################### function Test-PlaceholderUUID { param([AllowNull()][object]$Value) if ($null -eq $Value) { return $true } $trimmed = ([string]$Value).Trim() if ($trimmed -eq '') { return $true } if ($trimmed -eq 'not settable' -or $trimmed -eq 'not present') { return $true } $normal = ConvertTo-NormalisedUUID $trimmed if ($normal -eq '') { return $true } if ($normal -cmatch '^0{32}$' -or $normal -cmatch '^f{32}$') { return $true } if ($script:PlaceholderUUIDs -contains $normal) { return $true } return $false } ####################################################### # # Get-SysinfoUUIDStatus( $Value ) # # Spec 1.3: 'missing' (empty, Not Settable, Not Present), # 'placeholder' (rule P) or 'ok'. # ####################################################### function Get-SysinfoUUIDStatus { param([AllowNull()][object]$Value) $trimmed = '' if ($null -ne $Value) { $trimmed = ([string]$Value).Trim() } if ($trimmed -eq '' -or $trimmed -eq 'not settable' -or $trimmed -eq 'not present') { return 'missing' } if (Test-PlaceholderUUID $trimmed) { return 'placeholder' } return 'ok' } ####################################################### # # Test-PlaceholderSerial( $Value ) # # Spec 1.3 rule P: true when empty or matching the # placeholder list (case-insensitive, trimmed), as # unix/library.pm isPlaceholderSerial. # ####################################################### function Test-PlaceholderSerial { param([AllowNull()][object]$Value) if ($null -eq $Value) { return $true } $trimmed = ([string]$Value).Trim() if ($trimmed -eq '') { return $true } return ($trimmed -imatch $script:PlaceholderSerialPattern) } ####################################################### # # Get-SysinfoSerialStatus( $Value ) # # Spec 1.3: 'missing' (empty), 'placeholder' or 'ok'. # ####################################################### function Get-SysinfoSerialStatus { param([AllowNull()][object]$Value) if ($null -eq $Value -or ([string]$Value).Trim() -eq '') { return 'missing' } if (Test-PlaceholderSerial $Value) { return 'placeholder' } return 'ok' } Export-ModuleMember -Function ConvertTo-SysinfoField, Write-SysinfoFact, Write-SysinfoError, Exit-SysinfoSensor, Test-SysinfoWindows, Invoke-SysinfoSensor, ConvertTo-NormalisedUUID, Test-PlaceholderUUID, Get-SysinfoUUIDStatus, Test-PlaceholderSerial, Get-SysinfoSerialStatus ` -Variable SYSINFO_EXIT_OK, SYSINFO_EXIT_WRONG_OS, SYSINFO_EXIT_NOT_APPLICABLE, SYSINFO_EXIT_NOT_DUE, SYSINFO_EXIT_ERROR