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 Get-DefinitionChecksHash { <# .SYNOPSIS A short mark of the Checks in a document, leaving out its version and when it was built. Pure. .DESCRIPTION The fingerprint marks the whole document, so every build of the same Local Definitions gets a new one: the version and the time are in the text. This leaves those two out and nothing else, so it stays the same as long as the Checks and Scopes do. That is what tells the document a Run fetched apart from the Local Definitions it was built from, and one upload from the next. Worked out from the parsed document, written out one way on both editions: the two editions' ConvertTo-Json escape differently, and a mark that depended on the edition would differ between a Technician on 5.1 and a maintainer on 7. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][AllowNull()]$Content) $kept = [ordered]@{} if ($Content) { foreach ($property in $Content.PSObject.Properties) { if ($property.Name -notin 'Version', 'Generated') { $kept[$property.Name] = $property.Value } } } $text = ConvertTo-CanonicalJson -Value $kept $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 ConvertTo-CanonicalJson { <# .SYNOPSIS Parsed JSON written back one way, whichever edition parsed it. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Value) $invariant = [Globalization.CultureInfo]::InvariantCulture if ($null -eq $Value) { return 'null' } if ($Value -is [bool]) { return $(if ($Value) { 'true' } else { 'false' }) } if ($Value -is [datetime]) { $Value = $Value.ToString('o', $invariant) } if ($Value -is [string]) { $escaped = [regex]::Replace($Value, '[\\"\x00-\x1f]', { param($m) '\u{0:x4}' -f [int][char]$m.Value }) return '"' + $escaped + '"' } # Numbers. Not IFormattable: PowerShell's own wrapper around every object is one. if ($Value -is [System.ValueType]) { return [string]::Format($invariant, '{0}', $Value) } if ($Value -is [System.Collections.IDictionary]) { $pairs = foreach ($key in $Value.Keys) { '{0}:{1}' -f (ConvertTo-CanonicalJson -Value ([string]$key)), (ConvertTo-CanonicalJson -Value $Value[$key]) } return '{' + ($pairs -join ',') + '}' } if ($Value -is [System.Collections.IEnumerable]) { $items = foreach ($item in $Value) { ConvertTo-CanonicalJson -Value $item } return '[' + ($items -join ',') + ']' } $pairs = foreach ($property in $Value.PSObject.Properties) { '{0}:{1}' -f (ConvertTo-CanonicalJson -Value $property.Name), (ConvertTo-CanonicalJson -Value $property.Value) } '{' + ($pairs -join ',') + '}' } 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 # Of the Checks alone, the same for every build of them. ChecksHash = Get-DefinitionChecksHash -Content $content Check = $checks # The named parts of the Checks a Run can be narrowed to. None in a document # that has none: see Private/Scope.ps1. Scope = @($content.Scopes | Where-Object { $_ } | ForEach-Object { ConvertTo-ScopeDefinition -Entry $_ }) } } 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 Scope = @((Get-DataCollection $document 'Scope') | ForEach-Object { $_ }) Provenance = [pscustomobject]@{ PSTypeName = 'Gutcheck.Provenance' Source = $source Version = $document.Version Generated = $document.Generated Fingerprint = Get-DataProperty $document 'Fingerprint' ChecksHash = Get-DataProperty $document 'ChecksHash' 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')" $checksHash = "$(Get-DataProperty $Provenance 'ChecksHash')" if ($fingerprint -and $checksHash) { $version = Get-Text 'Provenance.VersionWithChecksHash' $version $fingerprint $checksHash } elseif ($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) } |