Private/Definition.ps1
|
# Check Definitions and where a Run's Definitions came from. # # A Check Definition is data: it names a Kind the module implements and supplies that # Kind's parameters. It can never introduce behaviour (ADR-0001). # # There are exactly two sources and no cache. A Run either fetched from the Checks Repo or # it used the Local Definitions shipped inside the module, and the Report says which. A Run # that used one while claiming the other is the silent failure this design exists to # prevent, so resolution keeps the Checks and the Provenance in one object and never # assembles them separately. function Get-LocalDefinitionPath { [CmdletBinding()] param() Join-Path $PSScriptRoot '..\Definitions\checks.json' | Resolve-Path | Select-Object -ExpandProperty Path } function Read-CheckDefinitionDocument { <# .SYNOPSIS Reads a Check Definition document from a file into the shape a Run uses. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)][string]$Path) ConvertFrom-CheckDefinitionJson -Document (Get-Content -Path $Path -Raw -Encoding UTF8) } function Get-DefinitionFingerprint { <# .SYNOPSIS A short mark of exactly which document this is. Pure. .DESCRIPTION A version says which document somebody meant to publish; it is a field in the document, and whoever edits a threshold in the library can leave it as it was. Two different documents then carry one version, and a Report cannot say which of them a Run used. The fingerprint is worked out from the document itself, by the Run that read it, so it changes whenever the document does and needs nobody to remember it. Eight characters of a SHA-256: enough to tell documents apart at a glance and short enough to read out over the phone. It is not a signature and proves nothing about who wrote the document. The byte order mark, the kind of line ending and space at the very end are left out of it. An editor or a download changes those without changing a single Check, and a mark that moved for that would stop being believed. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][AllowEmptyString()][string]$Document) $text = $Document.TrimStart([char]0xFEFF) -replace "`r`n", "`n" -replace "`r", "`n" $text = $text.TrimEnd() $sha = [System.Security.Cryptography.SHA256]::Create() try { $hash = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($text)) } finally { $sha.Dispose() } (-join ($hash[0..3] | ForEach-Object { $_.ToString('x2') })) } function ConvertFrom-CheckDefinitionJson { <# .SYNOPSIS Turns a Check Definition document's JSON into the shape a Run uses. .DESCRIPTION Separate from reading a file so that a document fetched from the Checks Repo and one shipped inside the module go through exactly the same parse. Two parsers would eventually disagree, and a Definition that behaves differently depending on where it came from is the failure Provenance exists to make visible. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)][AllowEmptyString()][string]$Document) # Not $document: PowerShell variable names are case-insensitive, so assigning the # parsed object back over a [string] parameter coerces it straight back into a string # and every property below reads as empty - silently, and only at runtime. $content = $Document | ConvertFrom-Json $generated = $null if ($content.Generated) { $parsedDate = [datetime]::MinValue if ([datetime]::TryParse([string]$content.Generated, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::None, [ref]$parsedDate)) { $generated = $parsedDate } } $checks = @($content.Checks | Where-Object { $_ } | ForEach-Object { [pscustomobject]@{ PSTypeName = 'Gutcheck.CheckDefinition' Name = $_.Name Kind = $_.Kind Parameters = ConvertTo-ParameterHashtable $_.Parameters MinimumModuleVersion = $_.MinimumModuleVersion } }) [pscustomobject]@{ PSTypeName = 'Gutcheck.CheckDefinitionDocument' Version = [string]$content.Version Generated = $generated # Of the text as it arrived, before anything was parsed out of it. Fingerprint = Get-DefinitionFingerprint -Document $Document Check = $checks } } function Resolve-CheckDefinition { <# .SYNOPSIS Decides which Check Definitions a Run uses, and states where they came from. .PARAMETER Fetched The document fetched from the Checks Repo, or $null when no fetch happened. There is no third state: an absent fetch means Local Definitions, not a stale cache. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][string]$LocalPath, [AllowNull()]$Fetched, [Parameter(Mandatory)][version]$ModuleVersion ) if ($Fetched) { $document = $Fetched $source = Get-Text 'Provenance.Source.Published' } else { $document = Read-CheckDefinitionDocument -Path $LocalPath $source = Get-Text 'Provenance.Source.Local' } [pscustomobject]@{ PSTypeName = 'Gutcheck.ResolvedDefinitions' Check = $document.Check Provenance = [pscustomobject]@{ PSTypeName = 'Gutcheck.Provenance' Source = $source Version = $document.Version Generated = $document.Generated Fingerprint = Get-DataProperty $document 'Fingerprint' ModuleVersion = $ModuleVersion } } } function Get-ProvenanceStatement { <# .SYNOPSIS The sentence every Report carries saying which Definitions produced it. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)]$Provenance, [datetime]$AsOf = (Get-Date) ) if ($null -eq $Provenance.Generated) { $age = Get-Text 'Provenance.Age.Unknown' } else { $generated = [datetime]$Provenance.Generated $days = [int][math]::Floor(($AsOf.Date - $generated.Date).TotalDays) if ($days -le 0) { # With the time when the document carries one: two documents published on the # same day are otherwise both "created today". A document dated without a time # reads as midnight, and midnight is not a time anybody published at. if ($generated.TimeOfDay.Ticks) { $age = Get-Text 'Provenance.Age.TodayAt' $generated.ToString('HH:mm') } else { $age = Get-Text 'Provenance.Age.Today' } } elseif ($days -eq 1) { # Published at 23:58 and read at 00:02 is "one day old" by the calendar and # four minutes old by the clock. With a time, say when yesterday. if ($generated.TimeOfDay.Ticks) { $age = Get-Text 'Provenance.Age.YesterdayAt' $generated.ToString('HH:mm') } else { $age = Get-Text 'Provenance.Age.OneDay' } } else { $age = Get-Text 'Provenance.Age.Days' $days } } # The fingerprint beside the version, because the version alone does not say which # document: see Get-DefinitionFingerprint. Absent on Provenance built before it existed. $version = "$($Provenance.Version)" $fingerprint = "$(Get-DataProperty $Provenance 'Fingerprint')" if ($fingerprint) { $version = Get-Text 'Provenance.VersionWithFingerprint' $version $fingerprint } (Get-Text 'Provenance.Statement') -f ` $Provenance.Source, $version, $age, $Provenance.ModuleVersion } function New-ProvenanceFinding { <# .SYNOPSIS Provenance as a Finding, so it reaches a Technician who reads only the Findings. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)]$Provenance, [datetime]$AsOf = (Get-Date) ) New-Finding -Category Gutcheck -Check (Get-Text 'Check.Definition.CheckDefinitions') -Severity INFO ` -Value (Get-ProvenanceStatement -Provenance $Provenance -AsOf $AsOf) } |