Private/New-SqlCertInventoryRow.ps1

# =============================================================================
# Script : Private/New-SqlCertInventoryRow.ps1
# Author : Keith Ramsey
# Created : 2026-09-07
# =============================================================================
# Change Log
# -----------------------------------------------------------------------------
# 2026-09-07 Keith Ramsey Initial: SqlCert.Inventory row factory for the D1
# estate-wide inventory (GR-001, DR-026/027/028).
# One row per certificate OR per unreadable surface;
# expiry reported not judged (Unknown-not-false).
# =============================================================================
# Decision Contract (Docs/DECISIONS_PHASE7.md)
# -----------------------------------------------------------------------------
# Must : SqlCert.Inventory shape with Status Found|Skipped|Failed (DR-026);
# DaysToExpiry/Expiring computed from NotAfter, and BOTH $null (Unknown)
# when NotAfter is unreadable -- never 0/false (DR-028); pure factory,
# no state change, never throws; provenance-stamped like SqlCert.Result.
# =============================================================================

function New-SqlCertInventoryRow {
    <#
    .SYNOPSIS
        Builds one SqlCert.Inventory row for a certificate found on a surface,
        or for a surface that could not be read.
    .DESCRIPTION
        Pure factory for the estate-wide inventory (D1). Normalises a certificate
        found on any surface -- engine, RS/PBIRS, TDE, backup, endpoint, cell -- to
        a single shape, and computes expiry per DR-028: DaysToExpiry is whole days
        from now (UTC) and Expiring is true at or below -ThresholdDays. When
        -NotAfter is not supplied (an unreadable date, or a Skipped/Failed surface
        row) DaysToExpiry and Expiring are both $null (Unknown) -- never 0/false,
        which would read as "fine" and hide the gap. Never throws.
    .PARAMETER Surface
        The cert surface this row describes: Engine, ReportingServices, Tde,
        Backup, Endpoint, or Cell.
    .PARAMETER SqlInstance
        The instance the row pertains to (may be empty for a host-level read).
    .PARAMETER Node
        The computer the row pertains to. Defaults to the local machine.
    .PARAMETER Thumbprint
        The certificate thumbprint, when a certificate was found.
    .PARAMETER Subject
        The certificate subject, when a certificate was found.
    .PARAMETER NotAfter
        The certificate's validity end. Omit for an unreadable date or a
        Skipped/Failed surface row -> expiry becomes Unknown ($null).
    .PARAMETER ThresholdDays
        Days-to-expiry at or below which Expiring is set. Default 30 (DR-023/028).
    .PARAMETER Status
        Found (a certificate), Skipped (surface absent), or Failed (surface
        present but unreadable). Default Found.
    .PARAMETER Detail
        Human-readable context; the reason on a Skipped/Failed row.
    .OUTPUTS
        SqlCert.Inventory (one object).
    .EXAMPLE
        New-SqlCertInventoryRow -Surface Tde -SqlInstance MSSQLSERVER `
            -Thumbprint $tp -Subject 'CN=tde' -NotAfter (Get-Date).AddDays(20)
    .EXAMPLE
        New-SqlCertInventoryRow -Surface Backup -Status Skipped -Detail 'No encrypted backups on this instance.'
    .NOTES
        Steps:
        1. Compute DaysToExpiry/Expiring from NotAfter when present; both $null (Unknown) when absent (DR-028).
        2. Read the provenance stamp from the module's single source (fallbacks keep a standalone unit test honest).
        3. Emit the SqlCert.Inventory pscustomobject. Pure; never throws.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Pure in-memory inventory-row factory; performs no state change.')]
    [CmdletBinding()]
    [OutputType('SqlCert.Inventory')]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('Engine', 'ReportingServices', 'Tde', 'Backup', 'Endpoint', 'Cell')]
        [string] $Surface,
        [string] $SqlInstance = '',
        [string] $Node = $env:COMPUTERNAME,
        [string] $Thumbprint = '',
        [string] $Subject = '',
        [Nullable[datetime]] $NotAfter,
        [int] $ThresholdDays = 30,
        [ValidateSet('Found', 'Skipped', 'Failed')] [string] $Status = 'Found',
        [string] $Detail = ''
    )

    # 1. Expiry per DR-028: reported, not judged; Unknown ($null) when no date.
    $daysToExpiry = $null
    $expiring = $null
    if ($null -ne $NotAfter) {
        $daysToExpiry = [int][math]::Floor(([datetime]$NotAfter).ToUniversalTime().Subtract([datetime]::UtcNow).TotalDays)
        $expiring = ($daysToExpiry -le $ThresholdDays)
    }

    # 2. Provenance: single source is SqlCertForge.psm1; fallbacks for standalone tests.
    $mv  = if (Test-Path variable:script:moduleVersion) { $script:moduleVersion } else { 'unknown' }
    $sv  = if (Test-Path variable:script:schemaVersion) { $script:schemaVersion } else { 'unknown' }
    $sha = if (Test-Path variable:script:commitSha)     { $script:commitSha }     else { $null }
    $rid = if (Test-Path variable:script:runId)         { $script:runId }         else { $null }

    # 3. Emit the row.
    [pscustomobject]@{
        PSTypeName    = 'SqlCert.Inventory'
        Surface       = $Surface
        SqlInstance   = $SqlInstance
        Node          = $Node
        Thumbprint    = $Thumbprint
        Subject       = $Subject
        NotAfter      = $NotAfter
        DaysToExpiry  = $daysToExpiry
        Expiring      = $expiring
        Status        = $Status
        Detail        = $Detail
        ModuleVersion = $mv
        SchemaVersion = $sv
        CommitSha     = $sha
        RunId         = $rid
        Timestamp     = [datetime]::UtcNow.ToString('o')
    }
}