Private/Report.ps1

#Requires -Version 5.1
# The offline device report (ADR-0039, New-DoctorDODeviceReport): Data/device-report.html is the console's device page,
# compiled with the analysis engine by apps/web/scripts/build-device-report.mjs; this file puts one device's inventory and
# telemetry into it. Pure functions plus one mockable path, so the tests drive them with a fake device.

Set-StrictMode -Version Latest

$script:DDReportMarker = '"__DOCTORDO_REPORT_DATA__"'

function Get-DDReportTemplatePath {
    # Mockable seam.
    [CmdletBinding()]
    param()
    Join-Path -Path $script:ModuleRoot -ChildPath 'Data/device-report.html'
}

function Test-DDReportTemplate {
    <#
    .SYNOPSIS
        Why a template cannot be used, or $null when it can: the data placeholder must occur exactly once, and the page
        script must still match the hash its Content-Security-Policy names (a CRLF conversion would break it and the
        page would load blank).
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][AllowEmptyString()][string] $Template)
    $first = $Template.IndexOf($script:DDReportMarker, [System.StringComparison]::Ordinal)
    if ($first -lt 0 -or $first -ne $Template.LastIndexOf($script:DDReportMarker, [System.StringComparison]::Ordinal)) { return 'the data placeholder is missing or appears more than once' }
    $hash = [regex]::Match($Template, "script-src 'sha256-([A-Za-z0-9+/=]+)'")
    $page = [regex]::Match($Template, '(?s)<script>(.*)</script>\s*</body>')
    if (-not $hash.Success -or -not $page.Success) { return 'the page script or its Content-Security-Policy hash is missing' }
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try { $actual = [Convert]::ToBase64String($sha.ComputeHash((New-Object System.Text.UTF8Encoding($false)).GetBytes($page.Groups[1].Value))) } finally { $sha.Dispose() }
    if ($actual -ne $hash.Groups[1].Value) { return 'the page script does not match its Content-Security-Policy hash (was the file converted to CRLF line endings?)' }
    $null
}

function Protect-DDReportInventory {
    <#
    .SYNOPSIS
        For an anonymised report, also drop what -Anonymise keeps in the inventory because the fleet needs it: the Entra
        device and tenant IDs, the Intune device ID, the ConfigMgr site code and the AD site. Each becomes a notCollected
        entry, so the page says why it is absent. Changes the inventory in place and returns it.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)] $Inventory)
    $removed = New-Object 'System.Collections.Generic.List[string]'
    $m = $Inventory['management']
    foreach ($k in 'entraDeviceId', 'tenantId') { if ($null -ne $m[$k]) { $m[$k] = $null; $removed.Add("management.$k") } }
    if ($null -ne $m['mdm'] -and $null -ne $m['mdm']['intuneDeviceId']) { $m['mdm']['intuneDeviceId'] = $null; $removed.Add('management.mdm.intuneDeviceId') }
    if ($null -ne $m['configMgr'] -and $null -ne $m['configMgr']['siteCode']) { $m['configMgr']['siteCode'] = $null; $removed.Add('management.configMgr.siteCode') }
    if ($null -ne $Inventory['network']['adSite']) { $Inventory['network']['adSite'] = $null; $removed.Add('network.adSite') }
    $nc = New-Object 'System.Collections.Generic.List[object]'
    foreach ($n in @($Inventory['notCollected'])) { $nc.Add($n) }
    foreach ($f in $removed) { $nc.Add([ordered]@{ field = $f; reason = 'skipped-by-flag'; detail = 'removed from an anonymised device report' }) }
    $Inventory['notCollected'] = @($nc | Select-Object -First 256)
    $Inventory
}

function ConvertTo-DDReportHtml {
    <#
    .SYNOPSIS
        The page with the report's data in place. The JSON is canonical (Json.ps1: the same text on Windows PowerShell
        5.1 and PowerShell 7) with every '<' written as its JSON escape (backslash, u003c), so nothing in it can end the
        data block early. String Replace is ordinal and literal: '$&' or '$1' in the data stay as they are.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string] $Template, [Parameter(Mandatory)] $Data)
    $escapedLt = '\' + 'u003c'   # built in two parts so no tool or editor can collapse the escape back into '<'
    $json = (ConvertTo-DDCanonicalJson -InputObject $Data).Replace('<', $escapedLt)
    $Template.Replace($script:DDReportMarker, $json)
}

function Get-DDReportFileName {
    # DoctorDO-<host>-<yyyyMMdd-HHmmss>.html (UTC, like the bundle); no host name in an anonymised report's name. The JSON
    # and the PDF beside it have the same stem and the extensions 'json' and 'pdf'. -BaseName (New-DoctorDODeviceReport
    # -FileName, 19 Oct 2026, already checked by Test-DDReportBaseName) replaces the whole stem, so a scheduled run keeps one file.
    [CmdletBinding()]
    param([AllowNull()][string] $HostName, [Parameter(Mandatory)][datetime] $At, [switch] $Anonymised, [ValidateSet('html', 'json', 'pdf')][string] $Extension = 'html', [string] $BaseName)
    if ($BaseName) { return ('{0}.{1}' -f $BaseName, $Extension) }
    $stamp = $At.ToString('yyyyMMdd-HHmmss', [System.Globalization.CultureInfo]::InvariantCulture)
    $who = $(if ($Anonymised -or -not $HostName) { 'report' } else { $HostName -replace '[^A-Za-z0-9-]', '-' })
    'DoctorDO-{0}-{1}.{2}' -f $who, $stamp, $Extension
}

function Test-DDReportBaseName {
    <#
    .SYNOPSIS
        Why a -FileName for the device report cannot be used, or $null when it can (19 Oct 2026). A name, not a path: the
        folder is -OutputPath. The checks are Windows' own rules, written out rather than taken from
        [System.IO.Path]::GetInvalidFileNameChars(), which lists only '/' and NUL on Linux, so the result is the same everywhere.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string] $Name)
    if ([string]::IsNullOrWhiteSpace($Name)) { return 'it is empty' }
    if ($Name -match '[\\/]') { return 'it contains a path separator; put the folder in -OutputPath and only the name in -FileName' }
    if ($Name -match '[:*?"<>|\x00-\x1F]') { return 'it contains a character Windows does not allow in a file name (: * ? " < > | or a control character)' }
    if ($Name -ne $Name.Trim() -or $Name.EndsWith('.')) { return 'it starts or ends with a space, or ends with a full stop' }
    if ($Name.Length -gt 200) { return 'it is longer than 200 characters' }
    if ($Name -match '\.(html?|json|pdf)$') { return 'it ends with a file extension; give the name without one, each format adds its own (.html, .json, .pdf)' }
    # Windows reserves these device names whatever follows the first full stop (CON.html is the console, not a file).
    $stem = $Name.Split('.')[0].TrimEnd(' ')
    if ($stem -match '^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9]|CONIN\$|CONOUT\$)$') { return ('{0} is a name Windows reserves for a device' -f $stem) }
    $null
}

function Get-DDReportPrintUrl {
    <#
    .SYNOPSIS
        The file URL a headless browser prints the report from, with the fragment #print: the page then draws itself for
        paper from its first frame (every section open, every table row, light colours, no controls; device-report/print.ts)
        rather than relying on the browser's print events, which a headless print does not wait for.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)][string] $Path)
    (New-Object System.Uri([System.IO.Path]::GetFullPath($Path))).AbsoluteUri + '#print'
}

function Test-DDPdfFile {
    # True when the file is there and starts as a PDF does ('%PDF-'): a browser that fails can leave nothing, or an empty file.
    [CmdletBinding()]
    [OutputType([bool])]
    param([Parameter(Mandatory)][string] $Path)
    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false }
    $head = New-Object byte[] 5
    $fs = [System.IO.File]::OpenRead($Path)
    try { $n = $fs.Read($head, 0, 5) } finally { $fs.Dispose() }
    $n -eq 5 -and [System.Text.Encoding]::ASCII.GetString($head) -eq '%PDF-'
}

function ConvertTo-DDPrettyJson {
    <#
    .SYNOPSIS
        Indents compact JSON (two spaces, "key": value, one member or element per line; an empty object or array stays
        as {} or []). It only moves whitespace: strings, numbers and key order are untouched, so the result parses to
        what the input did. Walks the text once and tracks whether it is inside a string, so a brace or comma in a value
        is data.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][AllowEmptyString()][string] $Json)
    $sb = New-Object System.Text.StringBuilder
    $depth = 0
    $inString = $false
    $n = $Json.Length
    for ($i = 0; $i -lt $n; $i++) {
        $c = $Json[$i]
        if ($inString) {
            [void]$sb.Append($c)
            if ($c -eq '\') { $i++; if ($i -lt $n) { [void]$sb.Append($Json[$i]) } }
            elseif ($c -eq '"') { $inString = $false }
            continue
        }
        if ($c -eq '"') { $inString = $true; [void]$sb.Append($c) }
        elseif ($c -eq '{' -or $c -eq '[') {
            $close = $(if ($c -eq '{') { '}' } else { ']' })
            if ($i + 1 -lt $n -and $Json[$i + 1] -eq $close) { [void]$sb.Append($c).Append($close); $i++ }
            else { $depth++; [void]$sb.Append($c).Append("`n").Append(' ' * (2 * $depth)) }
        }
        elseif ($c -eq '}' -or $c -eq ']') { $depth--; [void]$sb.Append("`n").Append(' ' * (2 * $depth)).Append($c) }
        elseif ($c -eq ',') { [void]$sb.Append(",`n").Append(' ' * (2 * $depth)) }
        elseif ($c -eq ':') { [void]$sb.Append(': ') }
        else { [void]$sb.Append($c) }
    }
    $sb.ToString()
}

function ConvertTo-DDReportJson {
    <#
    .SYNOPSIS
        The report's data as the JSON file written by New-DoctorDODeviceReport -Format Json: the same data the page embeds
        (ConvertTo-DDReportHtml), indented, with two properties first: schema ('doctordo-device-report/1', the payload's
        kind and version) and collectorVersion. Below those two the keys are in the canonical order (UTF-16 ordinal), a
        newline ends the text, and '<' is written as itself (it is a file, not an HTML data block).
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)] $Data, [Parameter(Mandatory)][string] $CollectorVersion)
    $pretty = ConvertTo-DDPrettyJson -Json (ConvertTo-DDCanonicalJson -InputObject $Data)
    $schema = ConvertTo-DDJsonString -Value ('{0}/{1}' -f $Data['kind'], $Data['version'])
    $head = "{`n `"schema`": $schema,`n `"collectorVersion`": $(ConvertTo-DDJsonString -Value $CollectorVersion),`n "
    $head + $pretty.Substring(4) + "`n"
}

function Write-DDReportFile {
    <#
    .SYNOPSIS
        Writes text as UTF-8 without a byte order mark, through a .tmp file beside the target, and replaces an existing
        file of that name (the report is a snapshot; the newest one wins). Nothing is left behind but the target.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string] $Path, [Parameter(Mandatory)][AllowEmptyString()][string] $Text)
    $tmp = $Path + '.tmp'
    [System.IO.File]::WriteAllText($tmp, $Text, (New-Object System.Text.UTF8Encoding($false)))
    if (Test-Path -LiteralPath $Path) { Remove-Item -LiteralPath $Path -Force }
    Move-Item -LiteralPath $tmp -Destination $Path
}