mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-10-05 14:46:55 +01:00
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0d8b7764-f15a-49ea-8d50-d9147334f8af
298 lines
10 KiB
PowerShell
298 lines
10 KiB
PowerShell
# Shared filesystem guards for catalog and exact article retrieval.
|
|
Set-StrictMode -Version Latest
|
|
|
|
function Resolve-KnowledgeRoot {
|
|
param([string] $Root)
|
|
|
|
$item = Get-Item -LiteralPath $Root -Force -ErrorAction Stop
|
|
if ($item.PSProvider.Name -ne 'FileSystem' -or -not $item.PSIsContainer) {
|
|
throw "BCQuality root must be a filesystem directory: $Root"
|
|
}
|
|
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
|
|
throw "Linked BCQuality roots are not supported: $Root"
|
|
}
|
|
return $item.FullName
|
|
}
|
|
|
|
function Assert-KnowledgePath {
|
|
param(
|
|
[string] $Path,
|
|
[ValidateSet('article', 'sample')] [string] $Kind = 'article'
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Path) -or
|
|
$Path -cnotmatch '^(microsoft|community|custom)/knowledge/[^/]+/.+' -or
|
|
$Path -match '[\\:*?"<>|\x00-\x1f]' -or
|
|
@($Path.Split('/') | Where-Object { $_ -in '', '.', '..' -or $_ -match '[. ]$' }).Count) {
|
|
throw "Invalid knowledge path: $Path"
|
|
}
|
|
if (($Kind -eq 'article' -and -not $Path.EndsWith('.md', [StringComparison]::Ordinal)) -or
|
|
($Kind -eq 'sample' -and $Path -cnotmatch '\.(good|bad)\.[a-z0-9]+$')) {
|
|
throw "Expected an exact $Kind path: $Path"
|
|
}
|
|
}
|
|
|
|
function Resolve-KnowledgePath {
|
|
param(
|
|
[string] $Root,
|
|
[string] $Path,
|
|
[ValidateSet('article', 'sample')] [string] $Kind = 'article'
|
|
)
|
|
|
|
Assert-KnowledgePath -Path $Path -Kind $Kind
|
|
$current = $Root
|
|
foreach ($part in $Path.Split('/')) {
|
|
$items = @(
|
|
Get-ChildItem -LiteralPath $current -Filter $part -Force -ErrorAction Stop |
|
|
Where-Object Name -CEQ $part
|
|
)
|
|
if ($items.Count -ne 1) {
|
|
throw "Knowledge path does not exist with exact casing: $Path"
|
|
}
|
|
$item = $items[0]
|
|
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
|
|
throw "Linked knowledge paths are not supported: $Path"
|
|
}
|
|
$current = $item.FullName
|
|
}
|
|
if ($item.PSIsContainer) {
|
|
throw "Knowledge path is not a file: $Path"
|
|
}
|
|
return $item.FullName
|
|
}
|
|
|
|
function Read-KnowledgeText {
|
|
param([string] $Path)
|
|
|
|
$bytes = [IO.File]::ReadAllBytes($Path)
|
|
try {
|
|
$text = [Text.UTF8Encoding]::new($false, $true).GetString($bytes)
|
|
}
|
|
catch {
|
|
throw "Knowledge file is not valid strict UTF-8: $Path"
|
|
}
|
|
return [pscustomobject]@{
|
|
text = $text
|
|
bytes = $bytes.Length
|
|
sha256 = [Convert]::ToHexString(
|
|
[Security.Cryptography.SHA256]::HashData($bytes)
|
|
).ToLowerInvariant()
|
|
}
|
|
}
|
|
|
|
function Get-NormalizedKnowledgeVersions {
|
|
param([string[]] $Values)
|
|
|
|
foreach ($value in $Values) {
|
|
if ($value -match '^"([^"]*)"$' -or $value -match "^'([^']*)'$") {
|
|
$Matches[1]
|
|
}
|
|
else {
|
|
$value
|
|
}
|
|
}
|
|
}
|
|
|
|
function Get-KnowledgeMetadataProblem {
|
|
param([Collections.IDictionary] $Row)
|
|
|
|
if ($Row['parsed'] -isnot [bool] -or -not $Row['parsed']) {
|
|
return 'unparsed frontmatter'
|
|
}
|
|
if ($Row['domain'] -isnot [string] -or
|
|
$Row['domain'] -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$') {
|
|
return 'missing/invalid domain'
|
|
}
|
|
foreach ($field in @('bc-version', 'technologies', 'countries', 'application-area', 'keywords')) {
|
|
if ($Row[$field] -isnot [array] -or -not $Row[$field].Count) {
|
|
return "missing/invalid $field"
|
|
}
|
|
foreach ($value in $Row[$field]) {
|
|
if ($value -isnot [string] -or [string]::IsNullOrWhiteSpace($value)) {
|
|
return "invalid $field value"
|
|
}
|
|
}
|
|
}
|
|
foreach ($field in @('title', 'description')) {
|
|
if ($Row[$field] -isnot [string] -or
|
|
[string]::IsNullOrWhiteSpace($Row[$field]) -or
|
|
$Row[$field] -match '[\r\n]') {
|
|
return "missing/invalid $field"
|
|
}
|
|
}
|
|
|
|
$versions = @(Get-NormalizedKnowledgeVersions -Values $Row['bc-version'])
|
|
if ($versions -ccontains 'all') {
|
|
if ($versions.Count -ne 1) {
|
|
return 'mixed bc-version sentinel'
|
|
}
|
|
}
|
|
elseif ($versions.Count -eq 1 -and $versions[0] -match '^(\d+)\.\.(\d+)?$') {
|
|
$start = [bigint]::Parse($Matches[1])
|
|
if ($start -le 0 -or ($Matches[2] -and [bigint]::Parse($Matches[2]) -le 0)) {
|
|
return 'invalid bc-version range bound'
|
|
}
|
|
if ($Matches[2] -and $start -gt [bigint]::Parse($Matches[2])) {
|
|
return 'descending bc-version range'
|
|
}
|
|
}
|
|
else {
|
|
foreach ($version in $versions) {
|
|
if ($version -notmatch '^\d+$' -or [bigint]::Parse($version) -le 0) {
|
|
return 'invalid bc-version'
|
|
}
|
|
}
|
|
}
|
|
if (@($Row.technologies | Where-Object { $_ -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$' }).Count) {
|
|
return 'invalid technologies'
|
|
}
|
|
if ($Row.technologies -ccontains 'all') {
|
|
return 'invalid technologies sentinel'
|
|
}
|
|
if ($Row.countries -ccontains 'w1') {
|
|
if ($Row.countries.Count -ne 1) {
|
|
return 'mixed countries sentinel'
|
|
}
|
|
}
|
|
elseif (@($Row.countries | Where-Object { $_ -cnotmatch '^[a-z]{2}$' }).Count) {
|
|
return 'invalid countries'
|
|
}
|
|
if (@($Row['application-area'] | Where-Object { $_ -cnotmatch '^(all|[a-z0-9]+(-[a-z0-9]+)*)$' }).Count) {
|
|
return 'invalid application-area'
|
|
}
|
|
if ($Row['application-area'] -ccontains 'all' -and $Row['application-area'].Count -ne 1) {
|
|
return 'mixed application-area sentinel'
|
|
}
|
|
if (@($Row.keywords | Where-Object { $_ -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$' }).Count) {
|
|
return 'invalid keywords'
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function Get-PreparedManifestSha256 {
|
|
param(
|
|
[string[]] $Paths,
|
|
[Collections.Generic.Dictionary[string, object]] $ByPath
|
|
)
|
|
|
|
$hash = [Security.Cryptography.IncrementalHash]::CreateHash(
|
|
[Security.Cryptography.HashAlgorithmName]::SHA256
|
|
)
|
|
try {
|
|
$hash.AppendData([byte[]][char]'[')
|
|
for ($i = 0; $i -lt $Paths.Count; $i++) {
|
|
if ($i) {
|
|
$hash.AppendData([byte[]][char]',')
|
|
}
|
|
$row = [ordered]@{
|
|
path = $Paths[$i]
|
|
sha256 = $ByPath[$Paths[$i]].sourceSha256
|
|
}
|
|
$hash.AppendData([Text.Encoding]::UTF8.GetBytes(
|
|
(ConvertTo-Json -InputObject $row -Depth 8 -Compress)
|
|
))
|
|
}
|
|
$hash.AppendData([byte[]][char]']')
|
|
return [Convert]::ToHexString($hash.GetHashAndReset()).ToLowerInvariant()
|
|
}
|
|
finally {
|
|
$hash.Dispose()
|
|
}
|
|
}
|
|
|
|
function Read-PreparedKnowledgeIndex {
|
|
param(
|
|
[string] $IndexPath,
|
|
[string] $Recovery
|
|
)
|
|
|
|
if (-not (Test-Path -LiteralPath $IndexPath -PathType Leaf)) {
|
|
throw "Knowledge index missing: $IndexPath. $Recovery"
|
|
}
|
|
$indexItem = Get-Item -LiteralPath $IndexPath -Force -ErrorAction Stop
|
|
if ($indexItem.PSProvider.Name -ne 'FileSystem' -or
|
|
($indexItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
|
throw "Knowledge index must be an unlinked filesystem file: $IndexPath. $Recovery"
|
|
}
|
|
|
|
$content = Read-KnowledgeText -Path $indexItem.FullName
|
|
try {
|
|
$index = $content.text.TrimStart([char]0xfeff) |
|
|
ConvertFrom-Json -AsHashtable -ErrorAction Stop
|
|
}
|
|
catch {
|
|
throw "Malformed knowledge index JSON: $($_.Exception.Message). $Recovery"
|
|
}
|
|
if ($index -isnot [Collections.IDictionary] -or
|
|
$index.version -ne 1 -or
|
|
$index.articles -isnot [array] -or
|
|
$index.articleCount -ne $index.articles.Count -or
|
|
$index.enabledLayers -isnot [array] -or
|
|
$index.knowledgeAllow -isnot [array] -or
|
|
$index.knowledgeDeny -isnot [array] -or
|
|
$index.sourceSnapshot -isnot [string] -or
|
|
$index.sourceSnapshot -cnotmatch '^[a-f0-9]{64}$') {
|
|
throw "Invalid knowledge index envelope. $Recovery"
|
|
}
|
|
|
|
$generatedAt = [DateTimeOffset]::MinValue
|
|
if ($index.generatedAt -is [DateTime]) {
|
|
$generatedAt = [DateTimeOffset]$index.generatedAt
|
|
}
|
|
elseif (-not [DateTimeOffset]::TryParse(
|
|
[string]$index.generatedAt,
|
|
[Globalization.CultureInfo]::InvariantCulture,
|
|
[Globalization.DateTimeStyles]::RoundtripKind,
|
|
[ref]$generatedAt
|
|
)) {
|
|
throw "Invalid knowledge index generatedAt. $Recovery"
|
|
}
|
|
if ($generatedAt -gt [DateTimeOffset]::UtcNow.AddMinutes(1)) {
|
|
throw "Invalid knowledge index generatedAt. $Recovery"
|
|
}
|
|
|
|
$byPath = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal)
|
|
foreach ($row in $index.articles) {
|
|
if ($row -isnot [Collections.IDictionary] -or $row.path -isnot [string]) {
|
|
throw "Index row has no exact path. $Recovery"
|
|
}
|
|
Assert-KnowledgePath -Path $row.path
|
|
if ($row.layer -cne $row.path.Split('/')[0] -or
|
|
$row.layer -cnotin @('microsoft', 'community', 'custom')) {
|
|
throw "Invalid index layer: $($row.path). $Recovery"
|
|
}
|
|
if ($row.sourceSha256 -isnot [string] -or
|
|
$row.sourceSha256 -cnotmatch '^[a-f0-9]{64}$') {
|
|
throw "Invalid source hash in knowledge index: $($row.path). $Recovery"
|
|
}
|
|
if (-not $byPath.TryAdd($row.path, $row)) {
|
|
throw "Duplicate index path: $($row.path). $Recovery"
|
|
}
|
|
}
|
|
|
|
$paths = [string[]]@($byPath.Keys)
|
|
[Array]::Sort($paths, [StringComparer]::Ordinal)
|
|
if ((Get-PreparedManifestSha256 -Paths $paths -ByPath $byPath) -cne $index.sourceSnapshot) {
|
|
throw "Stale or internally inconsistent prepared index snapshot. $Recovery"
|
|
}
|
|
|
|
return [pscustomobject]@{
|
|
index = $index
|
|
content = $content
|
|
byPath = $byPath
|
|
paths = $paths
|
|
}
|
|
}
|
|
|
|
function Assert-SampleLink {
|
|
param(
|
|
[string] $ArticleText,
|
|
[string] $SamplePath
|
|
)
|
|
|
|
$sampleName = [IO.Path]::GetFileName($SamplePath)
|
|
$expected = '[`' + $sampleName + '`](' + $sampleName + ')'
|
|
if (-not $ArticleText.Contains($expected, [StringComparison]::Ordinal)) {
|
|
throw "Sample is not linked by its article using the READ convention: $sampleName"
|
|
}
|
|
}
|